remote-pi 0.2.1 → 0.4.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 +44 -0
- package/dist/actions/handlers.d.ts +116 -0
- package/dist/actions/handlers.js +152 -0
- package/dist/actions/handlers.js.map +1 -0
- package/dist/actions/registry.d.ts +25 -0
- package/dist/actions/registry.js +34 -0
- package/dist/actions/registry.js.map +1 -0
- package/dist/bin/supervisord.js +43 -1
- package/dist/bin/supervisord.js.map +1 -1
- package/dist/commands/builtin_mirror.d.ts +58 -0
- package/dist/commands/builtin_mirror.js +71 -0
- package/dist/commands/builtin_mirror.js.map +1 -0
- package/dist/commands/list_commands.d.ts +60 -0
- package/dist/commands/list_commands.js +73 -0
- package/dist/commands/list_commands.js.map +1 -0
- package/dist/daemon/control_protocol.d.ts +9 -1
- package/dist/daemon/control_protocol.js +1 -1
- package/dist/daemon/control_protocol.js.map +1 -1
- package/dist/daemon/rpc_child.d.ts +24 -0
- package/dist/daemon/rpc_child.js +41 -2
- package/dist/daemon/rpc_child.js.map +1 -1
- package/dist/daemon/supervisor.d.ts +11 -0
- package/dist/daemon/supervisor.js +56 -4
- package/dist/daemon/supervisor.js.map +1 -1
- package/dist/index.d.ts +7 -1
- package/dist/index.js +749 -208
- package/dist/index.js.map +1 -1
- package/dist/mcp/mesh_server.d.ts +16 -0
- package/dist/mcp/mesh_server.js +207 -0
- package/dist/mcp/mesh_server.js.map +1 -0
- package/dist/protocol/types.d.ts +103 -0
- package/dist/session/bridge.d.ts +39 -0
- package/dist/session/bridge.js +41 -0
- package/dist/session/bridge.js.map +1 -0
- package/dist/session/mesh_node.d.ts +123 -0
- package/dist/session/mesh_node.js +203 -0
- package/dist/session/mesh_node.js.map +1 -0
- package/dist/session/setup_wizard.d.ts +6 -23
- package/dist/session/setup_wizard.js +6 -15
- package/dist/session/setup_wizard.js.map +1 -1
- package/dist/session/tools.d.ts +1 -1
- package/dist/transport/relay_client.d.ts +8 -0
- package/dist/transport/relay_client.js +50 -2
- package/dist/transport/relay_client.js.map +1 -1
- package/package.json +5 -3
- package/skills/claude-agent-network/SKILL.md +239 -0
package/dist/index.js
CHANGED
|
@@ -30,19 +30,19 @@
|
|
|
30
30
|
* for integration tests.
|
|
31
31
|
*/
|
|
32
32
|
import { randomUUID } from "node:crypto";
|
|
33
|
-
import {
|
|
33
|
+
import { SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
34
|
+
import { buildQRUri, qrSession, renderQRAscii } from "./pairing/qr.js";
|
|
34
35
|
import { addPeer, getOrCreateEd25519Keypair, listOwnerPubkeys, listPeers, removePeer, } from "./pairing/storage.js";
|
|
35
36
|
import { MeshClient } from "./mesh/client.js";
|
|
36
37
|
import { SelfRevoke } from "./mesh/self_revoke.js";
|
|
37
38
|
import { RelayClient, RoomAlreadyOpenError } from "./transport/relay_client.js";
|
|
38
39
|
import { PlainPeerChannel } from "./transport/peer_channel.js";
|
|
39
40
|
import { roomIdForCwd } from "./rooms.js";
|
|
40
|
-
import { SessionPeer } from "./session/peer.js";
|
|
41
41
|
import { registerAgentTools } from "./session/tools.js";
|
|
42
|
-
import { BrokerRemote } from "./session/broker_remote.js";
|
|
43
42
|
import { formatPeerInventory } from "./session/peer_inventory.js";
|
|
44
|
-
import {
|
|
45
|
-
import {
|
|
43
|
+
import { MeshNode } from "./session/mesh_node.js";
|
|
44
|
+
import { handleSessionCompact, handleSessionNew, handleModelSet, handleThinkingSet, handleListModels, } from "./actions/handlers.js";
|
|
45
|
+
import { ensureModelRegistry } from "./actions/registry.js";
|
|
46
46
|
import { ensureGlobalDirs, LOCAL_SESSION_NAME, sessionAuditPath, sessionSockPath, skillsDir, } from "./session/global_config.js";
|
|
47
47
|
import { acquireCwdLock } from "./session/cwd_lock.js";
|
|
48
48
|
import { addDaemon, listDaemons, removeDaemon } from "./daemon/registry.js";
|
|
@@ -51,10 +51,12 @@ import { installService, uninstallService, linkCliBinaries, unlinkCliBinaries }
|
|
|
51
51
|
import { defaultAgentName, effectiveAutoStartRelay, loadLocalConfig, localConfigExists, saveLocalConfig, } from "./session/local_config.js";
|
|
52
52
|
import { runSetupWizard } from "./session/setup_wizard.js";
|
|
53
53
|
import { updateFooter } from "./ui/footer.js";
|
|
54
|
-
import { join } from "node:path";
|
|
54
|
+
import { join, dirname, resolve } from "node:path";
|
|
55
55
|
import { fileURLToPath } from "node:url";
|
|
56
|
-
import { mkdirSync, copyFileSync, existsSync, unlinkSync, readFileSync } from "node:fs";
|
|
57
|
-
import {
|
|
56
|
+
import { mkdirSync, copyFileSync, existsSync, unlinkSync, readFileSync, writeFileSync, realpathSync } from "node:fs";
|
|
57
|
+
import { createInterface } from "node:readline";
|
|
58
|
+
import { spawnSync } from "node:child_process";
|
|
59
|
+
import { hostname, homedir } from "node:os";
|
|
58
60
|
import { kDefaultRelayUrl, resolveRelayUrl, saveConfig, isValidRelayUrl, isWebSocketScheme, toWebSocketUrl, } from "./config.js";
|
|
59
61
|
let _state = "idle";
|
|
60
62
|
let _relay = null;
|
|
@@ -74,17 +76,22 @@ let _relayUrl = null; // URL used by current _relay connection
|
|
|
74
76
|
const _activePeers = new Map();
|
|
75
77
|
let _peerShort = ""; // shortid of the most recently attached peer (UX hint only)
|
|
76
78
|
let _myRoomId = null; // this Pi's room id (derived from cwd)
|
|
79
|
+
// Plan/28 Wave D.1: `thinking` published alongside `model` so the app's
|
|
80
|
+
// Quick Actions sheet hydrates the thinking segmented control on first
|
|
81
|
+
// open instead of starting null. The SDK fires `thinking_level_select`
|
|
82
|
+
// on every change (initial load + user toggle), mirrored to room_meta
|
|
83
|
+
// the same way model is — apps subscribe to one channel for both.
|
|
77
84
|
let _myRoomMeta = null;
|
|
78
85
|
let _currentModel = undefined; // last-known model name
|
|
86
|
+
let _currentThinking = undefined; // last-known thinking level
|
|
79
87
|
// ── Agent-network session (plano 19) ──────────────────────────────────────────
|
|
80
|
-
|
|
88
|
+
// MeshNode owns both the local UDS mesh (SessionPeer) and the optional
|
|
89
|
+
// cross-PC relay bridge (BrokerRemote + PiForwardClient). The bridge is
|
|
90
|
+
// attached via `_meshNode.attachBridge()` once the relay WS is up and this
|
|
91
|
+
// Pi is the leader; MeshNode re-attaches it across UDS failovers.
|
|
92
|
+
let _meshNode = null;
|
|
81
93
|
let _sessionName = null;
|
|
82
94
|
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;
|
|
88
95
|
// Cached state of global pairings (`peers.json`). Pairing is per-machine, so a
|
|
89
96
|
// device paired in any Pi process is paired everywhere. Refreshed on boot,
|
|
90
97
|
// after addPeer (handle_pair_request), and after removePeer (revoke).
|
|
@@ -118,77 +125,51 @@ function _refreshSessionPeerCount(peer, ctx) {
|
|
|
118
125
|
function _currentModelName() {
|
|
119
126
|
return _currentModel;
|
|
120
127
|
}
|
|
121
|
-
// ── Cross-PC mesh wiring (plan/25 Wave B/C) ───────────────────────────────────
|
|
122
128
|
/**
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
* sibling set — push from a remote sibling will populate the cache later.
|
|
129
|
+
* Cache the active model name and fan it out to subscribed apps via a
|
|
130
|
+
* `room_meta_update`. The relay push is a no-op when the room isn't up yet —
|
|
131
|
+
* the next `room_meta` hello carries the cached value instead. Shared by the
|
|
132
|
+
* `model_select` event and the connect/turn-start seeding, so a daemon that
|
|
133
|
+
* just runs its DEFAULT model still reports it: `model_select` only fires on an
|
|
134
|
+
* explicit set/cycle (never on settings load), so default-model daemons would
|
|
135
|
+
* otherwise never surface their model.
|
|
131
136
|
*/
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
}
|
|
137
|
+
function _setCurrentModel(name) {
|
|
138
|
+
_currentModel = name;
|
|
139
|
+
if (_myRoomMeta)
|
|
140
|
+
_myRoomMeta = { ..._myRoomMeta, model: name };
|
|
141
|
+
if (_relay && _myRoomId) {
|
|
142
|
+
_relay.sendControl({ type: "room_meta_update", room_id: _myRoomId, meta: { model: name } });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Plan/32: publish the `working` flag as room_meta (raw, no debounce — the
|
|
147
|
+
* app debounces). Same shape as model/thinking updates. Used by turn_start/end
|
|
148
|
+
* AND by the compaction handlers: `compact()` doesn't run a turn (it
|
|
149
|
+
* disconnects the agent + aborts, emitting compaction_start, NOT turn_start),
|
|
150
|
+
* so room_meta.working must be bracketed manually around compaction.
|
|
151
|
+
*/
|
|
152
|
+
function _publishWorking(working) {
|
|
153
|
+
if (_myRoomMeta)
|
|
154
|
+
_myRoomMeta = { ..._myRoomMeta, working };
|
|
155
|
+
if (_relay && _myRoomId) {
|
|
156
|
+
_relay.sendControl({ type: "room_meta_update", room_id: _myRoomId, meta: { working } });
|
|
163
157
|
}
|
|
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
158
|
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
159
|
+
// ── Cross-PC mesh wiring (plan/25 Wave B/C) ───────────────────────────────────
|
|
160
|
+
/**
|
|
161
|
+
* Hand the live relay to MeshNode so it can bring up the cross-PC bridge
|
|
162
|
+
* (BrokerRemote + sibling discovery) — but only when this Pi is the leader
|
|
163
|
+
* (broker host). MeshNode is idempotent + re-attaches across UDS failovers,
|
|
164
|
+
* so this is safe to call from `_cmdStart`, relay reconnect, or SelfRevoke.
|
|
165
|
+
* No-op until the relay WS + cached identity are both present.
|
|
166
|
+
*/
|
|
167
|
+
function _attachBridgeIfReady() {
|
|
168
|
+
if (!_meshNode || !_relay || !_relayUrl || !_cachedEd25519)
|
|
169
|
+
return;
|
|
170
|
+
void _meshNode
|
|
171
|
+
.attachBridge({ relay: _relay, relayUrl: _relayUrl, keypair: _cachedEd25519 })
|
|
172
|
+
.catch(() => { });
|
|
192
173
|
}
|
|
193
174
|
/** Refreshes the Pi TUI footer slots from current module state. Safe no-op when ctx lacks ui. */
|
|
194
175
|
function _refreshFooter(ctx) {
|
|
@@ -205,7 +186,7 @@ function _refreshFooter(ctx) {
|
|
|
205
186
|
// `/remote-pi status` line, not the footer slot).
|
|
206
187
|
devicePaired: _anyPeerActive() ? _peerShort : undefined,
|
|
207
188
|
hasPairings: _hasGlobalPairings,
|
|
208
|
-
agentName:
|
|
189
|
+
agentName: _meshNode?.name(),
|
|
209
190
|
};
|
|
210
191
|
updateFooter({ ui: { setStatus: ui.setStatus.bind(ui), setTitle: ui.setTitle.bind(ui) } }, state);
|
|
211
192
|
}
|
|
@@ -258,6 +239,42 @@ export function _setSessionStartedAtForTest(ts) {
|
|
|
258
239
|
export function _setCurrentModelForTest(name) {
|
|
259
240
|
_currentModel = name;
|
|
260
241
|
}
|
|
242
|
+
/** Test-only: override the bound AgentSession so a spy can capture the
|
|
243
|
+
* content handed to `sendUserMessage` (plan/30 multimodal ingest). */
|
|
244
|
+
export function _setPiForTest(pi) {
|
|
245
|
+
_pi = pi;
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Persist a model change to the PROJECT settings (`<cwd>/.pi/settings.json`) so
|
|
249
|
+
* a model picked from the app survives a Pi/daemon restart. `pi.setModel` only
|
|
250
|
+
* sets the LIVE model — on the next restart a fresh session reads the saved
|
|
251
|
+
* default and reverts (the reported bug). We write the PROJECT scope, NOT
|
|
252
|
+
* global, deliberately: the SDK merges global←project with PROJECT winning
|
|
253
|
+
* (`SettingsManager`), so a folder that already has a project default (every
|
|
254
|
+
* created daemon does) would shadow a global write like the TUI's. Project
|
|
255
|
+
* scope is also correct for a fleet — each daemon keeps its own model rather
|
|
256
|
+
* than leaking one default globally.
|
|
257
|
+
*
|
|
258
|
+
* Read-merge-write + best-effort: preserves other keys and never throws (a
|
|
259
|
+
* settings write must not fail the live model change, which already applied).
|
|
260
|
+
*/
|
|
261
|
+
function _persistModelDefault(provider, modelId) {
|
|
262
|
+
try {
|
|
263
|
+
const path = join(process.cwd(), ".pi", "settings.json");
|
|
264
|
+
let obj = {};
|
|
265
|
+
try {
|
|
266
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
267
|
+
if (parsed && typeof parsed === "object")
|
|
268
|
+
obj = parsed;
|
|
269
|
+
}
|
|
270
|
+
catch { /* no existing/parseable file → start fresh */ }
|
|
271
|
+
obj["defaultProvider"] = provider;
|
|
272
|
+
obj["defaultModel"] = modelId;
|
|
273
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
274
|
+
writeFileSync(path, JSON.stringify(obj, null, 2));
|
|
275
|
+
}
|
|
276
|
+
catch { /* best-effort — model change already applied live */ }
|
|
277
|
+
}
|
|
261
278
|
// Per-turn messaging state
|
|
262
279
|
let _currentTurnId = null;
|
|
263
280
|
// Module-level pi reference
|
|
@@ -386,8 +403,8 @@ function _detachPeerChannel(appPeerId) {
|
|
|
386
403
|
* the raw cwd path.
|
|
387
404
|
*/
|
|
388
405
|
function _displayName(cwd) {
|
|
389
|
-
if (
|
|
390
|
-
return
|
|
406
|
+
if (_meshNode)
|
|
407
|
+
return _meshNode.name();
|
|
391
408
|
const local = loadLocalConfig(cwd);
|
|
392
409
|
return local.agent_name || defaultAgentName(cwd);
|
|
393
410
|
}
|
|
@@ -440,7 +457,7 @@ function _goIdle(byeReason) {
|
|
|
440
457
|
_selfRevoke?.stop();
|
|
441
458
|
_selfRevoke = null;
|
|
442
459
|
// Cross-PC routing relies on _relay being up; tear it down here too.
|
|
443
|
-
|
|
460
|
+
_meshNode?.detachBridge();
|
|
444
461
|
// Preserve _sessionStartedAt + _messageBuffer across stop/start cycles.
|
|
445
462
|
// The Pi agent session outlives the relay connection — `message_end` keeps
|
|
446
463
|
// firing for terminal turns even while idle, and the buffer must survive
|
|
@@ -479,7 +496,7 @@ function _onRelayClose() {
|
|
|
479
496
|
_relay = null; // _relayUrl preserved for retry
|
|
480
497
|
// Cross-PC routing relies on _relay; bring it down. Will be re-instated
|
|
481
498
|
// by _attemptReconnect on success.
|
|
482
|
-
|
|
499
|
+
_meshNode?.detachBridge();
|
|
483
500
|
_state = "started";
|
|
484
501
|
_refreshFooter();
|
|
485
502
|
_scheduleReconnect();
|
|
@@ -536,7 +553,7 @@ async function _attemptReconnect() {
|
|
|
536
553
|
relay.on("close", _onRelayClose);
|
|
537
554
|
_stopAutoListener = _installAutoListener(relay);
|
|
538
555
|
// Plan/25 Wave B/C: relay is back; bring cross-PC routing back online.
|
|
539
|
-
|
|
556
|
+
_attachBridgeIfReady();
|
|
540
557
|
// _state stays "started"; peer reconnect (if previously paired) flows
|
|
541
558
|
// through _installAutoListener → _findKnownPeer → _promoteToPaired
|
|
542
559
|
// automatically when the app sends any inner.
|
|
@@ -757,8 +774,19 @@ async function _handlePairRequest(relay, appPeerId, inner) {
|
|
|
757
774
|
});
|
|
758
775
|
}
|
|
759
776
|
// ── Extension factory (default export) ───────────────────────────────────────
|
|
760
|
-
// Stores most recent command context so the auto-listener can use ui.notify
|
|
777
|
+
// Stores most recent command context so the auto-listener can use ui.notify.
|
|
778
|
+
// NOTE: this is a CAPTURED command ctx — the SDK marks it stale after a
|
|
779
|
+
// session replacement (newSession/fork/switch/reload). We re-capture it via
|
|
780
|
+
// `withSession` when WE drive a newSession (see the session_new dispatch).
|
|
761
781
|
let _lastCtx = null;
|
|
782
|
+
// Freshest base ExtensionContext, re-captured on EVERY `session_start`
|
|
783
|
+
// (startup/new/fork/reload/resume). The session_start ctx is always bound to
|
|
784
|
+
// the CURRENT session, so compact (a base-ctx method) routed through here
|
|
785
|
+
// never hits a stale ctx — regardless of who triggered the replacement (an
|
|
786
|
+
// app Quick Action OR a `/new` typed in the Pi TUI). It carries only base-ctx
|
|
787
|
+
// methods (no newSession — that's command-ctx only), so command ops keep using
|
|
788
|
+
// `_lastCtx`.
|
|
789
|
+
let _lastEventCtx = null;
|
|
762
790
|
const _noopCtx = { ui: { notify: () => undefined }, abort: () => undefined };
|
|
763
791
|
const extension = (pi) => {
|
|
764
792
|
_pi = pi;
|
|
@@ -774,9 +802,9 @@ const extension = (pi) => {
|
|
|
774
802
|
_refreshPairingsCache();
|
|
775
803
|
pi.on("resources_discover", () => ({ skillPaths: [skillsDir()] }));
|
|
776
804
|
// Plano 20: agent_send + agent_request tools so the LLM can drive the
|
|
777
|
-
// session network natively. Getter captures `
|
|
805
|
+
// session network natively. Getter captures `_meshNode` live so the
|
|
778
806
|
// tool always sees the current state.
|
|
779
|
-
registerAgentTools(pi, () =>
|
|
807
|
+
registerAgentTools(pi, () => _meshNode?.peer() ?? null);
|
|
780
808
|
// Tool calls execute without prompting the remote user. The Pi SDK has no
|
|
781
809
|
// native `requiresApproval` per tool, and a hardcoded gate (Bash/Edit/Write)
|
|
782
810
|
// misfired on every custom tool from third-party packages. Approval will
|
|
@@ -804,18 +832,29 @@ const extension = (pi) => {
|
|
|
804
832
|
const modelName = m?.name ?? m?.id;
|
|
805
833
|
if (!modelName)
|
|
806
834
|
return;
|
|
807
|
-
|
|
808
|
-
//
|
|
809
|
-
//
|
|
810
|
-
|
|
835
|
+
// Cache + fan out. Keeps the cached room_meta fresh so a future reconnect
|
|
836
|
+
// carries the current model in its hello, and pushes a room_meta_update to
|
|
837
|
+
// apps already subscribed.
|
|
838
|
+
_setCurrentModel(modelName);
|
|
839
|
+
});
|
|
840
|
+
// Plan/28 Wave D.1: mirror model's room_meta_update path for thinking
|
|
841
|
+
// level so the app hydrates the segmented control on first open instead
|
|
842
|
+
// of starting null. SDK fires `thinking_level_select` on settings load
|
|
843
|
+
// AND on every user toggle (matching `model_select`'s behavior), so
|
|
844
|
+
// late-pairing apps see the current level via `room_meta_updated`.
|
|
845
|
+
pi.on("thinking_level_select", (event) => {
|
|
846
|
+
const level = event?.level;
|
|
847
|
+
if (!level)
|
|
848
|
+
return;
|
|
849
|
+
_currentThinking = level;
|
|
811
850
|
if (_myRoomMeta)
|
|
812
|
-
_myRoomMeta = { ..._myRoomMeta,
|
|
851
|
+
_myRoomMeta = { ..._myRoomMeta, thinking: level };
|
|
813
852
|
if (!_relay || !_myRoomId)
|
|
814
853
|
return;
|
|
815
854
|
_relay.sendControl({
|
|
816
855
|
type: "room_meta_update",
|
|
817
856
|
room_id: _myRoomId,
|
|
818
|
-
meta: {
|
|
857
|
+
meta: { thinking: level },
|
|
819
858
|
});
|
|
820
859
|
});
|
|
821
860
|
pi.on("message_update", (event) => {
|
|
@@ -843,9 +882,14 @@ const extension = (pi) => {
|
|
|
843
882
|
pi.on("tool_execution_end", (event) => {
|
|
844
883
|
if (!_anyPeerActive())
|
|
845
884
|
return;
|
|
885
|
+
// Stringify like the history mapper (same helper) so the live text == what
|
|
886
|
+
// a session_sync replays for this tool. Raw `String(event.result)` turned
|
|
887
|
+
// a content-array/object into "[object Object]" and the success branch sent
|
|
888
|
+
// the object unstringified — both diverging from re-sync.
|
|
889
|
+
const text = _stringifyToolResult(event.result);
|
|
846
890
|
const msg = event.isError
|
|
847
|
-
? { type: "tool_result", tool_call_id: event.toolCallId, error:
|
|
848
|
-
: { type: "tool_result", tool_call_id: event.toolCallId, result:
|
|
891
|
+
? { type: "tool_result", tool_call_id: event.toolCallId, error: text }
|
|
892
|
+
: { type: "tool_result", tool_call_id: event.toolCallId, result: text };
|
|
849
893
|
_broadcastToActive(msg);
|
|
850
894
|
});
|
|
851
895
|
// Cumulative session buffer fed via `message_end`, which fires once per
|
|
@@ -862,6 +906,21 @@ const extension = (pi) => {
|
|
|
862
906
|
if (m.role === "user" || m.role === "assistant" || m.role === "toolResult") {
|
|
863
907
|
_messageBuffer.push(m);
|
|
864
908
|
}
|
|
909
|
+
// Forward a failed turn to connected owners. Without this the app just
|
|
910
|
+
// hangs with no response when the provider errors (e.g. the TUI's
|
|
911
|
+
// "Provider finish_reason: error"): the SDK surfaces the failure as an
|
|
912
|
+
// assistant message with stopReason "error" + an `errorMessage` (pi-ai).
|
|
913
|
+
// `error` is an existing ServerMessage the app already renders — no
|
|
914
|
+
// protocol/app change. `in_reply_to` ties it to the turn the app awaits.
|
|
915
|
+
if (m.role === "assistant" && m.stopReason === "error" && _anyPeerActive()) {
|
|
916
|
+
const message = typeof m.errorMessage === "string" && m.errorMessage
|
|
917
|
+
? m.errorMessage
|
|
918
|
+
: "Provider error";
|
|
919
|
+
const errMsg = _currentTurnId
|
|
920
|
+
? { type: "error", in_reply_to: _currentTurnId, code: "provider_error", message }
|
|
921
|
+
: { type: "error", code: "provider_error", message };
|
|
922
|
+
_broadcastToActive(errMsg);
|
|
923
|
+
}
|
|
865
924
|
});
|
|
866
925
|
pi.on("agent_end", () => {
|
|
867
926
|
// Buffer is fed by `message_end`; here we only finalize the outbound
|
|
@@ -876,18 +935,71 @@ const extension = (pi) => {
|
|
|
876
935
|
// mid-turn (and `received` once idle). Fire-and-forget — if the broker
|
|
877
936
|
// can't be reached, the worst case is a bad ACK answer; recovery is the
|
|
878
937
|
// next turn boundary. Skip silently when no mesh session is joined.
|
|
879
|
-
pi.on("turn_start", () => {
|
|
880
|
-
if (
|
|
938
|
+
pi.on("turn_start", (_event, ctx) => {
|
|
939
|
+
// Late model hydration: if the model was still unknown at connect (resolved
|
|
940
|
+
// lazily by the SDK), grab it on the first turn and fan it out — so a daemon
|
|
941
|
+
// whose model only materialises at turn 1 still reports it to the app.
|
|
942
|
+
if (!_currentModel) {
|
|
943
|
+
try {
|
|
944
|
+
const m = ctx.getModel?.();
|
|
945
|
+
const name = m?.name ?? m?.id;
|
|
946
|
+
if (name)
|
|
947
|
+
_setCurrentModel(name);
|
|
948
|
+
}
|
|
949
|
+
catch { /* defensive — never block a turn on a model lookup */ }
|
|
950
|
+
}
|
|
951
|
+
// Plan/32 Part B: publish working=true as room_meta (raw, no debounce —
|
|
952
|
+
// the debounce lives in the app). Same shape as the model/thinking updates.
|
|
953
|
+
if (_myRoomMeta)
|
|
954
|
+
_myRoomMeta = { ..._myRoomMeta, working: true };
|
|
955
|
+
if (_relay && _myRoomId) {
|
|
956
|
+
_relay.sendControl({ type: "room_meta_update", room_id: _myRoomId, meta: { working: true } });
|
|
957
|
+
}
|
|
958
|
+
if (!_meshNode)
|
|
881
959
|
return;
|
|
882
|
-
void
|
|
960
|
+
void _meshNode.send("broker", { type: "turn_state", busy: true })
|
|
883
961
|
.catch(() => { });
|
|
884
962
|
});
|
|
885
963
|
pi.on("turn_end", () => {
|
|
886
|
-
|
|
964
|
+
// Plan/32 Part B: publish working=false as room_meta (raw, no debounce).
|
|
965
|
+
if (_myRoomMeta)
|
|
966
|
+
_myRoomMeta = { ..._myRoomMeta, working: false };
|
|
967
|
+
if (_relay && _myRoomId) {
|
|
968
|
+
_relay.sendControl({ type: "room_meta_update", room_id: _myRoomId, meta: { working: false } });
|
|
969
|
+
}
|
|
970
|
+
if (!_meshNode)
|
|
887
971
|
return;
|
|
888
|
-
void
|
|
972
|
+
void _meshNode.send("broker", { type: "turn_state", busy: false })
|
|
889
973
|
.catch(() => { });
|
|
890
974
|
});
|
|
975
|
+
// Plan/32: compaction feedback. compact() doesn't run a turn, so bracket it
|
|
976
|
+
// with working=true/false here. Returning void = no veto → default
|
|
977
|
+
// compaction proceeds.
|
|
978
|
+
pi.on("session_before_compact", () => {
|
|
979
|
+
_publishWorking(true);
|
|
980
|
+
});
|
|
981
|
+
pi.on("session_compact", (event) => {
|
|
982
|
+
const entry = event?.compactionEntry;
|
|
983
|
+
const summary = typeof entry?.summary === "string" ? entry.summary : "";
|
|
984
|
+
const tokensBefore = typeof entry?.tokensBefore === "number" ? entry.tokensBefore : 0;
|
|
985
|
+
const ts = Date.now();
|
|
986
|
+
// (2) Persist in history: the CompactionEntry never reaches _messageBuffer
|
|
987
|
+
// via message_end (only user/assistant/toolResult), so push a synthetic
|
|
988
|
+
// marker the mapper turns into a `compaction` event — survives session_sync.
|
|
989
|
+
_messageBuffer.push({ role: "compaction", content: summary, timestamp: ts, tokensBefore });
|
|
990
|
+
// (1) Live result to every connected owner.
|
|
991
|
+
_broadcastToActive({ type: "compaction", summary, tokens_before: tokensBefore, ts });
|
|
992
|
+
// (3) Working ends.
|
|
993
|
+
_publishWorking(false);
|
|
994
|
+
});
|
|
995
|
+
// Re-capture the freshest base ctx on every session replacement so compact
|
|
996
|
+
// never operates on a stale captured ctx — this is the fix for the
|
|
997
|
+
// "stale after session replacement" crash when the app taps Compact after a
|
|
998
|
+
// New session. Fires on startup/new/fork/reload/resume; the ctx is always
|
|
999
|
+
// bound to the current session.
|
|
1000
|
+
pi.on("session_start", (_event, ctx) => {
|
|
1001
|
+
_lastEventCtx = ctx;
|
|
1002
|
+
});
|
|
891
1003
|
// ── Commands ──────────────────────────────────────────────────────────────
|
|
892
1004
|
//
|
|
893
1005
|
// Final surface: 8 commands. Pre-2026-05-23 we had 20 commands covering
|
|
@@ -949,10 +1061,10 @@ const extension = (pi) => {
|
|
|
949
1061
|
await _cmdPeers(ctx);
|
|
950
1062
|
}
|
|
951
1063
|
else if (sub.startsWith("create")) {
|
|
952
|
-
_cmdCreate(sub.slice("create".length).trim(), ctx);
|
|
1064
|
+
await _cmdCreate(sub.slice("create".length).trim(), ctx);
|
|
953
1065
|
}
|
|
954
1066
|
else if (sub.startsWith("remove")) {
|
|
955
|
-
_cmdRemove(sub.slice("remove".length).trim(), ctx);
|
|
1067
|
+
await _cmdRemove(sub.slice("remove".length).trim(), ctx);
|
|
956
1068
|
}
|
|
957
1069
|
else if (sub === "daemons") {
|
|
958
1070
|
await _cmdDaemonsList(ctx);
|
|
@@ -1005,12 +1117,12 @@ const extension = (pi) => {
|
|
|
1005
1117
|
// Daemon registry (plan/26 Wave 1) — create + remove. start/stop/send/
|
|
1006
1118
|
// status/install/uninstall come in later waves with the supervisor.
|
|
1007
1119
|
pi.registerCommand("remote-pi create", {
|
|
1008
|
-
description: "Register a folder as a daemon (
|
|
1009
|
-
handler: async (args, ctx) => { _lastCtx = ctx; _cmdCreate(args.trim(), ctx); },
|
|
1120
|
+
description: "Register a folder as a daemon and start it (when the supervisor is running)",
|
|
1121
|
+
handler: async (args, ctx) => { _lastCtx = ctx; await _cmdCreate(args.trim(), ctx); },
|
|
1010
1122
|
});
|
|
1011
1123
|
pi.registerCommand("remote-pi remove", {
|
|
1012
|
-
description: "
|
|
1013
|
-
handler: async (args, ctx) => { _lastCtx = ctx; _cmdRemove(args.trim(), ctx); },
|
|
1124
|
+
description: "Stop + unregister a daemon by id (local config is preserved)",
|
|
1125
|
+
handler: async (args, ctx) => { _lastCtx = ctx; await _cmdRemove(args.trim(), ctx); },
|
|
1014
1126
|
});
|
|
1015
1127
|
// Fleet ops via the supervisor (plan/26 W2). `/remote-pi stop` stays as
|
|
1016
1128
|
// local stop — fleet stop is `/remote-pi daemon stop`.
|
|
@@ -1023,6 +1135,18 @@ const extension = (pi) => {
|
|
|
1023
1135
|
// Service install / uninstall (plan/26 W3)
|
|
1024
1136
|
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
1137
|
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 }); } });
|
|
1138
|
+
// Daemon mode: when spawned by pi-supervisord (REMOTE_PI_DAEMON=1) there is
|
|
1139
|
+
// no human to type /remote-pi, so we auto-init after the factory returns.
|
|
1140
|
+
// _cmdRoot checks localConfig.auto_start_relay and connects to the relay +
|
|
1141
|
+
// local broker automatically; if no config exists it is a no-op (no wizard
|
|
1142
|
+
// in headless mode).
|
|
1143
|
+
if (process.env["REMOTE_PI_DAEMON"] === "1") {
|
|
1144
|
+
const daemonCtx = {
|
|
1145
|
+
ui: { notify: (msg) => process.stderr.write(`${msg}\n`) },
|
|
1146
|
+
cwd: process.cwd(),
|
|
1147
|
+
};
|
|
1148
|
+
setTimeout(() => { void _cmdRoot(daemonCtx); }, 0);
|
|
1149
|
+
}
|
|
1026
1150
|
};
|
|
1027
1151
|
export default extension;
|
|
1028
1152
|
// ── Command implementations ───────────────────────────────────────────────────
|
|
@@ -1037,8 +1161,8 @@ function _cmdStatus(ctx) {
|
|
|
1037
1161
|
const relayUrl = _relayUrl ?? resolveRelayUrl().url;
|
|
1038
1162
|
// Mesh line
|
|
1039
1163
|
let meshLine;
|
|
1040
|
-
if (
|
|
1041
|
-
const name =
|
|
1164
|
+
if (_meshNode) {
|
|
1165
|
+
const name = _meshNode.name();
|
|
1042
1166
|
meshLine = `🟢 Local mesh: connected as "${name}" (${_sessionPeerCount} peer${_sessionPeerCount === 1 ? "" : "s"})`;
|
|
1043
1167
|
}
|
|
1044
1168
|
else {
|
|
@@ -1070,13 +1194,13 @@ function _cmdStatus(ctx) {
|
|
|
1070
1194
|
* their machine vs. on a paired sibling Pi.
|
|
1071
1195
|
*/
|
|
1072
1196
|
async function _cmdPeers(ctx) {
|
|
1073
|
-
if (!
|
|
1197
|
+
if (!_meshNode) {
|
|
1074
1198
|
ctx.ui.notify("[remote-pi] Not on the local mesh. Run /remote-pi to join.", "warning");
|
|
1075
1199
|
return;
|
|
1076
1200
|
}
|
|
1077
1201
|
let peers;
|
|
1078
1202
|
try {
|
|
1079
|
-
const reply = await
|
|
1203
|
+
const reply = await _meshNode.request("broker", { type: "list_peers" }, 2000);
|
|
1080
1204
|
peers = reply.body?.peers ?? [];
|
|
1081
1205
|
}
|
|
1082
1206
|
catch (err) {
|
|
@@ -1085,7 +1209,7 @@ async function _cmdPeers(ctx) {
|
|
|
1085
1209
|
}
|
|
1086
1210
|
// Exclude self from the printed list — `list_peers` returns every peer
|
|
1087
1211
|
// registered with the broker including the caller, which is noise here.
|
|
1088
|
-
const selfName =
|
|
1212
|
+
const selfName = _meshNode.name();
|
|
1089
1213
|
ctx.ui.notify(`[remote-pi] peers:\n${formatPeerInventory(peers, selfName)}`, "info");
|
|
1090
1214
|
}
|
|
1091
1215
|
/**
|
|
@@ -1121,30 +1245,25 @@ async function _cmdRoot(ctx) {
|
|
|
1121
1245
|
return;
|
|
1122
1246
|
}
|
|
1123
1247
|
const baseDefault = defaultAgentName(cwd);
|
|
1124
|
-
const
|
|
1248
|
+
const newConfig = await runSetupWizard(ui, {
|
|
1125
1249
|
agent_name: baseDefault,
|
|
1126
1250
|
use_relay: true,
|
|
1127
|
-
enable_daemon: false,
|
|
1128
1251
|
});
|
|
1129
|
-
if (!
|
|
1252
|
+
if (!newConfig) {
|
|
1130
1253
|
ctx.ui.notify("[remote-pi] Setup cancelled.", "info");
|
|
1131
1254
|
return;
|
|
1132
1255
|
}
|
|
1133
|
-
// `enable_daemon` is wizard-only state, not part of LocalConfig.
|
|
1134
|
-
const { enable_daemon, ...newConfig } = wizardResult;
|
|
1135
1256
|
saveLocalConfig(cwd, newConfig);
|
|
1136
1257
|
ctx.ui.notify(`[remote-pi] Config saved to ${cwd}/.pi/remote-pi/config.json`, "info");
|
|
1137
1258
|
await _cmdJoin(ctx);
|
|
1138
1259
|
if (effectiveAutoStartRelay(newConfig))
|
|
1139
1260
|
await _cmdStart(ctx);
|
|
1140
|
-
if (enable_daemon)
|
|
1141
|
-
_cmdInstall(ctx, { linkCli: true });
|
|
1142
1261
|
_cmdStatus(ctx);
|
|
1143
1262
|
return;
|
|
1144
1263
|
}
|
|
1145
1264
|
// Returning user with config: auto-start if requested + currently inactive.
|
|
1146
1265
|
const config = loadLocalConfig(cwd);
|
|
1147
|
-
if (effectiveAutoStartRelay(config) && !
|
|
1266
|
+
if (effectiveAutoStartRelay(config) && !_meshNode) {
|
|
1148
1267
|
await _cmdJoin(ctx);
|
|
1149
1268
|
if (_state === "idle")
|
|
1150
1269
|
await _cmdStart(ctx);
|
|
@@ -1164,23 +1283,16 @@ async function _cmdSetup(ctx) {
|
|
|
1164
1283
|
}
|
|
1165
1284
|
const current = loadLocalConfig(cwd);
|
|
1166
1285
|
const baseDefault = defaultAgentName(cwd);
|
|
1167
|
-
const
|
|
1286
|
+
const newConfig = await runSetupWizard(ui, {
|
|
1168
1287
|
agent_name: current.agent_name ?? baseDefault,
|
|
1169
1288
|
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,
|
|
1174
1289
|
});
|
|
1175
|
-
if (!
|
|
1290
|
+
if (!newConfig) {
|
|
1176
1291
|
ctx.ui.notify("[remote-pi] Setup cancelled.", "info");
|
|
1177
1292
|
return;
|
|
1178
1293
|
}
|
|
1179
|
-
const { enable_daemon, ...newConfig } = wizardResult;
|
|
1180
1294
|
saveLocalConfig(cwd, newConfig);
|
|
1181
1295
|
ctx.ui.notify("[remote-pi] Config updated. Run /remote-pi to apply now.", "info");
|
|
1182
|
-
if (enable_daemon)
|
|
1183
|
-
_cmdInstall(ctx, { linkCli: true });
|
|
1184
1296
|
}
|
|
1185
1297
|
async function _cmdStart(ctx) {
|
|
1186
1298
|
if (_state !== "idle") {
|
|
@@ -1198,17 +1310,55 @@ async function _cmdStart(ctx) {
|
|
|
1198
1310
|
// Same name we send in pair_ok — keeps room_meta.name and the per-pair
|
|
1199
1311
|
// session_name aligned so the app shows consistent labels.
|
|
1200
1312
|
const sessionName = _displayName(cwd);
|
|
1201
|
-
//
|
|
1202
|
-
//
|
|
1203
|
-
//
|
|
1204
|
-
//
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1313
|
+
// Seed the current model from the SDK's resolved selection so room_meta
|
|
1314
|
+
// carries it on connect. `model_select` only fires on an explicit set/cycle
|
|
1315
|
+
// (NOT on settings load), so a headless daemon that just runs its default
|
|
1316
|
+
// model never emits it — without this its room_meta would omit the model and
|
|
1317
|
+
// the app shows "unknown". `getModel()` returns the session's resolved model
|
|
1318
|
+
// in every mode (interactive + RPC daemon); turn_start hydrates it later if
|
|
1319
|
+
// the SDK resolves the model lazily.
|
|
1320
|
+
if (!_currentModel) {
|
|
1321
|
+
try {
|
|
1322
|
+
const c = ctx;
|
|
1323
|
+
// Prefer the live getModel() / ctx.model — populated for an interactive
|
|
1324
|
+
// Pi. For a HEADLESS DAEMON both are undefined at connect: the SDK only
|
|
1325
|
+
// resolves `this.model` lazily at the first turn, and `model_select`
|
|
1326
|
+
// never fires for a default-model session. So fall back to the CONFIGURED
|
|
1327
|
+
// default (defaultProvider/defaultModel in <cwd>/.pi/settings.json) — the
|
|
1328
|
+
// model the daemon will actually use. Without this an idle daemon (never
|
|
1329
|
+
// prompted → no turn) would never report its model and the app shows
|
|
1330
|
+
// "unknown". turn_start still hydrates a later override.
|
|
1331
|
+
const live = c.getModel?.() ?? c.model;
|
|
1332
|
+
if (live) {
|
|
1333
|
+
_currentModel = live.name ?? live.id ?? undefined;
|
|
1334
|
+
}
|
|
1335
|
+
else {
|
|
1336
|
+
const sm = SettingsManager.create(cwd);
|
|
1337
|
+
const provider = sm.getDefaultProvider();
|
|
1338
|
+
const modelId = sm.getDefaultModel();
|
|
1339
|
+
if (modelId) {
|
|
1340
|
+
const found = provider ? ensureModelRegistry().find(provider, modelId) : undefined;
|
|
1341
|
+
_currentModel = found?.name ?? modelId;
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
catch { /* defensive — never block start on a model lookup */ }
|
|
1346
|
+
}
|
|
1347
|
+
// Plan/28 Wave D.1: seed thinking from the SDK's current level so the
|
|
1348
|
+
// first room_meta hello already carries it. `pi.getThinkingLevel()` is
|
|
1349
|
+
// safe at this point — extension factory has been bound by the SDK
|
|
1350
|
+
// before any command handler fires. Future toggles go through the
|
|
1351
|
+
// `thinking_level_select` event handler above.
|
|
1352
|
+
try {
|
|
1353
|
+
_currentThinking = _pi?.getThinkingLevel();
|
|
1354
|
+
}
|
|
1355
|
+
catch { /* defensive — never block /remote-pi start on this */ }
|
|
1208
1356
|
const roomMeta = { name: sessionName, cwd };
|
|
1209
1357
|
const modelName = _currentModelName();
|
|
1210
1358
|
if (modelName)
|
|
1211
1359
|
roomMeta.model = modelName;
|
|
1360
|
+
if (_currentThinking)
|
|
1361
|
+
roomMeta.thinking = _currentThinking;
|
|
1212
1362
|
// Persist so _attemptReconnect can replay the same hello payload — without
|
|
1213
1363
|
// this, reconnect issues a bare hello and the relay creates a "default room"
|
|
1214
1364
|
// entry that surfaces in the app as a phantom legacy session.
|
|
@@ -1281,13 +1431,13 @@ async function _cmdStart(ctx) {
|
|
|
1281
1431
|
display: true,
|
|
1282
1432
|
});
|
|
1283
1433
|
},
|
|
1284
|
-
// Plan/25 Wave D: keep
|
|
1434
|
+
// Plan/25 Wave D: keep the cross-PC sibling list in sync with
|
|
1285
1435
|
// mesh_versions. The poller fires this whenever the union of Pi
|
|
1286
|
-
// members across owners changes — adding/removing a sibling, or
|
|
1287
|
-
//
|
|
1288
|
-
//
|
|
1436
|
+
// members across owners changes — adding/removing a sibling, or an
|
|
1437
|
+
// owner relabeling a nickname. MeshNode.setSiblings is a no-op until
|
|
1438
|
+
// the bridge is up (follower / relay down), so this is always safe.
|
|
1289
1439
|
onMembersChanged: (siblings) => {
|
|
1290
|
-
|
|
1440
|
+
_meshNode?.setSiblings(siblings);
|
|
1291
1441
|
},
|
|
1292
1442
|
// Silent log: routine self-revoke audit and per-Owner fetch
|
|
1293
1443
|
// failures don't belong in the TUI chat panel. User-facing
|
|
@@ -1297,9 +1447,9 @@ async function _cmdStart(ctx) {
|
|
|
1297
1447
|
_selfRevoke.start();
|
|
1298
1448
|
}
|
|
1299
1449
|
// Plan/25 Wave B/C: bring up cross-PC routing if local broker is ready.
|
|
1300
|
-
// No-op when we're a follower (
|
|
1301
|
-
//
|
|
1302
|
-
|
|
1450
|
+
// No-op when we're a follower (the bridge needs the local Broker instance
|
|
1451
|
+
// the leader hosts). Best-effort; failures don't surface.
|
|
1452
|
+
_attachBridgeIfReady();
|
|
1303
1453
|
ctx.ui.notify(`[remote-pi] state: started (peer=${myShort}) — Connected to relay ${relayUrl}`, "info");
|
|
1304
1454
|
}
|
|
1305
1455
|
/**
|
|
@@ -1328,7 +1478,7 @@ async function _cmdPair(ctx) {
|
|
|
1328
1478
|
return;
|
|
1329
1479
|
}
|
|
1330
1480
|
ctx.ui.notify("[remote-pi] Starting mesh + relay before pairing…", "info");
|
|
1331
|
-
if (!
|
|
1481
|
+
if (!_meshNode)
|
|
1332
1482
|
await _cmdJoin(ctx);
|
|
1333
1483
|
if (_state === "idle")
|
|
1334
1484
|
await _cmdStart(ctx);
|
|
@@ -1375,7 +1525,7 @@ async function _cmdPair(ctx) {
|
|
|
1375
1525
|
* `/remote-pi` again.
|
|
1376
1526
|
*/
|
|
1377
1527
|
async function _cmdStop(ctx) {
|
|
1378
|
-
const meshUp =
|
|
1528
|
+
const meshUp = _meshNode !== null;
|
|
1379
1529
|
const relayUp = _state !== "idle";
|
|
1380
1530
|
if (!meshUp && !relayUp) {
|
|
1381
1531
|
ctx.ui.notify("[remote-pi] Already stopped — nothing to do.", "info");
|
|
@@ -1383,10 +1533,10 @@ async function _cmdStop(ctx) {
|
|
|
1383
1533
|
}
|
|
1384
1534
|
if (meshUp) {
|
|
1385
1535
|
try {
|
|
1386
|
-
await
|
|
1536
|
+
await _meshNode.close();
|
|
1387
1537
|
}
|
|
1388
1538
|
catch { /* best-effort */ }
|
|
1389
|
-
|
|
1539
|
+
_meshNode = null;
|
|
1390
1540
|
_sessionName = null;
|
|
1391
1541
|
_sessionPeerCount = 0;
|
|
1392
1542
|
}
|
|
@@ -1487,7 +1637,7 @@ function _cmdSetRelay(arg, ctx) {
|
|
|
1487
1637
|
* on an existing daemon is idempotent at this layer; the registry
|
|
1488
1638
|
* itself rejects duplicate cwds.
|
|
1489
1639
|
*/
|
|
1490
|
-
function _cmdCreate(arg, ctx) {
|
|
1640
|
+
async function _cmdCreate(arg, ctx) {
|
|
1491
1641
|
// Parse `[cwd] [--name "value with spaces" | --name word]` in any order.
|
|
1492
1642
|
// The first non-flag token is the cwd; the rest of the line after
|
|
1493
1643
|
// `--name` (quoted or unquoted) is the display name.
|
|
@@ -1519,6 +1669,23 @@ function _cmdCreate(arg, ctx) {
|
|
|
1519
1669
|
}
|
|
1520
1670
|
const finalName = loadLocalConfig(result.cwd).agent_name ?? defaultAgentName(result.cwd);
|
|
1521
1671
|
ctx.ui.notify(`[remote-pi] Daemon registered: id=${result.id} name="${finalName}" cwd=${result.cwd}`, "info");
|
|
1672
|
+
// Auto-start: register alone used to leave the daemon idle until the next
|
|
1673
|
+
// supervisor restart (the reported bug — `create` didn't run anything). Ask
|
|
1674
|
+
// the supervisor to spawn THIS daemon now. Config was just written above, so
|
|
1675
|
+
// the child boots with it. When the supervisor is offline we keep the
|
|
1676
|
+
// registration and tell the user it'll boot on the next supervisor start.
|
|
1677
|
+
try {
|
|
1678
|
+
await callSupervisor({ op: "start", id: result.id });
|
|
1679
|
+
ctx.ui.notify(`[remote-pi] Daemon started: id=${result.id}`, "info");
|
|
1680
|
+
}
|
|
1681
|
+
catch (err) {
|
|
1682
|
+
if (err instanceof SupervisorOfflineError) {
|
|
1683
|
+
ctx.ui.notify(`[remote-pi] Registered, but the supervisor is offline — not running yet. ` +
|
|
1684
|
+
`Run \`remote-pi install\` (or start \`pi-supervisord\`); it auto-starts on the next supervisor boot.`, "warning");
|
|
1685
|
+
return;
|
|
1686
|
+
}
|
|
1687
|
+
ctx.ui.notify(`[remote-pi] Registered, but auto-start failed: ${String(err)}`, "error");
|
|
1688
|
+
}
|
|
1522
1689
|
}
|
|
1523
1690
|
/**
|
|
1524
1691
|
* `/remote-pi remove <id>`
|
|
@@ -1528,12 +1695,35 @@ function _cmdCreate(arg, ctx) {
|
|
|
1528
1695
|
* stays on disk — re-creating later with the same cwd is a no-op
|
|
1529
1696
|
* because the existing config wins.
|
|
1530
1697
|
*/
|
|
1531
|
-
function _cmdRemove(arg, ctx) {
|
|
1698
|
+
async function _cmdRemove(arg, ctx) {
|
|
1532
1699
|
const id = arg.trim();
|
|
1533
1700
|
if (!id) {
|
|
1534
1701
|
ctx.ui.notify("[remote-pi] Usage: /remote-pi remove <id>. Run /remote-pi daemons to see ids.", "warning");
|
|
1535
1702
|
return;
|
|
1536
1703
|
}
|
|
1704
|
+
// Prefer the supervisor's `unregister`: it STOPS the running child (SIGTERM →
|
|
1705
|
+
// SIGKILL) BEFORE deleting the registry entry. Removing only the registry
|
|
1706
|
+
// (the old behaviour) left an orphaned `pi --mode rpc` process running with
|
|
1707
|
+
// nothing left to manage it — the reported bug. Fall back to a registry-only
|
|
1708
|
+
// removal when the supervisor is offline (no managed process to stop anyway).
|
|
1709
|
+
try {
|
|
1710
|
+
const data = await callSupervisor({ op: "unregister", id });
|
|
1711
|
+
if (!data.removed) {
|
|
1712
|
+
const known = listDaemons().map((d) => d.id).join(", ") || "(none)";
|
|
1713
|
+
ctx.ui.notify(`[remote-pi] No daemon with id "${id}". Known ids: ${known}`, "warning");
|
|
1714
|
+
return;
|
|
1715
|
+
}
|
|
1716
|
+
ctx.ui.notify(`[remote-pi] Daemon removed + process stopped: id=${id} cwd=${data.cwd}. ` +
|
|
1717
|
+
`Local config at ${data.cwd}/.pi/remote-pi/config.json was kept.`, "info");
|
|
1718
|
+
return;
|
|
1719
|
+
}
|
|
1720
|
+
catch (err) {
|
|
1721
|
+
if (!(err instanceof SupervisorOfflineError)) {
|
|
1722
|
+
ctx.ui.notify(`[remote-pi] remove failed: ${String(err)}`, "error");
|
|
1723
|
+
return;
|
|
1724
|
+
}
|
|
1725
|
+
// Supervisor offline — fall through to registry-only removal below.
|
|
1726
|
+
}
|
|
1537
1727
|
let result;
|
|
1538
1728
|
try {
|
|
1539
1729
|
result = removeDaemon(id);
|
|
@@ -1543,13 +1733,12 @@ function _cmdRemove(arg, ctx) {
|
|
|
1543
1733
|
return;
|
|
1544
1734
|
}
|
|
1545
1735
|
if (!result.removed) {
|
|
1546
|
-
// Surface the registered ids for a quick visual diff.
|
|
1547
1736
|
const known = listDaemons().map((d) => d.id).join(", ") || "(none)";
|
|
1548
1737
|
ctx.ui.notify(`[remote-pi] No daemon with id "${id}". Known ids: ${known}`, "warning");
|
|
1549
1738
|
return;
|
|
1550
1739
|
}
|
|
1551
|
-
ctx.ui.notify(`[remote-pi] Daemon removed: id=${id} cwd=${result.cwd}. ` +
|
|
1552
|
-
`
|
|
1740
|
+
ctx.ui.notify(`[remote-pi] Daemon removed from registry: id=${id} cwd=${result.cwd}. ` +
|
|
1741
|
+
`Supervisor was offline, so any running process was NOT stopped. Local config kept.`, "warning");
|
|
1553
1742
|
}
|
|
1554
1743
|
// ── Fleet-ops commands (plan/26 W2) — talk to the supervisor over UDS ─────────
|
|
1555
1744
|
//
|
|
@@ -1803,6 +1992,84 @@ function _deployAgentNetworkSkill() {
|
|
|
1803
1992
|
}
|
|
1804
1993
|
catch { /* best-effort */ }
|
|
1805
1994
|
}
|
|
1995
|
+
/**
|
|
1996
|
+
* Inject text into the agent as a user message, waking a turn. The Pi SDK's
|
|
1997
|
+
* `ExtensionAPI.sendUserMessage` is fire-and-forget (returns `void`) and
|
|
1998
|
+
* "always triggers a turn" — the SDK runtime owns any *async* turn failure
|
|
1999
|
+
* (no model/API key, expired auth, provider error), which surfaces in the
|
|
2000
|
+
* agent's own output, not back to us. Two gaps this helper closes, both of
|
|
2001
|
+
* which previously failed silently:
|
|
2002
|
+
*
|
|
2003
|
+
* 1. `_pi` not bound yet (activation race / mesh joined before the session
|
|
2004
|
+
* attached): the old code did `if (!_pi) return`, dropping the message
|
|
2005
|
+
* with no trace. We log it (the daemon forwards child stderr to its log
|
|
2006
|
+
* with a cwd prefix, so it's visible in `journalctl`).
|
|
2007
|
+
* 2. A *synchronous* throw from `sendUserMessage` (e.g. malformed content):
|
|
2008
|
+
* the old fire-and-forget call let it propagate out of the `onMessage`
|
|
2009
|
+
* callback, which could wedge the read loop and blackout every later
|
|
2010
|
+
* message. We catch + surface it instead.
|
|
2011
|
+
*
|
|
2012
|
+
* NOTE: this does NOT make a wake that fails *inside* the SDK observable —
|
|
2013
|
+
* that requires a fix in the Pi runtime (no extension-level error event
|
|
2014
|
+
* exists for it). See `.orchestration/results/mesh-liveness-stale-peer.md`.
|
|
2015
|
+
*/
|
|
2016
|
+
function _wakeAgent(content, label) {
|
|
2017
|
+
if (!_pi) {
|
|
2018
|
+
console.error(`[remote-pi] ${label}: agent session not bound yet — message dropped`);
|
|
2019
|
+
return;
|
|
2020
|
+
}
|
|
2021
|
+
try {
|
|
2022
|
+
_pi.sendUserMessage(content);
|
|
2023
|
+
}
|
|
2024
|
+
catch (err) {
|
|
2025
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
2026
|
+
console.error(`[remote-pi] ${label}: agent rejected incoming message: ${detail}`);
|
|
2027
|
+
_lastCtx?.ui.notify(`[remote-pi] failed to process incoming message: ${detail}`, "error");
|
|
2028
|
+
}
|
|
2029
|
+
}
|
|
2030
|
+
/**
|
|
2031
|
+
* Deliver an inbound agent-network (mesh) message to the agent + the app.
|
|
2032
|
+
*
|
|
2033
|
+
* Display: the app renders it in the TOOL timeline (a matched
|
|
2034
|
+
* tool_request/tool_result "agent-network" pair) — NOT as the user's own
|
|
2035
|
+
* message, which is what `sendUserMessage` used to produce (the reported bug).
|
|
2036
|
+
*
|
|
2037
|
+
* Wake: we inject a CUSTOM message (role:"custom"), not a user message. The
|
|
2038
|
+
* SDK's `convertToLlm` maps custom → a user-role LLM message, so the agent
|
|
2039
|
+
* still sees + replies to it, but `message_end` does NOT buffer role:"custom",
|
|
2040
|
+
* so it never replays as `user_input` on session_sync. `triggerTurn` runs the
|
|
2041
|
+
* turn; `id` lets the LLM echo it via `agent_send(..., re=<id>)`.
|
|
2042
|
+
*/
|
|
2043
|
+
function _deliverMeshMessageToAgent(env) {
|
|
2044
|
+
const bodyText = typeof env.body === "string" ? env.body : JSON.stringify(env.body);
|
|
2045
|
+
const toolCallId = `mesh_${env.id}`;
|
|
2046
|
+
_broadcastToActive({
|
|
2047
|
+
type: "tool_request",
|
|
2048
|
+
tool_call_id: toolCallId,
|
|
2049
|
+
tool: "agent-network",
|
|
2050
|
+
args: env.re
|
|
2051
|
+
? { from: env.from, re: env.re, message: bodyText }
|
|
2052
|
+
: { from: env.from, message: bodyText },
|
|
2053
|
+
});
|
|
2054
|
+
_broadcastToActive({ type: "tool_result", tool_call_id: toolCallId, result: { from: env.from, message: bodyText } });
|
|
2055
|
+
const label = `agent-network message from "${env.from}"`;
|
|
2056
|
+
if (!_pi) {
|
|
2057
|
+
console.error(`[remote-pi] ${label}: agent session not bound yet — message dropped`);
|
|
2058
|
+
return;
|
|
2059
|
+
}
|
|
2060
|
+
const header = `[agent-network] message from "${env.from}" (id=${env.id}${env.re ? `, re=${env.re}` : ""}):`;
|
|
2061
|
+
const footer = env.re
|
|
2062
|
+
? "(This is a reply to a previous message of yours.)"
|
|
2063
|
+
: `(If a reply is expected, call agent_send with to="${env.from}" and re="${env.id}".)`;
|
|
2064
|
+
try {
|
|
2065
|
+
_pi.sendMessage({ customType: "remote-pi:mesh-message", content: `${header}\n${bodyText}\n\n${footer}`, display: true }, { triggerTurn: true });
|
|
2066
|
+
}
|
|
2067
|
+
catch (err) {
|
|
2068
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
2069
|
+
console.error(`[remote-pi] ${label}: agent rejected incoming message: ${detail}`);
|
|
2070
|
+
_lastCtx?.ui.notify(`[remote-pi] failed to process incoming message: ${detail}`, "error");
|
|
2071
|
+
}
|
|
2072
|
+
}
|
|
1806
2073
|
/**
|
|
1807
2074
|
* Joins the fixed local UDS mesh ("local" session — see LOCAL_SESSION_NAME).
|
|
1808
2075
|
* Called by `_cmdRoot` on first run and on subsequent runs when the relay
|
|
@@ -1815,7 +2082,7 @@ async function _cmdJoin(ctx) {
|
|
|
1815
2082
|
const local = loadLocalConfig(cwd);
|
|
1816
2083
|
const sessionName = LOCAL_SESSION_NAME;
|
|
1817
2084
|
const agentName = local.agent_name || defaultAgentName(cwd);
|
|
1818
|
-
if (
|
|
2085
|
+
if (_meshNode) {
|
|
1819
2086
|
ctx.ui.notify("[remote-pi] Already on the local mesh.", "warning");
|
|
1820
2087
|
return;
|
|
1821
2088
|
}
|
|
@@ -1823,7 +2090,7 @@ async function _cmdJoin(ctx) {
|
|
|
1823
2090
|
mkdirSync(join(skillsDir(), "..", "sessions", sessionName), { recursive: true });
|
|
1824
2091
|
const sock = sessionSockPath(sessionName);
|
|
1825
2092
|
const audit = sessionAuditPath(sessionName);
|
|
1826
|
-
const peer = new
|
|
2093
|
+
const peer = new MeshNode({ sockPath: sock, name: agentName, auditPath: audit });
|
|
1827
2094
|
peer.onMessage((env) => {
|
|
1828
2095
|
const body = env.body;
|
|
1829
2096
|
// Broker system events: re-query broker for authoritative count.
|
|
@@ -1836,11 +2103,12 @@ async function _cmdJoin(ctx) {
|
|
|
1836
2103
|
void peer.request("broker", { type: "list_peers" }, 2000)
|
|
1837
2104
|
.then((reply) => {
|
|
1838
2105
|
const peers = reply.body?.peers;
|
|
1839
|
-
if (Array.isArray(peers)
|
|
2106
|
+
if (Array.isArray(peers)) {
|
|
1840
2107
|
// Strip remote-prefixed entries — onLocalPeersChanged wants
|
|
1841
|
-
// local-only names (`list_peers` returns aggregated).
|
|
2108
|
+
// local-only names (`list_peers` returns aggregated). No-op when
|
|
2109
|
+
// the bridge isn't up (follower / relay down).
|
|
1842
2110
|
const local = peers.filter((p) => !p.includes(":"));
|
|
1843
|
-
|
|
2111
|
+
peer.onLocalPeersChanged(local);
|
|
1844
2112
|
}
|
|
1845
2113
|
})
|
|
1846
2114
|
.catch(() => { });
|
|
@@ -1848,45 +2116,24 @@ async function _cmdJoin(ctx) {
|
|
|
1848
2116
|
}
|
|
1849
2117
|
if (env.from === "broker")
|
|
1850
2118
|
return; // other broker control messages — ignore
|
|
1851
|
-
//
|
|
1852
|
-
//
|
|
1853
|
-
//
|
|
1854
|
-
|
|
1855
|
-
// the `id` so the LLM can echo it via `agent_send(..., re=<id>)` when
|
|
1856
|
-
// replying (otherwise the sender's agent_request times out).
|
|
1857
|
-
if (!_pi)
|
|
1858
|
-
return;
|
|
1859
|
-
const bodyText = typeof env.body === "string" ? env.body : JSON.stringify(env.body);
|
|
1860
|
-
const header = `[agent-network] message from "${env.from}" (id=${env.id}${env.re ? `, re=${env.re}` : ""}):`;
|
|
1861
|
-
const footer = env.re
|
|
1862
|
-
? "(This is a reply to a previous message of yours.)"
|
|
1863
|
-
: `(If a reply is expected, call agent_send with to="${env.from}" and re="${env.id}".)`;
|
|
1864
|
-
_pi.sendUserMessage(`${header}\n${bodyText}\n\n${footer}`);
|
|
2119
|
+
// Real agent-to-agent message (SessionPeer already correlated replies via
|
|
2120
|
+
// env.re before this point). Show it in the app's TOOL timeline and wake
|
|
2121
|
+
// the agent as a CUSTOM message — never as the user's own message.
|
|
2122
|
+
_deliverMeshMessageToAgent(env);
|
|
1865
2123
|
});
|
|
1866
2124
|
// After failover (leader died, we re-elected): the new broker's peers map
|
|
1867
2125
|
// starts fresh, but our cached `_sessionPeerCount` is stale. Re-seed it so
|
|
1868
2126
|
// surviving peers don't carry the pre-failover count forever.
|
|
1869
2127
|
//
|
|
1870
|
-
//
|
|
1871
|
-
//
|
|
1872
|
-
//
|
|
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.
|
|
2128
|
+
// The cross-PC bridge re-attach on failover (drop the stale broker ref,
|
|
2129
|
+
// re-wire against the fresh `localBroker()` if we were promoted to leader)
|
|
2130
|
+
// is handled INSIDE MeshNode — no manual teardown/ensure needed here.
|
|
1876
2131
|
peer.onReconnect(() => {
|
|
1877
2132
|
_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
|
-
}
|
|
1886
2133
|
});
|
|
1887
2134
|
try {
|
|
1888
|
-
const assigned = await peer.
|
|
1889
|
-
|
|
2135
|
+
const assigned = await peer.connect();
|
|
2136
|
+
_meshNode = peer;
|
|
1890
2137
|
_sessionName = sessionName;
|
|
1891
2138
|
_sessionPeerCount = 1; // optimistic — overwritten by list_peers below
|
|
1892
2139
|
// Broker broadcasts `peer_joined` only to existing peers when a new one
|
|
@@ -1899,7 +2146,7 @@ async function _cmdJoin(ctx) {
|
|
|
1899
2146
|
// Plan/25 Wave B/C: try to bring up cross-PC routing now that the
|
|
1900
2147
|
// local broker exists. No-op if the relay isn't up yet (will fire
|
|
1901
2148
|
// again from `_cmdStart`).
|
|
1902
|
-
|
|
2149
|
+
_attachBridgeIfReady();
|
|
1903
2150
|
}
|
|
1904
2151
|
catch (err) {
|
|
1905
2152
|
ctx.ui.notify(`[remote-pi] join failed: ${String(err)}`, "error");
|
|
@@ -1939,9 +2186,26 @@ export function _routeClientMessageFrom(sender, msg, ctx) {
|
|
|
1939
2186
|
// The user_message is also recorded in _messageBuffer indirectly
|
|
1940
2187
|
// via `pi.on("message_end")` after the SDK persists the turn — so
|
|
1941
2188
|
// a later `session_sync` returns it in the history events.
|
|
1942
|
-
|
|
2189
|
+
// Plan/30: echo any inline images too so every owner renders the same
|
|
2190
|
+
// image bubble. No-image path is byte-identical to before (no `images`
|
|
2191
|
+
// key on the wire).
|
|
2192
|
+
const echo = msg.images && msg.images.length > 0
|
|
2193
|
+
? { type: "user_message", id: msg.id, text: msg.text, images: msg.images }
|
|
2194
|
+
: { type: "user_message", id: msg.id, text: msg.text };
|
|
2195
|
+
_broadcastToActive(echo);
|
|
1943
2196
|
_currentTurnId = msg.id;
|
|
1944
|
-
|
|
2197
|
+
if (msg.images && msg.images.length > 0) {
|
|
2198
|
+
// Plan/30 multimodal: image(s) first, then the caption text — the shape
|
|
2199
|
+
// the SDK's sendUserMessage(content[]) hands straight to the model.
|
|
2200
|
+
const content = [
|
|
2201
|
+
...msg.images.map((img) => ({ type: "image", data: img.data, mimeType: img.mime })),
|
|
2202
|
+
{ type: "text", text: msg.text },
|
|
2203
|
+
];
|
|
2204
|
+
_wakeAgent(content, `app user_message id=${msg.id} (+${msg.images.length} image)`);
|
|
2205
|
+
}
|
|
2206
|
+
else {
|
|
2207
|
+
_wakeAgent(msg.text, `app user_message id=${msg.id}`);
|
|
2208
|
+
}
|
|
1945
2209
|
break;
|
|
1946
2210
|
}
|
|
1947
2211
|
case "approve_tool":
|
|
@@ -1962,6 +2226,48 @@ export function _routeClientMessageFrom(sender, msg, ctx) {
|
|
|
1962
2226
|
// Already paired — ignore subsequent pair_request to maintain idempotency.
|
|
1963
2227
|
// (Token is already consumed and peer is in peers.json.)
|
|
1964
2228
|
break;
|
|
2229
|
+
// Plan/28 — Typed app actions. Each delegates to the pure handler in
|
|
2230
|
+
// `actions/handlers.ts`; the only thing this layer does is unify the
|
|
2231
|
+
// dep injection (sender, _pi, _lastCtx, registry). `_lastCtx` may be
|
|
2232
|
+
// null or a narrower Pick than the handlers want, so we cast to
|
|
2233
|
+
// `ActionCtx` — fields that aren't present at runtime are surfaced
|
|
2234
|
+
// as `action_error` by the handlers, not as a TypeError.
|
|
2235
|
+
case "session_compact":
|
|
2236
|
+
// Route through _lastEventCtx (refreshed on every session_start), NOT the
|
|
2237
|
+
// capturable-stale _lastCtx — compact must never hit a ctx left stale by
|
|
2238
|
+
// a prior New session. compact() is a base-ctx method, so the
|
|
2239
|
+
// session_start ctx suffices. Fall back to _lastCtx defensively if no
|
|
2240
|
+
// session_start has landed yet (keeps the pre-replacement happy path).
|
|
2241
|
+
handleSessionCompact((_lastEventCtx ?? _lastCtx), sender, msg);
|
|
2242
|
+
break;
|
|
2243
|
+
case "session_new":
|
|
2244
|
+
void handleSessionNew(_lastCtx, sender, msg, (freshCtx) => {
|
|
2245
|
+
// newSession just made the captured _lastCtx STALE (the SDK throws
|
|
2246
|
+
// if it's reused). Re-capture the fresh command-capable ctx the SDK
|
|
2247
|
+
// passes to withSession so later command ops (another New session,
|
|
2248
|
+
// list_models) run on the current session, not the stale one. The
|
|
2249
|
+
// runtime object also carries ui/abort/cwd, so storing it in the
|
|
2250
|
+
// narrowly-typed _lastCtx slot is sound (mirrors the read-site casts).
|
|
2251
|
+
_lastCtx = freshCtx;
|
|
2252
|
+
}).then((created) => {
|
|
2253
|
+
// Pi-side reset is durable only here: handleSessionNew swaps the SDK
|
|
2254
|
+
// session, but the app's session_sync log (_messageBuffer) and the
|
|
2255
|
+
// session clock (_sessionStartedAt) live in this module. Reset them +
|
|
2256
|
+
// fan out an empty history so every owner drops the stale conversation
|
|
2257
|
+
// — not just the sender, who also clears locally on action_ok.
|
|
2258
|
+
if (created)
|
|
2259
|
+
_resetSessionForNew(msg.id);
|
|
2260
|
+
});
|
|
2261
|
+
break;
|
|
2262
|
+
case "model_set":
|
|
2263
|
+
void handleModelSet(_pi, ensureModelRegistry(), sender, msg, _persistModelDefault);
|
|
2264
|
+
break;
|
|
2265
|
+
case "thinking_set":
|
|
2266
|
+
handleThinkingSet(_pi, sender, msg);
|
|
2267
|
+
break;
|
|
2268
|
+
case "list_models":
|
|
2269
|
+
handleListModels(_lastCtx, ensureModelRegistry(), sender, msg);
|
|
2270
|
+
break;
|
|
1965
2271
|
}
|
|
1966
2272
|
}
|
|
1967
2273
|
/**
|
|
@@ -2011,6 +2317,33 @@ function _handleSessionSync(sender, msg) {
|
|
|
2011
2317
|
truncated,
|
|
2012
2318
|
});
|
|
2013
2319
|
}
|
|
2320
|
+
/**
|
|
2321
|
+
* Resets the Pi-side session view after a SUCCESSFUL `session_new`. The app's
|
|
2322
|
+
* New Session clears its local store on `action_ok`, but that alone isn't
|
|
2323
|
+
* durable: `_messageBuffer` (which answers `session_sync`) is append-only and
|
|
2324
|
+
* `_sessionStartedAt` is stamped once, so a later reconnect/restart would
|
|
2325
|
+
* replay the OLD history. We clear the buffer, restamp the clock, and
|
|
2326
|
+
* broadcast an EMPTY `session_history` — the exact shape `_handleSessionSync`
|
|
2327
|
+
* sends, just with `events: []` — so every attached owner drops the stale
|
|
2328
|
+
* conversation. The app's `_applyHistory` substitutes its cache wholesale, so
|
|
2329
|
+
* no new app-side code is needed.
|
|
2330
|
+
*
|
|
2331
|
+
* Unlike a per-request session_history reply (which must go to the sender
|
|
2332
|
+
* channel only — see `_broadcastToActive`), this is an intentional fan-out:
|
|
2333
|
+
* a new session is global state, so every owner must see the reset.
|
|
2334
|
+
*/
|
|
2335
|
+
function _resetSessionForNew(inReplyTo) {
|
|
2336
|
+
_messageBuffer = [];
|
|
2337
|
+
_sessionStartedAt = Date.now();
|
|
2338
|
+
_broadcastToActive({
|
|
2339
|
+
type: "session_history",
|
|
2340
|
+
in_reply_to: inReplyTo,
|
|
2341
|
+
session_started_at: _sessionStartedAt,
|
|
2342
|
+
events: [],
|
|
2343
|
+
eos: true,
|
|
2344
|
+
truncated: false,
|
|
2345
|
+
});
|
|
2346
|
+
}
|
|
2014
2347
|
function _stringifyContent(content) {
|
|
2015
2348
|
if (typeof content === "string")
|
|
2016
2349
|
return content;
|
|
@@ -2025,6 +2358,59 @@ function _stringifyContent(content) {
|
|
|
2025
2358
|
})
|
|
2026
2359
|
.join("");
|
|
2027
2360
|
}
|
|
2361
|
+
/**
|
|
2362
|
+
* Stringify a tool result consistently for BOTH the live `tool_execution_end`
|
|
2363
|
+
* broadcast AND the history mapper, so the app shows the same text live and on
|
|
2364
|
+
* re-sync. The SDK's `ToolExecutionEndEvent.result` is `any` — usually a
|
|
2365
|
+
* content-array of `{type:"text"}` blocks; `String()` on that yields the
|
|
2366
|
+
* "[object Object]" bug. Rules: string → as-is; content-array → join its text
|
|
2367
|
+
* (same as `_stringifyContent`); any other object → readable JSON; other
|
|
2368
|
+
* primitives → `String()`; null/undefined → "". Never "[object Object]".
|
|
2369
|
+
*/
|
|
2370
|
+
function _stringifyToolResult(value) {
|
|
2371
|
+
if (typeof value === "string")
|
|
2372
|
+
return value;
|
|
2373
|
+
if (Array.isArray(value))
|
|
2374
|
+
return _stringifyContent(value);
|
|
2375
|
+
if (value !== null && typeof value === "object") {
|
|
2376
|
+
// The LIVE `tool_execution_end` result is a WRAPPER object
|
|
2377
|
+
// `{ content: [{type:"text",...}], details:{} }` — not the bare
|
|
2378
|
+
// content-array the history path (`m.content`) carries. Unwrap `content`
|
|
2379
|
+
// (or a plain `text`) so live == re-sync; JSON is only the last fallback.
|
|
2380
|
+
const obj = value;
|
|
2381
|
+
if (Array.isArray(obj.content))
|
|
2382
|
+
return _stringifyContent(obj.content);
|
|
2383
|
+
if (typeof obj.text === "string")
|
|
2384
|
+
return obj.text;
|
|
2385
|
+
try {
|
|
2386
|
+
return JSON.stringify(value);
|
|
2387
|
+
}
|
|
2388
|
+
catch {
|
|
2389
|
+
return "";
|
|
2390
|
+
}
|
|
2391
|
+
}
|
|
2392
|
+
return value === null || value === undefined ? "" : String(value);
|
|
2393
|
+
}
|
|
2394
|
+
/**
|
|
2395
|
+
* Plan/30: extract `ImageContent` blocks ({type:"image", data, mimeType}) from
|
|
2396
|
+
* an SDK message's content and map them to the wire shape (`mimeType` → `mime`).
|
|
2397
|
+
* Used by the history mapper so a re-synced image bubble keeps its bytes —
|
|
2398
|
+
* `_stringifyContent` only pulls text and would otherwise drop the image.
|
|
2399
|
+
*/
|
|
2400
|
+
function _imagesFromContent(content) {
|
|
2401
|
+
if (!Array.isArray(content))
|
|
2402
|
+
return [];
|
|
2403
|
+
const out = [];
|
|
2404
|
+
for (const c of content) {
|
|
2405
|
+
if (!c || typeof c !== "object")
|
|
2406
|
+
continue;
|
|
2407
|
+
const block = c;
|
|
2408
|
+
if (block.type === "image" && typeof block.data === "string" && typeof block.mimeType === "string") {
|
|
2409
|
+
out.push({ data: block.data, mime: block.mimeType });
|
|
2410
|
+
}
|
|
2411
|
+
}
|
|
2412
|
+
return out;
|
|
2413
|
+
}
|
|
2028
2414
|
/**
|
|
2029
2415
|
* Maps SDK AgentMessage[] (UserMessage / AssistantMessage / ToolResultMessage)
|
|
2030
2416
|
* into the flat SessionHistoryEvent[] shape consumed by the app.
|
|
@@ -2039,15 +2425,31 @@ export function _mapAgentMessagesToEvents(messages) {
|
|
|
2039
2425
|
let lastUserId = null;
|
|
2040
2426
|
for (const m of messages) {
|
|
2041
2427
|
const ts = typeof m.timestamp === "number" ? m.timestamp : 0;
|
|
2042
|
-
if (m.role === "
|
|
2428
|
+
if (m.role === "compaction") {
|
|
2429
|
+
// Plan/32: re-render the compaction notice on history re-sync.
|
|
2430
|
+
events.push({
|
|
2431
|
+
ts,
|
|
2432
|
+
type: "compaction",
|
|
2433
|
+
summary: typeof m.content === "string" ? m.content : "",
|
|
2434
|
+
tokens_before: typeof m.tokensBefore === "number" ? m.tokensBefore : 0,
|
|
2435
|
+
});
|
|
2436
|
+
}
|
|
2437
|
+
else if (m.role === "user") {
|
|
2043
2438
|
const id = `sync_${ts}`;
|
|
2044
2439
|
lastUserId = id;
|
|
2045
|
-
|
|
2440
|
+
// Plan/30: keep any image blocks so a re-sync rebuilds the bubble. The
|
|
2441
|
+
// bytes are already in _messageBuffer; only attach `images` when present
|
|
2442
|
+
// so the text-only path stays byte-identical (no `images` key).
|
|
2443
|
+
const images = _imagesFromContent(m.content);
|
|
2444
|
+
const ev = {
|
|
2046
2445
|
ts,
|
|
2047
2446
|
type: "user_input",
|
|
2048
2447
|
id,
|
|
2049
2448
|
text: _stringifyContent(m.content),
|
|
2050
|
-
}
|
|
2449
|
+
};
|
|
2450
|
+
if (images.length > 0)
|
|
2451
|
+
ev.images = images;
|
|
2452
|
+
events.push(ev);
|
|
2051
2453
|
}
|
|
2052
2454
|
else if (m.role === "assistant") {
|
|
2053
2455
|
const content = Array.isArray(m.content) ? m.content : [];
|
|
@@ -2083,7 +2485,8 @@ export function _mapAgentMessagesToEvents(messages) {
|
|
|
2083
2485
|
}
|
|
2084
2486
|
}
|
|
2085
2487
|
else if (m.role === "toolResult") {
|
|
2086
|
-
|
|
2488
|
+
// Same helper as the live `tool_execution_end` broadcast → live == re-sync.
|
|
2489
|
+
const text = _stringifyToolResult(m.content);
|
|
2087
2490
|
const tcid = String(m.toolCallId ?? "");
|
|
2088
2491
|
events.push(m.isError
|
|
2089
2492
|
? { ts, type: "tool_result", tool_call_id: tcid, error: text }
|
|
@@ -2093,7 +2496,15 @@ export function _mapAgentMessagesToEvents(messages) {
|
|
|
2093
2496
|
return events;
|
|
2094
2497
|
}
|
|
2095
2498
|
// ── Standalone CLI ────────────────────────────────────────────────────────────
|
|
2096
|
-
|
|
2499
|
+
function _isDirectRun() {
|
|
2500
|
+
try {
|
|
2501
|
+
return fileURLToPath(import.meta.url) === realpathSync(process.argv[1] ?? "");
|
|
2502
|
+
}
|
|
2503
|
+
catch {
|
|
2504
|
+
return false;
|
|
2505
|
+
}
|
|
2506
|
+
}
|
|
2507
|
+
if (_isDirectRun()) {
|
|
2097
2508
|
const [, , subcmd, ...cliArgs] = process.argv;
|
|
2098
2509
|
if (subcmd === "devices" || subcmd === "list") {
|
|
2099
2510
|
const peers = await listPeers();
|
|
@@ -2149,13 +2560,13 @@ if (import.meta.url === `file://${process.argv[1]}`) {
|
|
|
2149
2560
|
// parser (shared with the slash-command path) sees the same shape
|
|
2150
2561
|
// as it would from a Pi interactive prompt.
|
|
2151
2562
|
const joined = cliArgs.map((a) => (/\s/.test(a) ? `"${a}"` : a)).join(" ");
|
|
2152
|
-
_cmdCreate(joined, {
|
|
2563
|
+
await _cmdCreate(joined, {
|
|
2153
2564
|
ui: { notify: (msg) => console.log(msg) },
|
|
2154
2565
|
});
|
|
2155
2566
|
}
|
|
2156
2567
|
else if (subcmd === "remove") {
|
|
2157
2568
|
const id = (cliArgs[0] ?? "").trim();
|
|
2158
|
-
_cmdRemove(id, {
|
|
2569
|
+
await _cmdRemove(id, {
|
|
2159
2570
|
ui: { notify: (msg) => console.log(msg) },
|
|
2160
2571
|
});
|
|
2161
2572
|
}
|
|
@@ -2190,6 +2601,9 @@ if (import.meta.url === `file://${process.argv[1]}`) {
|
|
|
2190
2601
|
console.log("Usage: remote-pi daemon <start|stop|restart|status|send <id> \"<text>\">");
|
|
2191
2602
|
}
|
|
2192
2603
|
}
|
|
2604
|
+
else if (subcmd === "claude") {
|
|
2605
|
+
await _cmdClaudeCli(cliArgs);
|
|
2606
|
+
}
|
|
2193
2607
|
else if (subcmd === "install") {
|
|
2194
2608
|
// CLI mode = user installed via `npm install -g remote-pi`, so the
|
|
2195
2609
|
// `remote-pi` / `pi-supervisord` bins are already on $PATH via npm's
|
|
@@ -2203,14 +2617,141 @@ if (import.meta.url === `file://${process.argv[1]}`) {
|
|
|
2203
2617
|
_cmdUninstall(stubCtx, { linkCli: false });
|
|
2204
2618
|
}
|
|
2205
2619
|
else {
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2620
|
+
console.log([
|
|
2621
|
+
"Usage: remote-pi <command>",
|
|
2622
|
+
"",
|
|
2623
|
+
"Daemon registry:",
|
|
2624
|
+
" create <cwd> [--name \"Name\"] Register a folder as a daemon",
|
|
2625
|
+
" remove <id> Unregister a daemon",
|
|
2626
|
+
" daemons List registered daemons",
|
|
2627
|
+
"",
|
|
2628
|
+
"Fleet control:",
|
|
2629
|
+
" daemon start Start all registered daemons",
|
|
2630
|
+
" daemon stop Stop all running daemons",
|
|
2631
|
+
" daemon restart Restart all daemons",
|
|
2632
|
+
" daemon status Show pid / uptime / restarts",
|
|
2633
|
+
" daemon send <id> \"<text>\" Send a prompt to a daemon",
|
|
2634
|
+
"",
|
|
2635
|
+
"Service:",
|
|
2636
|
+
" install Install pi-supervisord as a system service",
|
|
2637
|
+
" uninstall Remove the system service",
|
|
2638
|
+
"",
|
|
2639
|
+
"Devices:",
|
|
2640
|
+
" devices List paired devices",
|
|
2641
|
+
" revoke <shortid> Revoke a paired device",
|
|
2642
|
+
"",
|
|
2643
|
+
"Config:",
|
|
2644
|
+
" set-relay <url> Set the relay URL (http:// or https://)",
|
|
2645
|
+
"",
|
|
2646
|
+
"Agent mesh:",
|
|
2647
|
+
" claude [cwd] Start Claude Code connected to the agent mesh",
|
|
2648
|
+
].join("\n"));
|
|
2649
|
+
}
|
|
2650
|
+
}
|
|
2651
|
+
// ── `remote-pi claude` — launch Claude Code connected to the mesh ─────────────
|
|
2652
|
+
/**
|
|
2653
|
+
* Deploy the agent-network skill into the user-global Claude skills dir
|
|
2654
|
+
* (`~/.claude/skills/agent-network/SKILL.md`). The template ships in the
|
|
2655
|
+
* package at `skills/claude-agent-network/SKILL.md` (sibling of `dist/`).
|
|
2656
|
+
* Idempotent: overwrites on every launch so skill edits in the package
|
|
2657
|
+
* propagate. Best-effort — a failure here doesn't block the launch (the
|
|
2658
|
+
* MCP `instructions` + tool descriptions still give Claude the basics).
|
|
2659
|
+
*
|
|
2660
|
+
* Mirrors the Pi extension's `_deployAgentNetworkSkill()` (which targets
|
|
2661
|
+
* `~/.pi/remote/skills/`): same concept, each runtime's own skills dir.
|
|
2662
|
+
*/
|
|
2663
|
+
function _deployClaudeMeshSkill() {
|
|
2664
|
+
try {
|
|
2665
|
+
const here = fileURLToPath(import.meta.url); // dist/index.js
|
|
2666
|
+
const pkgRoot = dirname(dirname(here)); // package root (dist → ..)
|
|
2667
|
+
const template = join(pkgRoot, "skills", "claude-agent-network", "SKILL.md");
|
|
2668
|
+
if (!existsSync(template))
|
|
2669
|
+
return;
|
|
2670
|
+
const destDir = join(homedir(), ".claude", "skills", "agent-network");
|
|
2671
|
+
mkdirSync(destDir, { recursive: true });
|
|
2672
|
+
copyFileSync(template, join(destDir, "SKILL.md"));
|
|
2214
2673
|
}
|
|
2674
|
+
catch {
|
|
2675
|
+
/* best-effort — never block the launch on skill deploy */
|
|
2676
|
+
}
|
|
2677
|
+
}
|
|
2678
|
+
async function _cmdClaudeCli(args) {
|
|
2679
|
+
// Determine target cwd — first non-flag arg, or process.cwd()
|
|
2680
|
+
const targetCwd = args.find((a) => !a.startsWith("--")) ?? process.cwd();
|
|
2681
|
+
// Wizard when no local config exists
|
|
2682
|
+
if (!localConfigExists(targetCwd)) {
|
|
2683
|
+
const suggested = defaultAgentName(targetCwd);
|
|
2684
|
+
process.stdout.write(`\n[remote-pi] No config found for ${targetCwd}\n`);
|
|
2685
|
+
process.stdout.write("Let's set up this agent.\n\n");
|
|
2686
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
2687
|
+
const agentName = await new Promise((res) => rl.question(`Agent name [${suggested}]: `, (ans) => { rl.close(); res(ans.trim() || suggested); }));
|
|
2688
|
+
saveLocalConfig(targetCwd, { agent_name: agentName, auto_start_relay: true });
|
|
2689
|
+
process.stdout.write(`[remote-pi] Config saved: agent="${agentName}"\n\n`);
|
|
2690
|
+
}
|
|
2691
|
+
// Resolve mesh server script path (dist/mcp/mesh_server.js)
|
|
2692
|
+
const here = fileURLToPath(import.meta.url);
|
|
2693
|
+
const distRoot = dirname(here);
|
|
2694
|
+
const meshServerPath = resolve(distRoot, "mcp/mesh_server.js");
|
|
2695
|
+
if (!existsSync(meshServerPath)) {
|
|
2696
|
+
console.log(`[remote-pi] mesh server not found at ${meshServerPath}. Run pnpm build first.`);
|
|
2697
|
+
process.exit(1);
|
|
2698
|
+
}
|
|
2699
|
+
const absCwd = resolve(targetCwd);
|
|
2700
|
+
const SERVER_NAME = "remote-pi-mesh";
|
|
2701
|
+
// Deploy the agent-network skill so Claude knows HOW to use the mesh tools
|
|
2702
|
+
// (when to call get_messages, ACK statuses, cross-PC addressing, replies).
|
|
2703
|
+
// Skills load only from disk — an MCP server can't inject one — so we write
|
|
2704
|
+
// it to the user-global Claude skills dir, mirroring how the Pi extension
|
|
2705
|
+
// deploys its own agent-network skill to ~/.pi/remote/skills/. The skill's
|
|
2706
|
+
// `description` self-gates activation to mesh contexts, so a global deploy
|
|
2707
|
+
// doesn't pollute unrelated Claude sessions.
|
|
2708
|
+
_deployClaudeMeshSkill();
|
|
2709
|
+
// The channel feature (claude/channel push) only recognizes MCP servers
|
|
2710
|
+
// registered in one of the persistent scopes Claude Code enumerates:
|
|
2711
|
+
// enterprise / user / project / local. A server passed via `--mcp-config`
|
|
2712
|
+
// on the command line is loaded for tool calls but is NOT in any of those
|
|
2713
|
+
// scopes, so `--dangerously-load-development-channels server:<name>` can't
|
|
2714
|
+
// match it ("no MCP server configured with that name").
|
|
2715
|
+
//
|
|
2716
|
+
// We therefore register in the **local** scope: per-project (only active in
|
|
2717
|
+
// this cwd) and stored in `~/.claude.json` — NOT written into the project
|
|
2718
|
+
// dir, NOT committed to VCS, NOT global. Best of both: no file pollution
|
|
2719
|
+
// AND channels work.
|
|
2720
|
+
//
|
|
2721
|
+
// remove-then-add makes it idempotent and refreshes the path if the
|
|
2722
|
+
// extension moved (Pi can reinstall to a new hash dir on upgrade).
|
|
2723
|
+
spawnSync("claude", ["mcp", "remove", SERVER_NAME, "-s", "local"], {
|
|
2724
|
+
cwd: absCwd, stdio: "ignore", shell: false,
|
|
2725
|
+
});
|
|
2726
|
+
const add = spawnSync("claude", [
|
|
2727
|
+
"mcp", "add", SERVER_NAME, "-s", "local", "--",
|
|
2728
|
+
process.execPath, meshServerPath, "--cwd", absCwd,
|
|
2729
|
+
], { cwd: absCwd, stdio: ["ignore", "pipe", "pipe"], shell: false, encoding: "utf8" });
|
|
2730
|
+
if (add.status !== 0) {
|
|
2731
|
+
console.log(`[remote-pi] failed to register MCP server: ${add.stderr || add.stdout}`);
|
|
2732
|
+
process.exit(1);
|
|
2733
|
+
}
|
|
2734
|
+
const agentName = loadLocalConfig(targetCwd).agent_name ?? defaultAgentName(targetCwd);
|
|
2735
|
+
process.stdout.write(`[remote-pi] Launching Claude as "${agentName}" in ${absCwd}\n`);
|
|
2736
|
+
process.stdout.write(`[remote-pi] MCP server: ${meshServerPath} (local scope)\n`);
|
|
2737
|
+
process.stdout.write(`[remote-pi] Tools: list_peers, agent_send, get_messages\n`);
|
|
2738
|
+
process.stdout.write(`[remote-pi] Skill: agent-network (~/.claude/skills/)\n`);
|
|
2739
|
+
process.stdout.write(`[remote-pi] Channel push enabled — incoming messages wake Claude\n\n`);
|
|
2740
|
+
// Launch flags:
|
|
2741
|
+
// --dangerously-load-development-channels TAG — enable claude/channel push
|
|
2742
|
+
// for our local (non-allowlisted) server, so incoming mesh messages
|
|
2743
|
+
// wake Claude instead of waiting for a get_messages poll. Entries must
|
|
2744
|
+
// be tagged: `server:<name>` for a manually configured MCP server
|
|
2745
|
+
// (`plugin:<name>@<marketplace>` is the plugin form). Shows a one-time
|
|
2746
|
+
// confirmation dialog at startup.
|
|
2747
|
+
// --dangerously-skip-permissions — auto-approve tool calls
|
|
2748
|
+
spawnSync("claude", [
|
|
2749
|
+
"--dangerously-load-development-channels", `server:${SERVER_NAME}`,
|
|
2750
|
+
"--dangerously-skip-permissions",
|
|
2751
|
+
], {
|
|
2752
|
+
cwd: absCwd,
|
|
2753
|
+
stdio: "inherit",
|
|
2754
|
+
shell: false,
|
|
2755
|
+
});
|
|
2215
2756
|
}
|
|
2216
2757
|
//# sourceMappingURL=index.js.map
|