remote-pi 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +384 -0
- package/dist/config.d.ts +18 -0
- package/dist/config.js +51 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +77 -0
- package/dist/index.js +1285 -0
- package/dist/index.js.map +1 -0
- package/dist/pairing/crypto.d.ts +8 -0
- package/dist/pairing/crypto.js +22 -0
- package/dist/pairing/crypto.js.map +1 -0
- package/dist/pairing/handshake.d.ts +28 -0
- package/dist/pairing/handshake.js +113 -0
- package/dist/pairing/handshake.js.map +1 -0
- package/dist/pairing/noise-sha256.d.ts +16 -0
- package/dist/pairing/noise-sha256.js +103 -0
- package/dist/pairing/noise-sha256.js.map +1 -0
- package/dist/pairing/qr.d.ts +41 -0
- package/dist/pairing/qr.js +96 -0
- package/dist/pairing/qr.js.map +1 -0
- package/dist/pairing/storage.d.ts +10 -0
- package/dist/pairing/storage.js +65 -0
- package/dist/pairing/storage.js.map +1 -0
- package/dist/protocol/codec.d.ts +7 -0
- package/dist/protocol/codec.js +46 -0
- package/dist/protocol/codec.js.map +1 -0
- package/dist/protocol/types.d.ts +119 -0
- package/dist/protocol/types.js +2 -0
- package/dist/protocol/types.js.map +1 -0
- package/dist/rooms.d.ts +9 -0
- package/dist/rooms.js +22 -0
- package/dist/rooms.js.map +1 -0
- package/dist/session/agent_bridge.d.ts +55 -0
- package/dist/session/agent_bridge.js +146 -0
- package/dist/session/agent_bridge.js.map +1 -0
- package/dist/session/broker.d.ts +37 -0
- package/dist/session/broker.js +206 -0
- package/dist/session/broker.js.map +1 -0
- package/dist/session/envelope.d.ts +24 -0
- package/dist/session/envelope.js +89 -0
- package/dist/session/envelope.js.map +1 -0
- package/dist/session/global_config.d.ts +14 -0
- package/dist/session/global_config.js +51 -0
- package/dist/session/global_config.js.map +1 -0
- package/dist/session/leader_election.d.ts +16 -0
- package/dist/session/leader_election.js +78 -0
- package/dist/session/leader_election.js.map +1 -0
- package/dist/session/local_config.d.ts +18 -0
- package/dist/session/local_config.js +47 -0
- package/dist/session/local_config.js.map +1 -0
- package/dist/session/peer.d.ts +80 -0
- package/dist/session/peer.js +268 -0
- package/dist/session/peer.js.map +1 -0
- package/dist/session/setup_wizard.d.ts +32 -0
- package/dist/session/setup_wizard.js +60 -0
- package/dist/session/setup_wizard.js.map +1 -0
- package/dist/session/tool_gate.d.ts +5 -0
- package/dist/session/tool_gate.js +11 -0
- package/dist/session/tool_gate.js.map +1 -0
- package/dist/session/tools.d.ts +16 -0
- package/dist/session/tools.js +123 -0
- package/dist/session/tools.js.map +1 -0
- package/dist/session/wizard.d.ts +13 -0
- package/dist/session/wizard.js +20 -0
- package/dist/session/wizard.js.map +1 -0
- package/dist/settings.d.ts +15 -0
- package/dist/settings.js +52 -0
- package/dist/settings.js.map +1 -0
- package/dist/transport/peer_channel.d.ts +37 -0
- package/dist/transport/peer_channel.js +85 -0
- package/dist/transport/peer_channel.js.map +1 -0
- package/dist/transport/relay_client.d.ts +81 -0
- package/dist/transport/relay_client.js +154 -0
- package/dist/transport/relay_client.js.map +1 -0
- package/dist/ui/footer.d.ts +32 -0
- package/dist/ui/footer.js +32 -0
- package/dist/ui/footer.js.map +1 -0
- package/package.json +77 -0
- package/skills/agent-network/SKILL.md +429 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-extension — remote-pi slash commands + AgentBridge wiring
|
|
3
|
+
*
|
|
4
|
+
* Exported as ExtensionFactory (default export) to be loaded by Pi SDK:
|
|
5
|
+
* pi -e $(pwd)/dist/index.js
|
|
6
|
+
*
|
|
7
|
+
* State machine: idle → started → paired
|
|
8
|
+
* /remote-pi start connects to relay (idle → started)
|
|
9
|
+
* /remote-pi pair shows QR for new peers (started, async → paired via auto-listener)
|
|
10
|
+
* /remote-pi stop closes everything (any → idle)
|
|
11
|
+
*
|
|
12
|
+
* Pairing (post plano 06 — sem Noise XX):
|
|
13
|
+
* App envia inner `pair_request` (id, token, device_name) sobre canal opaco.
|
|
14
|
+
* Pi valida o token via qrSession.consumeToken, salva peer em peers.json
|
|
15
|
+
* {name, remote_epk, paired_at} e responde com `pair_ok` (ou `pair_error`).
|
|
16
|
+
* `ct` é base64(JSON.stringify(inner)) — sem cifra, sem MAC.
|
|
17
|
+
*
|
|
18
|
+
* Reconexão de peer conhecido:
|
|
19
|
+
* Se uma mensagem chega em estado `started` vinda de um epk presente em
|
|
20
|
+
* peers.json, o auto-listener promove direto pra `paired` sem novo
|
|
21
|
+
* pair_request, criando o PlainPeerChannel e roteando a mensagem.
|
|
22
|
+
*
|
|
23
|
+
* Architecture note — why we don't use AgentBridge directly here:
|
|
24
|
+
* AgentBridge.beforeToolCallHook is designed to be passed to createAgentSession().
|
|
25
|
+
* Inside an extension Pi already owns the AgentSession, so we can't re-bind
|
|
26
|
+
* beforeToolCall after the fact. The equivalent is pi.on("tool_call", …) which
|
|
27
|
+
* fires BEFORE execution and supports { block: true }.
|
|
28
|
+
* AgentBridge (src/session/agent_bridge.ts) remains the tested, mockable unit
|
|
29
|
+
* for integration tests.
|
|
30
|
+
*/
|
|
31
|
+
import { randomUUID } from "node:crypto";
|
|
32
|
+
import { buildQRUri, displayQR, qrSession, startQRRotation } from "./pairing/qr.js";
|
|
33
|
+
import { addPeer, getOrCreateEd25519Keypair, listPeers, removePeer, } from "./pairing/storage.js";
|
|
34
|
+
import { RelayClient, RoomAlreadyOpenError } from "./transport/relay_client.js";
|
|
35
|
+
import { PlainPeerChannel } from "./transport/peer_channel.js";
|
|
36
|
+
import { roomIdForCwd } from "./rooms.js";
|
|
37
|
+
import { SessionPeer } from "./session/peer.js";
|
|
38
|
+
import { registerAgentTools } from "./session/tools.js";
|
|
39
|
+
import { ensureGlobalDirs, listSessions, sessionAuditPath, sessionHasSock, sessionSockPath, skillsDir, } from "./session/global_config.js";
|
|
40
|
+
import { defaultAgentName, effectiveAutoStartRelay, loadLocalConfig, localConfigExists, saveLocalConfig, } from "./session/local_config.js";
|
|
41
|
+
import { runSetupWizard } from "./session/setup_wizard.js";
|
|
42
|
+
import { updateFooter } from "./ui/footer.js";
|
|
43
|
+
import { join } from "node:path";
|
|
44
|
+
import { fileURLToPath } from "node:url";
|
|
45
|
+
import { mkdirSync, copyFileSync, existsSync, unlinkSync } from "node:fs";
|
|
46
|
+
import { kDefaultRelayUrl, resolveRelayUrl, saveConfig, isValidRelayUrl, } from "./config.js";
|
|
47
|
+
let _state = "idle";
|
|
48
|
+
let _relay = null;
|
|
49
|
+
let _relayUrl = null; // URL used by current _relay connection
|
|
50
|
+
let _peerChannel = null;
|
|
51
|
+
let _appPeerId = null; // active app peer ID (Ed25519 pk base64 std)
|
|
52
|
+
let _peerShort = "";
|
|
53
|
+
let _myRoomId = null; // this Pi's room id (derived from cwd)
|
|
54
|
+
let _myRoomMeta = null;
|
|
55
|
+
let _currentModel = undefined; // last-known model name
|
|
56
|
+
// ── Agent-network session (plano 19) ──────────────────────────────────────────
|
|
57
|
+
let _sessionPeer = null;
|
|
58
|
+
let _sessionName = null;
|
|
59
|
+
let _sessionPeerCount = 0;
|
|
60
|
+
// Cached state of global pairings (`peers.json`). Pairing is per-machine, so a
|
|
61
|
+
// device paired in any Pi process is paired everywhere. Refreshed on boot,
|
|
62
|
+
// after addPeer (handle_pair_request), and after removePeer (revoke).
|
|
63
|
+
let _hasGlobalPairings = false;
|
|
64
|
+
/** Reads peers.json and updates the global-pairings cache + footer. Fire and
|
|
65
|
+
* forget; failures keep the previous cached value. */
|
|
66
|
+
function _refreshPairingsCache() {
|
|
67
|
+
void listPeers()
|
|
68
|
+
.then((peers) => {
|
|
69
|
+
_hasGlobalPairings = peers.length > 0;
|
|
70
|
+
_refreshFooter();
|
|
71
|
+
})
|
|
72
|
+
.catch(() => { });
|
|
73
|
+
}
|
|
74
|
+
/** Re-queries the broker for the authoritative peer list. The broker's map is
|
|
75
|
+
* the source of truth — incremental +1/-1 counters drift after failover, lost
|
|
76
|
+
* `peer_left` broadcasts (e.g., leader leaves), or any dropped event. Called
|
|
77
|
+
* on every `peer_joined`/`peer_left` and once on join. Fire-and-forget. */
|
|
78
|
+
function _refreshSessionPeerCount(peer, ctx) {
|
|
79
|
+
void peer.request("broker", { type: "list_peers" }, 2000)
|
|
80
|
+
.then((reply) => {
|
|
81
|
+
const peers = reply.body?.peers;
|
|
82
|
+
if (Array.isArray(peers)) {
|
|
83
|
+
_sessionPeerCount = peers.length;
|
|
84
|
+
_refreshFooter(ctx);
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
.catch(() => { });
|
|
88
|
+
}
|
|
89
|
+
/** Friendly model name for room_meta (plano 18). undefined when SDK has none yet. */
|
|
90
|
+
function _currentModelName() {
|
|
91
|
+
return _currentModel;
|
|
92
|
+
}
|
|
93
|
+
/** Refreshes the Pi TUI footer slots from current module state. Safe no-op when ctx lacks ui. */
|
|
94
|
+
function _refreshFooter(ctx) {
|
|
95
|
+
const target = ctx ?? _lastCtx;
|
|
96
|
+
const ui = target?.ui;
|
|
97
|
+
if (!ui || typeof ui.setStatus !== "function" || typeof ui.setTitle !== "function")
|
|
98
|
+
return;
|
|
99
|
+
const state = {
|
|
100
|
+
session: _sessionName ?? undefined,
|
|
101
|
+
peerCount: _sessionPeerCount,
|
|
102
|
+
relayOn: _state !== "idle",
|
|
103
|
+
devicePaired: _state === "paired" ? _peerShort : undefined,
|
|
104
|
+
hasPairings: _hasGlobalPairings,
|
|
105
|
+
agentName: _sessionPeer?.name(),
|
|
106
|
+
};
|
|
107
|
+
updateFooter({ ui: { setStatus: ui.setStatus.bind(ui), setTitle: ui.setTitle.bind(ui) } }, state);
|
|
108
|
+
}
|
|
109
|
+
// Epoch ms when the state machine entered 'started' (last /remote-pi start).
|
|
110
|
+
// Used by session_sync to let the app detect Pi restarts (and force a full
|
|
111
|
+
// replay). Cleared on _goIdle.
|
|
112
|
+
let _sessionStartedAt = null;
|
|
113
|
+
let _messageBuffer = [];
|
|
114
|
+
/** Test-only override of the message buffer. */
|
|
115
|
+
export function _setMessageBufferForTest(msgs) {
|
|
116
|
+
_messageBuffer = msgs;
|
|
117
|
+
}
|
|
118
|
+
/** Test-only accessor: returns a defensive copy of the buffer. */
|
|
119
|
+
export function _getMessageBufferForTest() {
|
|
120
|
+
return [..._messageBuffer];
|
|
121
|
+
}
|
|
122
|
+
/** Test-only override of session started timestamp. */
|
|
123
|
+
export function _setSessionStartedAtForTest(ts) {
|
|
124
|
+
_sessionStartedAt = ts;
|
|
125
|
+
}
|
|
126
|
+
/** Test-only: reset the cached model name (between tests). */
|
|
127
|
+
export function _setCurrentModelForTest(name) {
|
|
128
|
+
_currentModel = name;
|
|
129
|
+
}
|
|
130
|
+
// Per-turn messaging state
|
|
131
|
+
let _currentTurnId = null;
|
|
132
|
+
// Module-level pi reference
|
|
133
|
+
let _pi = null;
|
|
134
|
+
let _stopAutoListener = null;
|
|
135
|
+
// Cached keypair (loaded once, reused across start/pair cycles)
|
|
136
|
+
let _cachedEd25519 = null;
|
|
137
|
+
// ── Session sync limit (mirror cache cap) ─────────────────────────────────────
|
|
138
|
+
//
|
|
139
|
+
// Configurable via REMOTE_PI_SYNC_LIMIT env var (positive int, default 30).
|
|
140
|
+
// Read on every session_sync so QA can `export REMOTE_PI_SYNC_LIMIT=N` between
|
|
141
|
+
// runs without restarting the extension. The value is also clamped against
|
|
142
|
+
// the client-provided `limit` (server is authoritative).
|
|
143
|
+
const SYNC_LIMIT_DEFAULT = 30;
|
|
144
|
+
function _getSyncLimit() {
|
|
145
|
+
const raw = process.env["REMOTE_PI_SYNC_LIMIT"];
|
|
146
|
+
const parsed = raw ? parseInt(raw, 10) : NaN;
|
|
147
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : SYNC_LIMIT_DEFAULT;
|
|
148
|
+
}
|
|
149
|
+
// ── Relay reconnect state ─────────────────────────────────────────────────────
|
|
150
|
+
// Backoffs in ms: 1s, 2s, 5s, 10s, 30s, then stays at 30s.
|
|
151
|
+
const RECONNECT_BACKOFFS_MS = [1_000, 2_000, 5_000, 10_000, 30_000];
|
|
152
|
+
let _reconnectTimer = null;
|
|
153
|
+
let _reconnectAttempt = 0;
|
|
154
|
+
/** Test-only: exposes pending reconnect timer state. */
|
|
155
|
+
export function _hasPendingReconnect() {
|
|
156
|
+
return _reconnectTimer !== null;
|
|
157
|
+
}
|
|
158
|
+
/** Exported for tests. */
|
|
159
|
+
export function _getState() { return _state; }
|
|
160
|
+
// ── Peer lookup helpers ───────────────────────────────────────────────────────
|
|
161
|
+
async function _findKnownPeer(appPeerIdStd) {
|
|
162
|
+
const peers = await listPeers();
|
|
163
|
+
return peers.find((p) => p.remote_epk === appPeerIdStd) ?? null;
|
|
164
|
+
}
|
|
165
|
+
// ── Transition helpers ────────────────────────────────────────────────────────
|
|
166
|
+
/**
|
|
167
|
+
* Full teardown: stop listener, detach channel, close relay → idle.
|
|
168
|
+
*
|
|
169
|
+
* `byeReason` (optional): when present and the channel is up, sends a
|
|
170
|
+
* `{type:"bye", reason}` to the app before detaching so it sees offline
|
|
171
|
+
* immediately instead of waiting ~50s for a ping miss. Fire-and-forget —
|
|
172
|
+
* if the WS already failed (e.g., `relay.on("close")` callback) skip it
|
|
173
|
+
* by omitting the reason; app falls back to ping miss naturally.
|
|
174
|
+
*/
|
|
175
|
+
function _goIdle(byeReason) {
|
|
176
|
+
if (_peerChannel && byeReason && _state !== "idle") {
|
|
177
|
+
try {
|
|
178
|
+
_peerChannel.send({ type: "bye", reason: byeReason });
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
// peer already offline — fine
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
// Cancel any pending reconnect attempt. Critical: /remote-pi stop must
|
|
185
|
+
// win the race against a scheduled reconnect.
|
|
186
|
+
if (_reconnectTimer !== null) {
|
|
187
|
+
clearTimeout(_reconnectTimer);
|
|
188
|
+
_reconnectTimer = null;
|
|
189
|
+
}
|
|
190
|
+
_reconnectAttempt = 0;
|
|
191
|
+
_stopAutoListener?.();
|
|
192
|
+
_stopAutoListener = null;
|
|
193
|
+
_peerChannel?.detach();
|
|
194
|
+
_peerChannel = null;
|
|
195
|
+
_appPeerId = null;
|
|
196
|
+
_peerShort = "";
|
|
197
|
+
_currentTurnId = null;
|
|
198
|
+
_relay?.close();
|
|
199
|
+
_relay = null;
|
|
200
|
+
_relayUrl = null;
|
|
201
|
+
// Preserve _sessionStartedAt + _messageBuffer across stop/start cycles.
|
|
202
|
+
// The Pi agent session outlives the relay connection — `message_end` keeps
|
|
203
|
+
// firing for terminal turns even while idle, and the buffer must survive
|
|
204
|
+
// so those turns appear in the next session_sync. Only a Pi process
|
|
205
|
+
// restart resets these (init-time values).
|
|
206
|
+
_state = "idle";
|
|
207
|
+
_refreshFooter();
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Called when the relay WS closes unexpectedly (network drop, relay restart,
|
|
211
|
+
* etc.). Does a **partial** teardown — keeps `_sessionStartedAt`, `_messageBuffer`,
|
|
212
|
+
* `_relayUrl`, `_cachedEd25519`, `_peerShort` so the session can resume on
|
|
213
|
+
* reconnect — and schedules an `_attemptReconnect`.
|
|
214
|
+
*
|
|
215
|
+
* Peer (app) reconnect after a successful relay reconnect is handled by the
|
|
216
|
+
* existing auto-listener via `peers.json` lookup, so we don't need to track
|
|
217
|
+
* the prior peer here; we just go back to `started` and wait.
|
|
218
|
+
*/
|
|
219
|
+
function _onRelayClose() {
|
|
220
|
+
if (_state === "idle")
|
|
221
|
+
return; // already torn down (e.g. /remote-pi stop)
|
|
222
|
+
_stopAutoListener?.();
|
|
223
|
+
_stopAutoListener = null;
|
|
224
|
+
_peerChannel?.detach();
|
|
225
|
+
_peerChannel = null;
|
|
226
|
+
_appPeerId = null;
|
|
227
|
+
_currentTurnId = null;
|
|
228
|
+
_relay = null; // _relayUrl preserved for retry
|
|
229
|
+
_state = "started";
|
|
230
|
+
_refreshFooter();
|
|
231
|
+
_scheduleReconnect();
|
|
232
|
+
}
|
|
233
|
+
function _scheduleReconnect() {
|
|
234
|
+
if (_reconnectTimer !== null)
|
|
235
|
+
return; // already scheduled
|
|
236
|
+
if (!_cachedEd25519 || !_relayUrl)
|
|
237
|
+
return; // can't reconnect without these
|
|
238
|
+
if (_getState() === "idle")
|
|
239
|
+
return; // stopped while we were here
|
|
240
|
+
const idx = Math.min(_reconnectAttempt, RECONNECT_BACKOFFS_MS.length - 1);
|
|
241
|
+
const delay = RECONNECT_BACKOFFS_MS[idx];
|
|
242
|
+
_reconnectAttempt += 1;
|
|
243
|
+
_reconnectTimer = setTimeout(() => {
|
|
244
|
+
_reconnectTimer = null;
|
|
245
|
+
void _attemptReconnect();
|
|
246
|
+
}, delay);
|
|
247
|
+
}
|
|
248
|
+
async function _attemptReconnect() {
|
|
249
|
+
// `_state` may transition to "idle" between awaits via _goIdle; read via
|
|
250
|
+
// _getState() to defeat TS narrowing on the module-level let.
|
|
251
|
+
if (_getState() === "idle")
|
|
252
|
+
return;
|
|
253
|
+
if (!_cachedEd25519 || !_relayUrl)
|
|
254
|
+
return;
|
|
255
|
+
const edKp = _cachedEd25519;
|
|
256
|
+
const url = _relayUrl;
|
|
257
|
+
const relay = new RelayClient(url, edKp);
|
|
258
|
+
try {
|
|
259
|
+
// Replay the same room identity from _cmdStart. Without this the relay
|
|
260
|
+
// would log this WS as a default-room peer and the app would see a
|
|
261
|
+
// phantom "legacy session" appear (regression of plano 17 + 18).
|
|
262
|
+
await relay.connect({
|
|
263
|
+
...(_myRoomId ? { roomId: _myRoomId } : {}),
|
|
264
|
+
...(_myRoomMeta ? { roomMeta: _myRoomMeta } : {}),
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
if (_getState() === "idle")
|
|
269
|
+
return;
|
|
270
|
+
_scheduleReconnect();
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (_getState() === "idle") {
|
|
274
|
+
// Stop fired while connect was succeeding — drop the new relay.
|
|
275
|
+
relay.close();
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
_relay = relay;
|
|
279
|
+
_reconnectAttempt = 0;
|
|
280
|
+
relay.on("close", _onRelayClose);
|
|
281
|
+
_stopAutoListener = _installAutoListener(relay);
|
|
282
|
+
// _state stays "started"; peer reconnect (if previously paired) flows
|
|
283
|
+
// through _installAutoListener → _findKnownPeer → _promoteToPaired
|
|
284
|
+
// automatically when the app sends any inner.
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* App-level peer disconnect (relay still up).
|
|
288
|
+
* Transitions paired → started and re-installs the auto-listener.
|
|
289
|
+
* Exported so tests can trigger it directly; in production it will be
|
|
290
|
+
* called when the relay sends a peer-disconnect notification (future).
|
|
291
|
+
*/
|
|
292
|
+
export function _onPeerDisconnect() {
|
|
293
|
+
if (_state !== "paired")
|
|
294
|
+
return;
|
|
295
|
+
_peerChannel?.detach();
|
|
296
|
+
_peerChannel = null;
|
|
297
|
+
_appPeerId = null;
|
|
298
|
+
_peerShort = "";
|
|
299
|
+
_currentTurnId = null;
|
|
300
|
+
_state = "started";
|
|
301
|
+
_refreshFooter();
|
|
302
|
+
_lastCtx?.ui.notify("[remote-pi] App disconnected, listening for reconnect", "info");
|
|
303
|
+
// Re-install auto-listener so reconnect works
|
|
304
|
+
if (_relay) {
|
|
305
|
+
_stopAutoListener?.();
|
|
306
|
+
_stopAutoListener = _installAutoListener(_relay);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Promotes started → paired by installing a PlainPeerChannel for `appPeerId`.
|
|
311
|
+
* Routes `firstInner` immediately so the message that triggered reconnection
|
|
312
|
+
* isn't dropped.
|
|
313
|
+
*/
|
|
314
|
+
function _promoteToPaired(relay, appPeerId, peerName, firstInner) {
|
|
315
|
+
const peerShort = appPeerId.slice(0, 8);
|
|
316
|
+
const channel = new PlainPeerChannel(relay, appPeerId, _myRoomId ?? undefined, (msg) => routeClientMessage(msg, _lastCtx ?? _noopCtx), () => _onPeerDisconnect());
|
|
317
|
+
_peerChannel = channel;
|
|
318
|
+
_appPeerId = appPeerId;
|
|
319
|
+
_peerShort = peerShort;
|
|
320
|
+
_state = "paired";
|
|
321
|
+
_refreshFooter();
|
|
322
|
+
_lastCtx?.ui.notify(`[remote-pi] state: paired (peer=${peerShort}, name=${peerName})`, "info");
|
|
323
|
+
if (firstInner) {
|
|
324
|
+
// Route the inner that triggered the reconnect — the channel listener
|
|
325
|
+
// also saw it, but we route through routeClientMessage to be explicit.
|
|
326
|
+
void firstInner;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
// ── Auto-reconnect listener ───────────────────────────────────────────────────
|
|
330
|
+
//
|
|
331
|
+
// Installed while in 'started' state. Decodes the outer envelope as
|
|
332
|
+
// base64(JSON) and dispatches based on inner type:
|
|
333
|
+
// • pair_request from any peer → validate token, persist peer, send pair_ok/pair_error
|
|
334
|
+
// • any inner from a known peer (peers.json) → promote to paired and route
|
|
335
|
+
// • anything else → ignored
|
|
336
|
+
function _installAutoListener(relay) {
|
|
337
|
+
const onMsg = async (line) => {
|
|
338
|
+
let outer;
|
|
339
|
+
try {
|
|
340
|
+
outer = JSON.parse(line);
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
if (!outer.peer || !outer.ct)
|
|
346
|
+
return;
|
|
347
|
+
// Once paired, the PlainPeerChannel handles application messages.
|
|
348
|
+
if (_state === "paired")
|
|
349
|
+
return;
|
|
350
|
+
if (_state !== "started")
|
|
351
|
+
return;
|
|
352
|
+
// Decode inner envelope (base64 JSON)
|
|
353
|
+
let inner;
|
|
354
|
+
try {
|
|
355
|
+
const plaintext = Buffer.from(outer.ct, "base64").toString("utf8");
|
|
356
|
+
const parsed = JSON.parse(plaintext);
|
|
357
|
+
if (!parsed ||
|
|
358
|
+
typeof parsed !== "object" ||
|
|
359
|
+
typeof parsed.type !== "string")
|
|
360
|
+
return;
|
|
361
|
+
inner = parsed;
|
|
362
|
+
}
|
|
363
|
+
catch {
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
const appPeerId = outer.peer;
|
|
367
|
+
if (inner.type === "pair_request") {
|
|
368
|
+
await _handlePairRequest(relay, appPeerId, inner);
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
// Reconnect path: known peer sends a non-pair message → promote to paired
|
|
372
|
+
// and route through the new PlainPeerChannel. See pairing.md §Reconexão.
|
|
373
|
+
const known = await _findKnownPeer(appPeerId);
|
|
374
|
+
if (known) {
|
|
375
|
+
_promoteToPaired(relay, appPeerId, known.name);
|
|
376
|
+
// The PlainPeerChannel that was just installed will not have observed
|
|
377
|
+
// the line we already consumed; route the inner directly.
|
|
378
|
+
routeClientMessage(inner, _lastCtx ?? _noopCtx);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
// Unknown peer with non-pair_request inner — signal so the app can react
|
|
382
|
+
// (peer was revoked / never paired). pair_request from unknown peer was
|
|
383
|
+
// already handled above as a legitimate path. We never log inner contents,
|
|
384
|
+
// only inner.type.
|
|
385
|
+
const errReply = {
|
|
386
|
+
type: "error",
|
|
387
|
+
code: "unknown_peer",
|
|
388
|
+
message: "Peer not paired — re-scan QR",
|
|
389
|
+
};
|
|
390
|
+
const errCt = Buffer.from(JSON.stringify(errReply)).toString("base64");
|
|
391
|
+
relay.send(JSON.stringify({ peer: appPeerId, ct: errCt }));
|
|
392
|
+
};
|
|
393
|
+
relay.on("message", onMsg);
|
|
394
|
+
return () => relay.off("message", onMsg);
|
|
395
|
+
}
|
|
396
|
+
async function _handlePairRequest(relay, appPeerId, inner) {
|
|
397
|
+
const sendInner = (msg) => {
|
|
398
|
+
const ct = Buffer.from(JSON.stringify(msg)).toString("base64");
|
|
399
|
+
relay.send(JSON.stringify({ peer: appPeerId, ct }));
|
|
400
|
+
};
|
|
401
|
+
const sendError = (code, message) => {
|
|
402
|
+
sendInner({ type: "pair_error", in_reply_to: inner.id, code, message });
|
|
403
|
+
};
|
|
404
|
+
const status = qrSession.consumeToken(inner.token);
|
|
405
|
+
if (status !== "ok") {
|
|
406
|
+
const code = status === "expired" ? "token_expired"
|
|
407
|
+
: status === "consumed" ? "token_consumed"
|
|
408
|
+
: "token_unknown";
|
|
409
|
+
const msg = code === "token_expired" ? "Token efêmero expirou. Gere um novo QR com /remote-pi pair."
|
|
410
|
+
: code === "token_consumed" ? "Token já consumido por outro pair_request."
|
|
411
|
+
: "Token não foi emitido por este Pi.";
|
|
412
|
+
sendError(code, msg);
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
try {
|
|
416
|
+
await addPeer({
|
|
417
|
+
name: inner.device_name,
|
|
418
|
+
remote_epk: appPeerId,
|
|
419
|
+
paired_at: new Date().toISOString(),
|
|
420
|
+
});
|
|
421
|
+
_refreshPairingsCache();
|
|
422
|
+
}
|
|
423
|
+
catch (err) {
|
|
424
|
+
sendError("internal_error", `Failed to persist peer: ${String(err)}`);
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
const cwd = _lastCtx && "cwd" in _lastCtx
|
|
428
|
+
? _lastCtx.cwd
|
|
429
|
+
: process.cwd();
|
|
430
|
+
const sessionName = cwd.split("/").slice(-2).join("/") || "remote";
|
|
431
|
+
_promoteToPaired(relay, appPeerId, inner.device_name);
|
|
432
|
+
sendInner({
|
|
433
|
+
type: "pair_ok",
|
|
434
|
+
in_reply_to: inner.id,
|
|
435
|
+
session_name: sessionName,
|
|
436
|
+
session_started_at: _sessionStartedAt ?? Date.now(),
|
|
437
|
+
// App uses this to address subsequent inner messages to the right room
|
|
438
|
+
// when this Pi runs alongside others with the same epk. Defensive fallback
|
|
439
|
+
// to roomIdForCwd(cwd) covers the edge case where pair_request lands
|
|
440
|
+
// before _cmdStart could set _myRoomId (shouldn't happen in practice).
|
|
441
|
+
room_id: _myRoomId ?? roomIdForCwd(cwd),
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
// ── Extension factory (default export) ───────────────────────────────────────
|
|
445
|
+
// Stores most recent command context so the auto-listener can use ui.notify
|
|
446
|
+
let _lastCtx = null;
|
|
447
|
+
const _noopCtx = { ui: { notify: () => undefined }, abort: () => undefined };
|
|
448
|
+
const extension = (pi) => {
|
|
449
|
+
_pi = pi;
|
|
450
|
+
console.error(`[remote-pi] session sync limit: ${_getSyncLimit()}`);
|
|
451
|
+
// Plano 19: ensure ~/.pi/remote/{sessions,skills}/ exist and deploy the
|
|
452
|
+
// agent-network skill on first load. resources_discover lets Pi find it.
|
|
453
|
+
try {
|
|
454
|
+
ensureGlobalDirs();
|
|
455
|
+
_deployAgentNetworkSkill();
|
|
456
|
+
}
|
|
457
|
+
catch { /* best-effort init */ }
|
|
458
|
+
// Seed the global-pairings cache from peers.json so the footer can show
|
|
459
|
+
// 🟢/🟡 correctly the moment the relay is up (no race with first refresh).
|
|
460
|
+
_refreshPairingsCache();
|
|
461
|
+
pi.on("resources_discover", () => ({ skillPaths: [skillsDir()] }));
|
|
462
|
+
// Plano 20: agent_send + agent_request tools so the LLM can drive the
|
|
463
|
+
// session network natively. Getter captures `_sessionPeer` live so the
|
|
464
|
+
// tool always sees the current state.
|
|
465
|
+
registerAgentTools(pi, () => _sessionPeer);
|
|
466
|
+
// Tool calls execute without prompting the remote user. The Pi SDK has no
|
|
467
|
+
// native `requiresApproval` per tool, and a hardcoded gate (Bash/Edit/Write)
|
|
468
|
+
// misfired on every custom tool from third-party packages. Approval will
|
|
469
|
+
// come back when the Pi ecosystem ships a permissions convention. tool_result
|
|
470
|
+
// is still forwarded so the app shows tool activity transparently.
|
|
471
|
+
// Mirror input typed in the Pi terminal (or sent via RPC) to the remote app.
|
|
472
|
+
// 'extension' source is our own sendUserMessage call from routeClientMessage,
|
|
473
|
+
// which already set _currentTurnId — skip to avoid double turnId.
|
|
474
|
+
pi.on("input", (event) => {
|
|
475
|
+
if (!_peerChannel)
|
|
476
|
+
return;
|
|
477
|
+
if (event.source === "extension")
|
|
478
|
+
return;
|
|
479
|
+
const turnId = `local_${randomUUID()}`;
|
|
480
|
+
_currentTurnId = turnId;
|
|
481
|
+
_peerChannel.send({ type: "user_input", id: turnId, text: event.text });
|
|
482
|
+
});
|
|
483
|
+
// Track active model so the app can show it in the SessionTile (plano 18).
|
|
484
|
+
// SDK fires model_select on settings load + every user switch. We cache the
|
|
485
|
+
// friendly name and broadcast a room_meta_update so the relay can fan it
|
|
486
|
+
// out to subscribed apps without needing a new pair.
|
|
487
|
+
pi.on("model_select", (event) => {
|
|
488
|
+
const m = event?.model;
|
|
489
|
+
const modelName = m?.name ?? m?.id;
|
|
490
|
+
if (!modelName)
|
|
491
|
+
return;
|
|
492
|
+
_currentModel = modelName;
|
|
493
|
+
// Keep the cached room_meta fresh so a future reconnect carries the
|
|
494
|
+
// current model in its hello (otherwise the post-reconnect hello would
|
|
495
|
+
// ship the stale model that was active at _cmdStart time).
|
|
496
|
+
if (_myRoomMeta)
|
|
497
|
+
_myRoomMeta = { ..._myRoomMeta, model: modelName };
|
|
498
|
+
if (!_relay || !_myRoomId)
|
|
499
|
+
return;
|
|
500
|
+
console.error(`[remote-pi] model_select → ${modelName}`);
|
|
501
|
+
_relay.sendControl({
|
|
502
|
+
type: "room_meta_update",
|
|
503
|
+
room_id: _myRoomId,
|
|
504
|
+
meta: { model: modelName },
|
|
505
|
+
});
|
|
506
|
+
});
|
|
507
|
+
pi.on("message_update", (event) => {
|
|
508
|
+
if (!_peerChannel || !_currentTurnId)
|
|
509
|
+
return;
|
|
510
|
+
const ae = event.assistantMessageEvent;
|
|
511
|
+
if (ae.type === "text_delta") {
|
|
512
|
+
_peerChannel.send({ type: "agent_chunk", in_reply_to: _currentTurnId, delta: ae.delta });
|
|
513
|
+
}
|
|
514
|
+
});
|
|
515
|
+
// Notify the app a tool is about to run (visibility only, NOT approval).
|
|
516
|
+
// tool_execution_start fires before the tool executes; tool_execution_end
|
|
517
|
+
// closes the loop with the result (success or error). Together they let
|
|
518
|
+
// the app render a "Tool running… done" timeline without any gating.
|
|
519
|
+
pi.on("tool_execution_start", (event) => {
|
|
520
|
+
if (!_peerChannel)
|
|
521
|
+
return;
|
|
522
|
+
_peerChannel.send({
|
|
523
|
+
type: "tool_request",
|
|
524
|
+
tool_call_id: event.toolCallId,
|
|
525
|
+
tool: event.toolName,
|
|
526
|
+
args: event.args,
|
|
527
|
+
});
|
|
528
|
+
});
|
|
529
|
+
pi.on("tool_execution_end", (event) => {
|
|
530
|
+
if (!_peerChannel)
|
|
531
|
+
return;
|
|
532
|
+
const msg = event.isError
|
|
533
|
+
? { type: "tool_result", tool_call_id: event.toolCallId, error: String(event.result) }
|
|
534
|
+
: { type: "tool_result", tool_call_id: event.toolCallId, result: event.result };
|
|
535
|
+
_peerChannel.send(msg);
|
|
536
|
+
});
|
|
537
|
+
// Cumulative session buffer fed via `message_end`, which fires once per
|
|
538
|
+
// persisted message (user, assistant, toolResult) — same hook the SDK uses
|
|
539
|
+
// to persist to sessionManager (see agent-session.js:298-309). Pushing here
|
|
540
|
+
// accumulates the whole session over time, so session_sync can replay every
|
|
541
|
+
// turn — including turns initiated from the Pi terminal (source:"interactive")
|
|
542
|
+
// or RPC. Previous impl overwrote on `agent_end` and lost everything but the
|
|
543
|
+
// last turn (see diagnostics 14, 15).
|
|
544
|
+
pi.on("message_end", (event) => {
|
|
545
|
+
const m = event?.message;
|
|
546
|
+
if (!m)
|
|
547
|
+
return;
|
|
548
|
+
if (m.role === "user" || m.role === "assistant" || m.role === "toolResult") {
|
|
549
|
+
_messageBuffer.push(m);
|
|
550
|
+
}
|
|
551
|
+
});
|
|
552
|
+
pi.on("agent_end", () => {
|
|
553
|
+
// Buffer is fed by `message_end`; here we only finalize the outbound
|
|
554
|
+
// turn signal to the app. No buffer mutation.
|
|
555
|
+
if (!_peerChannel || !_currentTurnId)
|
|
556
|
+
return;
|
|
557
|
+
_peerChannel.send({ type: "agent_done", in_reply_to: _currentTurnId });
|
|
558
|
+
_currentTurnId = null;
|
|
559
|
+
});
|
|
560
|
+
// ── Commands (plano 19 taxonomy) ──────────────────────────────────────────
|
|
561
|
+
pi.registerCommand("remote-pi", {
|
|
562
|
+
description: "Connect (join session + start relay), or run setup on first use",
|
|
563
|
+
getArgumentCompletions: async (prefix) => {
|
|
564
|
+
if (prefix.startsWith("revoke ") || prefix === "revoke") {
|
|
565
|
+
const shortPrefix = prefix === "revoke" ? "" : prefix.slice("revoke ".length);
|
|
566
|
+
return _shortidCompletions(shortPrefix, "revoke ");
|
|
567
|
+
}
|
|
568
|
+
return [
|
|
569
|
+
"join", "leave", "rename", "sessions", "setup",
|
|
570
|
+
"relay", "pair", "devices", "revoke",
|
|
571
|
+
"set-relay", "config",
|
|
572
|
+
// legacy aliases (still autocomplete-visible during the deprecation window)
|
|
573
|
+
"start", "stop", "list", "add-relay",
|
|
574
|
+
]
|
|
575
|
+
.filter((o) => o.startsWith(prefix))
|
|
576
|
+
.map((o) => ({ value: o, label: o }));
|
|
577
|
+
},
|
|
578
|
+
handler: async (args, ctx) => {
|
|
579
|
+
_lastCtx = ctx;
|
|
580
|
+
const sub = args.trim();
|
|
581
|
+
if (sub === "") {
|
|
582
|
+
await _cmdStatus(ctx);
|
|
583
|
+
}
|
|
584
|
+
else if (sub === "setup") {
|
|
585
|
+
await _cmdSetup(ctx);
|
|
586
|
+
}
|
|
587
|
+
else if (sub === "join" || sub.startsWith("join ")) {
|
|
588
|
+
await _cmdJoin(sub.slice("join".length).trim(), ctx);
|
|
589
|
+
}
|
|
590
|
+
else if (sub === "leave") {
|
|
591
|
+
await _cmdLeave(ctx);
|
|
592
|
+
}
|
|
593
|
+
else if (sub.startsWith("rename")) {
|
|
594
|
+
await _cmdRename(sub.slice("rename".length).trim(), ctx);
|
|
595
|
+
}
|
|
596
|
+
else if (sub === "sessions") {
|
|
597
|
+
_cmdSessions(ctx);
|
|
598
|
+
}
|
|
599
|
+
else if (sub === "relay") {
|
|
600
|
+
await _cmdRelayToggle(ctx);
|
|
601
|
+
}
|
|
602
|
+
else if (sub === "relay start") {
|
|
603
|
+
await _cmdStart(ctx);
|
|
604
|
+
}
|
|
605
|
+
else if (sub === "relay stop") {
|
|
606
|
+
await _cmdStop(ctx);
|
|
607
|
+
}
|
|
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
|
+
else if (sub === "pair") {
|
|
615
|
+
await _cmdPair(ctx);
|
|
616
|
+
}
|
|
617
|
+
else if (sub === "devices") {
|
|
618
|
+
await _cmdList(ctx);
|
|
619
|
+
}
|
|
620
|
+
else if (sub.startsWith("revoke")) {
|
|
621
|
+
await _cmdRevoke(sub.slice("revoke".length).trim(), ctx);
|
|
622
|
+
}
|
|
623
|
+
else if (sub.startsWith("set-relay")) {
|
|
624
|
+
_cmdSetRelay(sub.slice("set-relay".length).trim(), ctx);
|
|
625
|
+
}
|
|
626
|
+
else if (sub === "config") {
|
|
627
|
+
_cmdConfig(ctx);
|
|
628
|
+
}
|
|
629
|
+
// ── Legacy aliases (deprecated, 1-release window) ─────────────────────
|
|
630
|
+
else if (sub === "start") {
|
|
631
|
+
ctx.ui.notify("[remote-pi] '/remote-pi start' deprecated — use '/remote-pi relay start' (auto-joining default session)", "warning");
|
|
632
|
+
await _cmdJoin("", ctx);
|
|
633
|
+
await _cmdStart(ctx);
|
|
634
|
+
}
|
|
635
|
+
else if (sub === "stop") {
|
|
636
|
+
ctx.ui.notify("[remote-pi] '/remote-pi stop' deprecated — use '/remote-pi leave' + '/remote-pi relay stop'", "warning");
|
|
637
|
+
await _cmdLeave(ctx);
|
|
638
|
+
await _cmdStop(ctx);
|
|
639
|
+
}
|
|
640
|
+
else if (sub === "list") {
|
|
641
|
+
ctx.ui.notify("[remote-pi] '/remote-pi list' deprecated — use '/remote-pi devices'", "warning");
|
|
642
|
+
await _cmdList(ctx);
|
|
643
|
+
}
|
|
644
|
+
else if (sub.startsWith("add-relay")) {
|
|
645
|
+
ctx.ui.notify("[remote-pi] '/remote-pi add-relay' deprecated — use '/remote-pi relay url <...>'", "warning");
|
|
646
|
+
_cmdSetRelay(sub.slice("add-relay".length).trim(), ctx);
|
|
647
|
+
}
|
|
648
|
+
else {
|
|
649
|
+
await _cmdStatus(ctx);
|
|
650
|
+
}
|
|
651
|
+
},
|
|
652
|
+
});
|
|
653
|
+
// Nested registrations (full taxonomy)
|
|
654
|
+
pi.registerCommand("remote-pi setup", { description: "Run the setup wizard and update local config", handler: async (_, ctx) => { _lastCtx = ctx; await _cmdSetup(ctx); } });
|
|
655
|
+
pi.registerCommand("remote-pi join", { description: "Join (or create) a local agent session", handler: async (args, ctx) => { _lastCtx = ctx; await _cmdJoin(args.trim(), ctx); } });
|
|
656
|
+
pi.registerCommand("remote-pi leave", { description: "Leave the current agent session", handler: async (_, ctx) => { _lastCtx = ctx; await _cmdLeave(ctx); } });
|
|
657
|
+
pi.registerCommand("remote-pi rename", { description: "Rename this agent in the current session", handler: async (args, ctx) => { _lastCtx = ctx; await _cmdRename(args.trim(), ctx); } });
|
|
658
|
+
pi.registerCommand("remote-pi sessions", { description: "List local agent sessions", handler: async (_, ctx) => { _lastCtx = ctx; _cmdSessions(ctx); } });
|
|
659
|
+
pi.registerCommand("remote-pi relay", { description: "Toggle the relay connection on/off", handler: async (_, ctx) => { _lastCtx = ctx; await _cmdRelayToggle(ctx); } });
|
|
660
|
+
pi.registerCommand("remote-pi relay start", { description: "Connect to the relay", handler: async (_, ctx) => { _lastCtx = ctx; await _cmdStart(ctx); } });
|
|
661
|
+
pi.registerCommand("remote-pi relay stop", { description: "Disconnect from the relay", handler: async (_, ctx) => { _lastCtx = ctx; await _cmdStop(ctx); } });
|
|
662
|
+
pi.registerCommand("remote-pi relay status", { description: "Show current relay status", handler: async (_, ctx) => { _lastCtx = ctx; _cmdRelayStatus(ctx); } });
|
|
663
|
+
pi.registerCommand("remote-pi relay url", { description: "Set relay URL (alias of /remote-pi set-relay)", handler: async (args, ctx) => { _lastCtx = ctx; _cmdSetRelay(args.trim(), ctx); } });
|
|
664
|
+
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
|
+
pi.registerCommand("remote-pi devices", { description: "List paired mobile devices", handler: async (_, ctx) => { _lastCtx = ctx; await _cmdList(ctx); } });
|
|
666
|
+
pi.registerCommand("remote-pi revoke", {
|
|
667
|
+
description: "Revoke a paired device by its shortid",
|
|
668
|
+
getArgumentCompletions: async (prefix) => _shortidCompletions(prefix),
|
|
669
|
+
handler: async (args, ctx) => { _lastCtx = ctx; await _cmdRevoke(args.trim(), ctx); },
|
|
670
|
+
});
|
|
671
|
+
pi.registerCommand("remote-pi set-relay", { description: "Persist a new relay URL to user config", handler: async (args, ctx) => { _lastCtx = ctx; _cmdSetRelay(args.trim(), ctx); } });
|
|
672
|
+
pi.registerCommand("remote-pi config", { description: "Show the effective relay URL and its source", handler: async (_, ctx) => { _lastCtx = ctx; _cmdConfig(ctx); } });
|
|
673
|
+
// Legacy aliases (deprecated, 1-release deprecation window).
|
|
674
|
+
const legacyWarn = (ctx, old, neu) => ctx.ui.notify(`[remote-pi] '${old}' deprecated — use '${neu}'`, "warning");
|
|
675
|
+
pi.registerCommand("remote-pi start", {
|
|
676
|
+
description: "[DEPRECATED] alias of /remote-pi relay start (also auto-joins the default session)",
|
|
677
|
+
handler: async (_, ctx) => { _lastCtx = ctx; legacyWarn(ctx, "/remote-pi start", "/remote-pi relay start"); await _cmdJoin("", ctx); await _cmdStart(ctx); },
|
|
678
|
+
});
|
|
679
|
+
pi.registerCommand("remote-pi stop", {
|
|
680
|
+
description: "[DEPRECATED] alias of /remote-pi leave + /remote-pi relay stop",
|
|
681
|
+
handler: async (_, ctx) => { _lastCtx = ctx; legacyWarn(ctx, "/remote-pi stop", "/remote-pi leave + /remote-pi relay stop"); await _cmdLeave(ctx); await _cmdStop(ctx); },
|
|
682
|
+
});
|
|
683
|
+
pi.registerCommand("remote-pi list", {
|
|
684
|
+
description: "[DEPRECATED] alias of /remote-pi devices",
|
|
685
|
+
handler: async (_, ctx) => { _lastCtx = ctx; legacyWarn(ctx, "/remote-pi list", "/remote-pi devices"); await _cmdList(ctx); },
|
|
686
|
+
});
|
|
687
|
+
pi.registerCommand("remote-pi add-relay", {
|
|
688
|
+
description: "[DEPRECATED] alias of /remote-pi relay url",
|
|
689
|
+
handler: async (args, ctx) => { _lastCtx = ctx; legacyWarn(ctx, "/remote-pi add-relay", "/remote-pi relay url"); _cmdSetRelay(args.trim(), ctx); },
|
|
690
|
+
});
|
|
691
|
+
};
|
|
692
|
+
export default extension;
|
|
693
|
+
// ── Command implementations ───────────────────────────────────────────────────
|
|
694
|
+
function _showStatus(ctx) {
|
|
695
|
+
const relayUrl = _relayUrl ?? resolveRelayUrl().url;
|
|
696
|
+
const sessionPart = _sessionName ? `session=${_sessionName} (${_sessionPeerCount}) · ` : "";
|
|
697
|
+
let msg;
|
|
698
|
+
if (_state === "idle")
|
|
699
|
+
msg = `[remote-pi] ${sessionPart}relay=idle (${relayUrl}). Run /remote-pi relay start to connect.`;
|
|
700
|
+
else if (_state === "started")
|
|
701
|
+
msg = `[remote-pi] ${sessionPart}relay=started (peer=${_peerShort || "?"}, ${relayUrl}) — run /remote-pi pair to show QR`;
|
|
702
|
+
else
|
|
703
|
+
msg = `[remote-pi] ${sessionPart}relay=paired (peer=${_peerShort}, ${relayUrl}) — connected and ready`;
|
|
704
|
+
ctx.ui.notify(msg, "info");
|
|
705
|
+
}
|
|
706
|
+
async function _cmdStatus(ctx) {
|
|
707
|
+
const cwd = "cwd" in ctx ? ctx.cwd : process.cwd();
|
|
708
|
+
// First-time wizard: no local config in this cwd → run interactive setup.
|
|
709
|
+
if (!localConfigExists(cwd)) {
|
|
710
|
+
const ui = ctx.ui;
|
|
711
|
+
if (typeof ui.select !== "function") {
|
|
712
|
+
_showStatus(ctx);
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
const baseDefault = defaultAgentName(cwd);
|
|
716
|
+
const newConfig = await runSetupWizard(ui, {
|
|
717
|
+
agent_name: baseDefault,
|
|
718
|
+
session_name: baseDefault,
|
|
719
|
+
auto_start_relay: true,
|
|
720
|
+
});
|
|
721
|
+
if (!newConfig) {
|
|
722
|
+
ctx.ui.notify("[remote-pi] Setup cancelled.", "info");
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
saveLocalConfig(cwd, newConfig);
|
|
726
|
+
ctx.ui.notify(`[remote-pi] Config saved to ${cwd}/.pi/remote-pi/config.json`, "info");
|
|
727
|
+
await _cmdJoin(newConfig.session_name ?? baseDefault, ctx);
|
|
728
|
+
if (effectiveAutoStartRelay(newConfig))
|
|
729
|
+
await _cmdStart(ctx);
|
|
730
|
+
_showStatus(ctx);
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
// Returning user with config: auto-start if requested + currently inactive.
|
|
734
|
+
const config = loadLocalConfig(cwd);
|
|
735
|
+
if (effectiveAutoStartRelay(config) && !_sessionPeer) {
|
|
736
|
+
const sessionName = config.session_name ?? defaultAgentName(cwd);
|
|
737
|
+
await _cmdJoin(sessionName, ctx);
|
|
738
|
+
if (_state === "idle")
|
|
739
|
+
await _cmdStart(ctx);
|
|
740
|
+
}
|
|
741
|
+
_showStatus(ctx);
|
|
742
|
+
}
|
|
743
|
+
async function _cmdSetup(ctx) {
|
|
744
|
+
const cwd = "cwd" in ctx ? ctx.cwd : process.cwd();
|
|
745
|
+
const ui = ctx.ui;
|
|
746
|
+
if (typeof ui.select !== "function") {
|
|
747
|
+
ctx.ui.notify("[remote-pi] Setup requires an interactive UI.", "warning");
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
const current = loadLocalConfig(cwd);
|
|
751
|
+
const baseDefault = defaultAgentName(cwd);
|
|
752
|
+
const newConfig = await runSetupWizard(ui, {
|
|
753
|
+
agent_name: current.agent_name ?? baseDefault,
|
|
754
|
+
session_name: current.session_name ?? baseDefault,
|
|
755
|
+
auto_start_relay: effectiveAutoStartRelay(current),
|
|
756
|
+
});
|
|
757
|
+
if (!newConfig) {
|
|
758
|
+
ctx.ui.notify("[remote-pi] Setup cancelled.", "info");
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
saveLocalConfig(cwd, newConfig);
|
|
762
|
+
ctx.ui.notify("[remote-pi] Config updated. Run /remote-pi to apply now (join + relay).", "info");
|
|
763
|
+
}
|
|
764
|
+
async function _cmdStart(ctx) {
|
|
765
|
+
if (_state !== "idle") {
|
|
766
|
+
ctx.ui.notify("[remote-pi] Already started.", "warning");
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
const edKp = await getOrCreateEd25519Keypair();
|
|
770
|
+
_cachedEd25519 = edKp;
|
|
771
|
+
const { url: relayUrl, source } = resolveRelayUrl();
|
|
772
|
+
const myShort = Buffer.from(edKp.publicKey).toString("base64").slice(0, 8);
|
|
773
|
+
// Derive room from cwd so N parallel `pi -e` in different directories can
|
|
774
|
+
// share the same Ed25519 identity without colliding on the relay.
|
|
775
|
+
const cwd = "cwd" in ctx ? ctx.cwd : process.cwd();
|
|
776
|
+
const roomId = roomIdForCwd(cwd);
|
|
777
|
+
const sessionName = cwd.split("/").slice(-2).join("/") || "remote";
|
|
778
|
+
// Initial model from ctx (ExtensionContext.model is the SDK's current
|
|
779
|
+
// selection — set by user settings or last-used). May be undefined on
|
|
780
|
+
// first boot before any model_select; that's fine, room_meta omits the
|
|
781
|
+
// field then.
|
|
782
|
+
const ctxModelName = ctx.model;
|
|
783
|
+
if (ctxModelName)
|
|
784
|
+
_currentModel = ctxModelName.name ?? ctxModelName.id ?? undefined;
|
|
785
|
+
const roomMeta = { name: sessionName, cwd };
|
|
786
|
+
const modelName = _currentModelName();
|
|
787
|
+
if (modelName)
|
|
788
|
+
roomMeta.model = modelName;
|
|
789
|
+
// Persist so _attemptReconnect can replay the same hello payload — without
|
|
790
|
+
// this, reconnect issues a bare hello and the relay creates a "default room"
|
|
791
|
+
// entry that surfaces in the app as a phantom legacy session.
|
|
792
|
+
_myRoomMeta = roomMeta;
|
|
793
|
+
ctx.ui.notify(`[remote-pi] Connecting to relay ${relayUrl} (source: ${source}, room: ${roomId})…`, "info");
|
|
794
|
+
const relay = new RelayClient(relayUrl, edKp);
|
|
795
|
+
try {
|
|
796
|
+
await relay.connect({ roomId, roomMeta });
|
|
797
|
+
}
|
|
798
|
+
catch (err) {
|
|
799
|
+
if (err instanceof RoomAlreadyOpenError) {
|
|
800
|
+
ctx.ui.notify("[remote-pi] Already running in this cwd. Stop the other terminal first.", "error");
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
ctx.ui.notify(`[remote-pi] relay connect failed: ${String(err)}`, "error");
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
_relay = relay;
|
|
807
|
+
_relayUrl = relayUrl;
|
|
808
|
+
_peerShort = myShort;
|
|
809
|
+
_myRoomId = roomId;
|
|
810
|
+
_state = "started";
|
|
811
|
+
// Set _sessionStartedAt ONLY on first /remote-pi start since process boot.
|
|
812
|
+
// Subsequent start cycles (after stop) preserve the original epoch so the
|
|
813
|
+
// app keeps treating it as the same session (and merges new events from
|
|
814
|
+
// the terminal turns that happened during the idle window). Pi process
|
|
815
|
+
// restart is the only thing that produces a fresh session_started_at.
|
|
816
|
+
if (_sessionStartedAt === null)
|
|
817
|
+
_sessionStartedAt = Date.now();
|
|
818
|
+
// _messageBuffer intentionally preserved across stop/start — it accumulates
|
|
819
|
+
// message_end events for the lifetime of the Pi process, including turns
|
|
820
|
+
// initiated from the terminal while the relay was disconnected.
|
|
821
|
+
relay.on("close", _onRelayClose);
|
|
822
|
+
_stopAutoListener = _installAutoListener(relay);
|
|
823
|
+
_refreshFooter(ctx);
|
|
824
|
+
ctx.ui.notify(`[remote-pi] state: started (peer=${myShort}) — Connected to relay ${relayUrl}`, "info");
|
|
825
|
+
}
|
|
826
|
+
async function _cmdPair(ctx) {
|
|
827
|
+
if (_state === "idle") {
|
|
828
|
+
ctx.ui.notify("[remote-pi] Run /remote-pi start first.", "warning");
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
if (_state === "paired") {
|
|
832
|
+
ctx.ui.notify(`[remote-pi] Already paired with ${_peerShort}. Run /remote-pi stop first.`, "warning");
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
const edKp = _cachedEd25519;
|
|
836
|
+
const cwd = "cwd" in ctx ? ctx.cwd : "";
|
|
837
|
+
const sessionName = cwd.split("/").slice(-2).join("/") || "remote";
|
|
838
|
+
const { token, expiresAt } = qrSession.issueToken();
|
|
839
|
+
const roomId = _myRoomId ?? roomIdForCwd(cwd);
|
|
840
|
+
const qrUri = buildQRUri(token, edKp.publicKey, sessionName, roomId);
|
|
841
|
+
displayQR(qrUri);
|
|
842
|
+
ctx.ui.notify(`[remote-pi] QR ready — valid until ${new Date(expiresAt).toLocaleTimeString()}. Scan with the app.`, "info");
|
|
843
|
+
// Returns immediately; the auto-listener transitions to 'paired' on pair_request.
|
|
844
|
+
}
|
|
845
|
+
async function _cmdStop(ctx) {
|
|
846
|
+
if (_state === "idle") {
|
|
847
|
+
ctx.ui.notify("[remote-pi] Already idle — nothing to stop.", "info");
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
_goIdle("peer_stop");
|
|
851
|
+
ctx.ui.notify("[remote-pi] state: idle — Disconnected.", "info");
|
|
852
|
+
}
|
|
853
|
+
async function _cmdList(ctx) {
|
|
854
|
+
const peers = await listPeers();
|
|
855
|
+
if (peers.length === 0) {
|
|
856
|
+
ctx.ui.notify("[remote-pi] No paired devices.", "info");
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
const lines = peers.map((p) => {
|
|
860
|
+
const shortid = p.remote_epk.slice(0, 8);
|
|
861
|
+
const active = _state === "paired" && _appPeerId === p.remote_epk ? " (active)" : "";
|
|
862
|
+
return `• ${shortid} — ${p.name}${active}`;
|
|
863
|
+
}).join("\n");
|
|
864
|
+
ctx.ui.notify(`[remote-pi] Paired devices:\n${lines}`, "info");
|
|
865
|
+
}
|
|
866
|
+
async function _cmdRevoke(arg, ctx) {
|
|
867
|
+
const shortid = arg.trim();
|
|
868
|
+
if (!shortid) {
|
|
869
|
+
ctx.ui.notify("[remote-pi] Usage: /remote-pi revoke <shortid>. Run /remote-pi list to see shortids.", "warning");
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
const peers = await listPeers();
|
|
873
|
+
const matches = peers.filter((p) => p.remote_epk.startsWith(shortid));
|
|
874
|
+
if (matches.length === 0) {
|
|
875
|
+
ctx.ui.notify(`[remote-pi] No peer matching '${shortid}'. Run /remote-pi list to see shortids.`, "warning");
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
if (matches.length > 1) {
|
|
879
|
+
const collisions = matches.map((p) => p.remote_epk.slice(0, 8)).join(", ");
|
|
880
|
+
ctx.ui.notify(`[remote-pi] Ambiguous shortid — ${matches.length} matches: ${collisions}. Use mais chars.`, "warning");
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
const peer = matches[0];
|
|
884
|
+
await removePeer(peer.remote_epk);
|
|
885
|
+
_refreshPairingsCache();
|
|
886
|
+
if (_state === "paired" && _appPeerId === peer.remote_epk) {
|
|
887
|
+
_goIdle("session_replaced");
|
|
888
|
+
}
|
|
889
|
+
ctx.ui.notify(`[remote-pi] Revoked: ${peer.name} (${peer.remote_epk.slice(0, 8)}…)`, "info");
|
|
890
|
+
}
|
|
891
|
+
async function _shortidCompletions(prefix, valuePrefix = "") {
|
|
892
|
+
const peers = await listPeers();
|
|
893
|
+
return peers
|
|
894
|
+
.map((p) => ({ shortid: p.remote_epk.slice(0, 8), name: p.name }))
|
|
895
|
+
.filter((x) => x.shortid.startsWith(prefix))
|
|
896
|
+
.map((x) => ({ value: `${valuePrefix}${x.shortid}`, label: `${x.shortid} (${x.name})` }));
|
|
897
|
+
}
|
|
898
|
+
function _cmdSetRelay(arg, ctx) {
|
|
899
|
+
const url = arg.trim();
|
|
900
|
+
if (!url) {
|
|
901
|
+
ctx.ui.notify("[remote-pi] Usage: /remote-pi set-relay <ws:// or wss:// url>", "warning");
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
if (!isValidRelayUrl(url)) {
|
|
905
|
+
ctx.ui.notify(`[remote-pi] Invalid URL: ${url}. Must start with ws:// or wss://`, "error");
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
saveConfig({ relay: url });
|
|
909
|
+
ctx.ui.notify(`[remote-pi] Relay set to ${url}. Run /remote-pi start (or restart) to apply.`, "info");
|
|
910
|
+
}
|
|
911
|
+
function _cmdConfig(ctx) {
|
|
912
|
+
const { url, source } = resolveRelayUrl();
|
|
913
|
+
ctx.ui.notify(`[remote-pi] Relay: ${url}\n Source: ${source}`, "info");
|
|
914
|
+
}
|
|
915
|
+
// ── Agent-network commands (plano 19) ─────────────────────────────────────────
|
|
916
|
+
function _resolveExtensionDir() {
|
|
917
|
+
// dist/index.js → dist; skills sit at <extensionRoot>/skills/. When we run
|
|
918
|
+
// from src/ via tsx (dev), index.ts is in src/ and skills/ is sibling. We
|
|
919
|
+
// detect by checking both locations.
|
|
920
|
+
const here = fileURLToPath(import.meta.url);
|
|
921
|
+
// dist/index.js or src/index.ts → parent = <dist or src>; sibling = ../skills
|
|
922
|
+
const parent = here.replace(/\/[^/]+$/, "");
|
|
923
|
+
const candidateA = join(parent, "..", "skills"); // dist → ../skills
|
|
924
|
+
const candidateB = join(parent, "skills"); // src → skills
|
|
925
|
+
if (existsSync(candidateA))
|
|
926
|
+
return parent.replace(/\/dist$/, "");
|
|
927
|
+
if (existsSync(candidateB))
|
|
928
|
+
return parent;
|
|
929
|
+
return parent;
|
|
930
|
+
}
|
|
931
|
+
function _deployAgentNetworkSkill() {
|
|
932
|
+
// Pi SDK spec (core/skills.js): every skill must live at
|
|
933
|
+
// <skillsRoot>/<skill-name>/SKILL.md
|
|
934
|
+
// The skill `name:` frontmatter must equal the parent directory name. We
|
|
935
|
+
// ship the source pre-arranged that way so deploy is a straight copy into
|
|
936
|
+
// ~/.pi/remote/skills/agent-network/SKILL.md.
|
|
937
|
+
const root = _resolveExtensionDir();
|
|
938
|
+
const src1 = join(root, "skills", "agent-network", "SKILL.md");
|
|
939
|
+
const src2 = join(root, "..", "skills", "agent-network", "SKILL.md");
|
|
940
|
+
const src = existsSync(src1) ? src1 : (existsSync(src2) ? src2 : null);
|
|
941
|
+
if (!src)
|
|
942
|
+
return;
|
|
943
|
+
const dstDir = join(skillsDir(), "agent-network");
|
|
944
|
+
const dst = join(dstDir, "SKILL.md");
|
|
945
|
+
try {
|
|
946
|
+
mkdirSync(dstDir, { recursive: true });
|
|
947
|
+
copyFileSync(src, dst);
|
|
948
|
+
// Cleanup legacy deploy at ~/.pi/remote/skills/agent-network.md (flat
|
|
949
|
+
// layout, fails the Pi SDK's name-vs-parent-dir validation).
|
|
950
|
+
const legacy = join(skillsDir(), "agent-network.md");
|
|
951
|
+
if (existsSync(legacy)) {
|
|
952
|
+
try {
|
|
953
|
+
unlinkSync(legacy);
|
|
954
|
+
}
|
|
955
|
+
catch { /* ignored */ }
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
catch { /* best-effort */ }
|
|
959
|
+
}
|
|
960
|
+
async function _cmdJoin(arg, ctx) {
|
|
961
|
+
const cwd = "cwd" in ctx ? ctx.cwd : process.cwd();
|
|
962
|
+
const local = loadLocalConfig(cwd);
|
|
963
|
+
const sessionName = (arg || local.session_name || defaultAgentName(cwd)).trim();
|
|
964
|
+
const agentName = local.agent_name || defaultAgentName(cwd);
|
|
965
|
+
if (_sessionPeer) {
|
|
966
|
+
ctx.ui.notify(`[remote-pi] Already joined "${_sessionName}". Leave first.`, "warning");
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
ensureGlobalDirs();
|
|
970
|
+
mkdirSync(join(skillsDir(), "..", "sessions", sessionName), { recursive: true });
|
|
971
|
+
const sock = sessionSockPath(sessionName);
|
|
972
|
+
const audit = sessionAuditPath(sessionName);
|
|
973
|
+
const peer = new SessionPeer({ sockPath: sock, name: agentName, auditPath: audit });
|
|
974
|
+
peer.onMessage((env) => {
|
|
975
|
+
const body = env.body;
|
|
976
|
+
// Broker system events: re-query broker for authoritative count.
|
|
977
|
+
// Incremental ±1 drifts when peer_left is missed (leader leaves cleanly,
|
|
978
|
+
// failover, etc.) — querying list_peers makes the count self-healing.
|
|
979
|
+
if (body && (body.type === "peer_joined" || body.type === "peer_left")) {
|
|
980
|
+
_refreshSessionPeerCount(peer, ctx);
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
if (env.from === "broker")
|
|
984
|
+
return; // other broker control messages — ignore
|
|
985
|
+
// Anything else is a real agent-to-agent message. SessionPeer already
|
|
986
|
+
// correlated replies (env.re matched a pending request) before reaching
|
|
987
|
+
// here — what arrives now is unsolicited and needs the LLM to react.
|
|
988
|
+
// Inject as a user message so the model sees it as a turn input. Include
|
|
989
|
+
// the `id` so the LLM can echo it via `agent_send(..., re=<id>)` when
|
|
990
|
+
// replying (otherwise the sender's agent_request times out).
|
|
991
|
+
if (!_pi)
|
|
992
|
+
return;
|
|
993
|
+
const bodyText = typeof env.body === "string" ? env.body : JSON.stringify(env.body);
|
|
994
|
+
const header = `[agent-network] message from "${env.from}" (id=${env.id}${env.re ? `, re=${env.re}` : ""}):`;
|
|
995
|
+
const footer = env.re
|
|
996
|
+
? "(This is a reply to a previous message of yours.)"
|
|
997
|
+
: `(If a reply is expected, call agent_send with to="${env.from}" and re="${env.id}".)`;
|
|
998
|
+
_pi.sendUserMessage(`${header}\n${bodyText}\n\n${footer}`);
|
|
999
|
+
});
|
|
1000
|
+
// After failover (leader died, we re-elected): the new broker's peers map
|
|
1001
|
+
// starts fresh, but our cached `_sessionPeerCount` is stale. Re-seed it so
|
|
1002
|
+
// surviving peers don't carry the pre-failover count forever.
|
|
1003
|
+
peer.onReconnect(() => {
|
|
1004
|
+
_refreshSessionPeerCount(peer, ctx);
|
|
1005
|
+
});
|
|
1006
|
+
try {
|
|
1007
|
+
const assigned = await peer.start();
|
|
1008
|
+
_sessionPeer = peer;
|
|
1009
|
+
_sessionName = sessionName;
|
|
1010
|
+
_sessionPeerCount = 1; // optimistic — overwritten by list_peers below
|
|
1011
|
+
// Broker broadcasts `peer_joined` only to existing peers when a new one
|
|
1012
|
+
// arrives — the newcomer doesn't get retroactive joined events. Ask the
|
|
1013
|
+
// broker for the live peer list to seed the count correctly on join.
|
|
1014
|
+
_refreshSessionPeerCount(peer, ctx);
|
|
1015
|
+
saveLocalConfig(cwd, { agent_name: assigned, session_name: sessionName });
|
|
1016
|
+
ctx.ui.notify(`[remote-pi] Joined session "${sessionName}" as "${assigned}" (${peer.currentRole()})`, "info");
|
|
1017
|
+
_refreshFooter(ctx);
|
|
1018
|
+
}
|
|
1019
|
+
catch (err) {
|
|
1020
|
+
ctx.ui.notify(`[remote-pi] join failed: ${String(err)}`, "error");
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
async function _cmdLeave(ctx) {
|
|
1024
|
+
if (!_sessionPeer) {
|
|
1025
|
+
ctx.ui.notify("[remote-pi] Not in any session.", "info");
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
await _sessionPeer.leave();
|
|
1029
|
+
const name = _sessionName;
|
|
1030
|
+
_sessionPeer = null;
|
|
1031
|
+
_sessionName = null;
|
|
1032
|
+
_sessionPeerCount = 0;
|
|
1033
|
+
ctx.ui.notify(`[remote-pi] Left session "${name}".`, "info");
|
|
1034
|
+
_refreshFooter(ctx);
|
|
1035
|
+
}
|
|
1036
|
+
async function _cmdRename(arg, ctx) {
|
|
1037
|
+
const newName = arg.trim();
|
|
1038
|
+
if (!newName) {
|
|
1039
|
+
ctx.ui.notify("[remote-pi] Usage: /remote-pi rename <new-name>", "warning");
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
1042
|
+
if (!_sessionPeer) {
|
|
1043
|
+
ctx.ui.notify("[remote-pi] Not in any session. Run /remote-pi join first.", "warning");
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
1046
|
+
try {
|
|
1047
|
+
const assigned = await _sessionPeer.rename(newName);
|
|
1048
|
+
const cwd = "cwd" in ctx ? ctx.cwd : process.cwd();
|
|
1049
|
+
saveLocalConfig(cwd, { agent_name: assigned });
|
|
1050
|
+
ctx.ui.notify(`[remote-pi] Renamed to "${assigned}".`, "info");
|
|
1051
|
+
}
|
|
1052
|
+
catch (err) {
|
|
1053
|
+
ctx.ui.notify(`[remote-pi] rename failed: ${String(err)}`, "error");
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
function _cmdSessions(ctx) {
|
|
1057
|
+
const sessions = listSessions();
|
|
1058
|
+
if (sessions.length === 0) {
|
|
1059
|
+
ctx.ui.notify("[remote-pi] No sessions found.", "info");
|
|
1060
|
+
return;
|
|
1061
|
+
}
|
|
1062
|
+
const lines = sessions.map((s) => {
|
|
1063
|
+
const live = sessionHasSock(s) ? "🟢" : "⚪";
|
|
1064
|
+
const me = s === _sessionName ? " (current)" : "";
|
|
1065
|
+
return ` ${live} ${s}${me}`;
|
|
1066
|
+
});
|
|
1067
|
+
ctx.ui.notify(`[remote-pi] Sessions:\n${lines.join("\n")}`, "info");
|
|
1068
|
+
}
|
|
1069
|
+
async function _cmdRelayToggle(ctx) {
|
|
1070
|
+
if (_state === "idle")
|
|
1071
|
+
await _cmdStart(ctx);
|
|
1072
|
+
else
|
|
1073
|
+
await _cmdStop(ctx);
|
|
1074
|
+
}
|
|
1075
|
+
function _cmdRelayStatus(ctx) {
|
|
1076
|
+
_showStatus(ctx);
|
|
1077
|
+
}
|
|
1078
|
+
// ── routeClientMessage ────────────────────────────────────────────────────────
|
|
1079
|
+
export function routeClientMessage(msg, ctx) {
|
|
1080
|
+
// session_sync has its own internal guards — handle before the strict
|
|
1081
|
+
// peer/pi guard so a missing _pi doesn't drop the reply.
|
|
1082
|
+
if (msg.type === "session_sync") {
|
|
1083
|
+
_handleSessionSync(msg);
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
if (!_peerChannel || !_pi)
|
|
1087
|
+
return;
|
|
1088
|
+
switch (msg.type) {
|
|
1089
|
+
case "user_message":
|
|
1090
|
+
_currentTurnId = msg.id;
|
|
1091
|
+
_pi.sendUserMessage(msg.text);
|
|
1092
|
+
break;
|
|
1093
|
+
case "approve_tool":
|
|
1094
|
+
// Approval gate was removed (plano 10.2 revisado). Type kept in
|
|
1095
|
+
// ClientMessage for forward-compat with a future permissions model;
|
|
1096
|
+
// ignore silently if the app still sends it from an older build.
|
|
1097
|
+
break;
|
|
1098
|
+
case "cancel":
|
|
1099
|
+
ctx.abort();
|
|
1100
|
+
_peerChannel.send({ type: "cancelled", in_reply_to: msg.id, target_id: msg.target_id });
|
|
1101
|
+
break;
|
|
1102
|
+
case "ping":
|
|
1103
|
+
_peerChannel.send({ type: "pong", in_reply_to: msg.id });
|
|
1104
|
+
break;
|
|
1105
|
+
case "pair_request":
|
|
1106
|
+
// Already paired — ignore subsequent pair_request to maintain idempotency.
|
|
1107
|
+
// (Token is already consumed and peer is in peers.json.)
|
|
1108
|
+
break;
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
// ── session_sync handler + helpers ────────────────────────────────────────────
|
|
1112
|
+
function _handleSessionSync(msg) {
|
|
1113
|
+
if (!_peerChannel)
|
|
1114
|
+
return;
|
|
1115
|
+
if (_sessionStartedAt === null) {
|
|
1116
|
+
_peerChannel.send({
|
|
1117
|
+
type: "session_history",
|
|
1118
|
+
in_reply_to: msg.id,
|
|
1119
|
+
session_started_at: 0,
|
|
1120
|
+
events: [],
|
|
1121
|
+
eos: true,
|
|
1122
|
+
truncated: false,
|
|
1123
|
+
});
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1126
|
+
// Mirror semantics: always return the last N events. App SUBSTITUTES its
|
|
1127
|
+
// local cache with this response — no delta/since_ts logic.
|
|
1128
|
+
const serverLimit = _getSyncLimit();
|
|
1129
|
+
const requested = msg.limit ?? serverLimit;
|
|
1130
|
+
const effectiveLimit = Math.min(requested, serverLimit); // server clamps
|
|
1131
|
+
const allEvents = _mapAgentMessagesToEvents(_messageBuffer);
|
|
1132
|
+
const slice = effectiveLimit > 0 ? allEvents.slice(-effectiveLimit) : [];
|
|
1133
|
+
const truncated = allEvents.length > effectiveLimit;
|
|
1134
|
+
_peerChannel.send({
|
|
1135
|
+
type: "session_history",
|
|
1136
|
+
in_reply_to: msg.id,
|
|
1137
|
+
session_started_at: _sessionStartedAt,
|
|
1138
|
+
events: slice,
|
|
1139
|
+
eos: true,
|
|
1140
|
+
truncated,
|
|
1141
|
+
});
|
|
1142
|
+
}
|
|
1143
|
+
function _stringifyContent(content) {
|
|
1144
|
+
if (typeof content === "string")
|
|
1145
|
+
return content;
|
|
1146
|
+
if (!Array.isArray(content))
|
|
1147
|
+
return "";
|
|
1148
|
+
return content
|
|
1149
|
+
.map((c) => {
|
|
1150
|
+
if (!c || typeof c !== "object")
|
|
1151
|
+
return "";
|
|
1152
|
+
const block = c;
|
|
1153
|
+
return block.type === "text" ? String(block.text ?? "") : "";
|
|
1154
|
+
})
|
|
1155
|
+
.join("");
|
|
1156
|
+
}
|
|
1157
|
+
/**
|
|
1158
|
+
* Maps SDK AgentMessage[] (UserMessage / AssistantMessage / ToolResultMessage)
|
|
1159
|
+
* into the flat SessionHistoryEvent[] shape consumed by the app.
|
|
1160
|
+
*
|
|
1161
|
+
* Caveats (see report): in_reply_to of agent_message is the *last* user_input
|
|
1162
|
+
* id seen in a linear scan — fine for typical conversational flow but not
|
|
1163
|
+
* a perfect reconstruction of multi-turn ordering when tools interleave.
|
|
1164
|
+
* Stable id for user_input is `sync_<timestamp>`.
|
|
1165
|
+
*/
|
|
1166
|
+
export function _mapAgentMessagesToEvents(messages) {
|
|
1167
|
+
const events = [];
|
|
1168
|
+
let lastUserId = null;
|
|
1169
|
+
for (const m of messages) {
|
|
1170
|
+
const ts = typeof m.timestamp === "number" ? m.timestamp : 0;
|
|
1171
|
+
if (m.role === "user") {
|
|
1172
|
+
const id = `sync_${ts}`;
|
|
1173
|
+
lastUserId = id;
|
|
1174
|
+
events.push({
|
|
1175
|
+
ts,
|
|
1176
|
+
type: "user_input",
|
|
1177
|
+
id,
|
|
1178
|
+
text: _stringifyContent(m.content),
|
|
1179
|
+
});
|
|
1180
|
+
}
|
|
1181
|
+
else if (m.role === "assistant") {
|
|
1182
|
+
const content = Array.isArray(m.content) ? m.content : [];
|
|
1183
|
+
const usage = m.usage
|
|
1184
|
+
? { input_tokens: m.usage.input ?? 0, output_tokens: m.usage.output ?? 0 }
|
|
1185
|
+
: undefined;
|
|
1186
|
+
for (const raw of content) {
|
|
1187
|
+
if (!raw || typeof raw !== "object")
|
|
1188
|
+
continue;
|
|
1189
|
+
const block = raw;
|
|
1190
|
+
if (block.type === "text") {
|
|
1191
|
+
const text = String(block.text ?? "");
|
|
1192
|
+
if (!text)
|
|
1193
|
+
continue;
|
|
1194
|
+
const ev = {
|
|
1195
|
+
ts,
|
|
1196
|
+
type: "agent_message",
|
|
1197
|
+
in_reply_to: lastUserId ?? `sync_${ts}`,
|
|
1198
|
+
text,
|
|
1199
|
+
...(usage ? { usage } : {}),
|
|
1200
|
+
};
|
|
1201
|
+
events.push(ev);
|
|
1202
|
+
}
|
|
1203
|
+
else if (block.type === "toolCall") {
|
|
1204
|
+
events.push({
|
|
1205
|
+
ts,
|
|
1206
|
+
type: "tool_request",
|
|
1207
|
+
tool_call_id: String(block.id ?? ""),
|
|
1208
|
+
tool: String(block.name ?? ""),
|
|
1209
|
+
args: block.arguments ?? {},
|
|
1210
|
+
});
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
else if (m.role === "toolResult") {
|
|
1215
|
+
const text = _stringifyContent(m.content);
|
|
1216
|
+
const tcid = String(m.toolCallId ?? "");
|
|
1217
|
+
events.push(m.isError
|
|
1218
|
+
? { ts, type: "tool_result", tool_call_id: tcid, error: text }
|
|
1219
|
+
: { ts, type: "tool_result", tool_call_id: tcid, result: text });
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
return events;
|
|
1223
|
+
}
|
|
1224
|
+
// ── Standalone CLI ────────────────────────────────────────────────────────────
|
|
1225
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
1226
|
+
const [, , subcmd, ...cliArgs] = process.argv;
|
|
1227
|
+
if (subcmd === "list") {
|
|
1228
|
+
const peers = await listPeers();
|
|
1229
|
+
if (peers.length === 0) {
|
|
1230
|
+
console.log("[remote-pi] No peers");
|
|
1231
|
+
}
|
|
1232
|
+
else {
|
|
1233
|
+
for (const p of peers)
|
|
1234
|
+
console.log(`• ${p.remote_epk.slice(0, 8)} — ${p.name}`);
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
else if (subcmd === "revoke") {
|
|
1238
|
+
const shortid = (cliArgs[0] ?? "").trim();
|
|
1239
|
+
if (!shortid) {
|
|
1240
|
+
console.log("Usage: revoke <shortid>");
|
|
1241
|
+
}
|
|
1242
|
+
else {
|
|
1243
|
+
const peers = await listPeers();
|
|
1244
|
+
const matches = peers.filter((p) => p.remote_epk.startsWith(shortid));
|
|
1245
|
+
if (matches.length === 0)
|
|
1246
|
+
console.log(`No peer matching '${shortid}'`);
|
|
1247
|
+
else if (matches.length > 1)
|
|
1248
|
+
console.log(`Ambiguous: ${matches.map((p) => p.remote_epk.slice(0, 8)).join(", ")}`);
|
|
1249
|
+
else {
|
|
1250
|
+
const peer = matches[0];
|
|
1251
|
+
const { removePeer } = await import("./pairing/storage.js");
|
|
1252
|
+
await removePeer(peer.remote_epk);
|
|
1253
|
+
console.log(`Revoked: ${peer.name} (${peer.remote_epk.slice(0, 8)}…)`);
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
else if (subcmd === "set-relay") {
|
|
1258
|
+
const url = (cliArgs[0] ?? "").trim();
|
|
1259
|
+
if (!url) {
|
|
1260
|
+
console.log(`Usage: set-relay <url> (default: ${kDefaultRelayUrl})`);
|
|
1261
|
+
}
|
|
1262
|
+
else if (!isValidRelayUrl(url)) {
|
|
1263
|
+
console.log(`Invalid URL: ${url}. Must start with ws:// or wss://`);
|
|
1264
|
+
}
|
|
1265
|
+
else {
|
|
1266
|
+
saveConfig({ relay: url });
|
|
1267
|
+
console.log(`Relay set to ${url}`);
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
else if (subcmd === "config") {
|
|
1271
|
+
const { url, source } = resolveRelayUrl();
|
|
1272
|
+
console.log(`Relay: ${url}\n Source: ${source}`);
|
|
1273
|
+
}
|
|
1274
|
+
else {
|
|
1275
|
+
const edKp = await getOrCreateEd25519Keypair();
|
|
1276
|
+
const sessionName = process.cwd().split("/").slice(-2).join("/");
|
|
1277
|
+
const { url: relayUrl, source } = resolveRelayUrl();
|
|
1278
|
+
const roomId = roomIdForCwd(process.cwd());
|
|
1279
|
+
console.log(`[remote-pi] relay: ${relayUrl} (source: ${source}), room: ${roomId}`);
|
|
1280
|
+
void cliArgs;
|
|
1281
|
+
const stop = startQRRotation(edKp.publicKey, sessionName, roomId);
|
|
1282
|
+
process.once("SIGINT", () => { stop(); process.exit(0); });
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
//# sourceMappingURL=index.js.map
|