remote-pi 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +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 +8 -0
- 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 +6 -0
- package/dist/index.js +753 -209
- 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/pairing/storage.js +12 -10
- package/dist/pairing/storage.js.map +1 -1
- 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.js +0 -6
- package/dist/session/tools.js.map +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 +4 -2
- 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 "@mariozechner/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,61 +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
|
-
const [labelRes, sibs] = await Promise.all([
|
|
153
|
-
discoverSelfLabel({ client: meshClient, ownerEpks: owners, myPubkey: _cachedEd25519.publicKey }),
|
|
154
|
-
discoverSiblings({ client: meshClient, ownerEpks: owners, myPubkey: _cachedEd25519.publicKey }),
|
|
155
|
-
]);
|
|
156
|
-
selfPcLabel = labelRes.selfPcLabel;
|
|
157
|
-
siblings = sibs;
|
|
158
|
-
}
|
|
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 } });
|
|
159
157
|
}
|
|
160
|
-
catch (err) {
|
|
161
|
-
console.error(`[remote-pi] broker_remote bootstrap: sibling discovery failed: ${String(err)}`);
|
|
162
|
-
}
|
|
163
|
-
_brokerRemote = new BrokerRemote({
|
|
164
|
-
broker,
|
|
165
|
-
pi,
|
|
166
|
-
selfPcLabel,
|
|
167
|
-
selfPcPubkey: selfPubkeyB64,
|
|
168
|
-
siblings,
|
|
169
|
-
});
|
|
170
158
|
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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(() => { });
|
|
176
173
|
}
|
|
177
174
|
/** Refreshes the Pi TUI footer slots from current module state. Safe no-op when ctx lacks ui. */
|
|
178
175
|
function _refreshFooter(ctx) {
|
|
@@ -189,7 +186,7 @@ function _refreshFooter(ctx) {
|
|
|
189
186
|
// `/remote-pi status` line, not the footer slot).
|
|
190
187
|
devicePaired: _anyPeerActive() ? _peerShort : undefined,
|
|
191
188
|
hasPairings: _hasGlobalPairings,
|
|
192
|
-
agentName:
|
|
189
|
+
agentName: _meshNode?.name(),
|
|
193
190
|
};
|
|
194
191
|
updateFooter({ ui: { setStatus: ui.setStatus.bind(ui), setTitle: ui.setTitle.bind(ui) } }, state);
|
|
195
192
|
}
|
|
@@ -242,6 +239,42 @@ export function _setSessionStartedAtForTest(ts) {
|
|
|
242
239
|
export function _setCurrentModelForTest(name) {
|
|
243
240
|
_currentModel = name;
|
|
244
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
|
+
}
|
|
245
278
|
// Per-turn messaging state
|
|
246
279
|
let _currentTurnId = null;
|
|
247
280
|
// Module-level pi reference
|
|
@@ -370,8 +403,8 @@ function _detachPeerChannel(appPeerId) {
|
|
|
370
403
|
* the raw cwd path.
|
|
371
404
|
*/
|
|
372
405
|
function _displayName(cwd) {
|
|
373
|
-
if (
|
|
374
|
-
return
|
|
406
|
+
if (_meshNode)
|
|
407
|
+
return _meshNode.name();
|
|
375
408
|
const local = loadLocalConfig(cwd);
|
|
376
409
|
return local.agent_name || defaultAgentName(cwd);
|
|
377
410
|
}
|
|
@@ -424,7 +457,7 @@ function _goIdle(byeReason) {
|
|
|
424
457
|
_selfRevoke?.stop();
|
|
425
458
|
_selfRevoke = null;
|
|
426
459
|
// Cross-PC routing relies on _relay being up; tear it down here too.
|
|
427
|
-
|
|
460
|
+
_meshNode?.detachBridge();
|
|
428
461
|
// Preserve _sessionStartedAt + _messageBuffer across stop/start cycles.
|
|
429
462
|
// The Pi agent session outlives the relay connection — `message_end` keeps
|
|
430
463
|
// firing for terminal turns even while idle, and the buffer must survive
|
|
@@ -463,7 +496,7 @@ function _onRelayClose() {
|
|
|
463
496
|
_relay = null; // _relayUrl preserved for retry
|
|
464
497
|
// Cross-PC routing relies on _relay; bring it down. Will be re-instated
|
|
465
498
|
// by _attemptReconnect on success.
|
|
466
|
-
|
|
499
|
+
_meshNode?.detachBridge();
|
|
467
500
|
_state = "started";
|
|
468
501
|
_refreshFooter();
|
|
469
502
|
_scheduleReconnect();
|
|
@@ -520,9 +553,7 @@ async function _attemptReconnect() {
|
|
|
520
553
|
relay.on("close", _onRelayClose);
|
|
521
554
|
_stopAutoListener = _installAutoListener(relay);
|
|
522
555
|
// Plan/25 Wave B/C: relay is back; bring cross-PC routing back online.
|
|
523
|
-
|
|
524
|
-
console.error(`[remote-pi] _ensureBrokerRemote (post-reconnect) failed: ${String(err)}`);
|
|
525
|
-
});
|
|
556
|
+
_attachBridgeIfReady();
|
|
526
557
|
// _state stays "started"; peer reconnect (if previously paired) flows
|
|
527
558
|
// through _installAutoListener → _findKnownPeer → _promoteToPaired
|
|
528
559
|
// automatically when the app sends any inner.
|
|
@@ -743,12 +774,22 @@ async function _handlePairRequest(relay, appPeerId, inner) {
|
|
|
743
774
|
});
|
|
744
775
|
}
|
|
745
776
|
// ── Extension factory (default export) ───────────────────────────────────────
|
|
746
|
-
// 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).
|
|
747
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;
|
|
748
790
|
const _noopCtx = { ui: { notify: () => undefined }, abort: () => undefined };
|
|
749
791
|
const extension = (pi) => {
|
|
750
792
|
_pi = pi;
|
|
751
|
-
console.error(`[remote-pi] session sync limit: ${_getSyncLimit()}`);
|
|
752
793
|
// Plano 19: ensure ~/.pi/remote/{sessions,skills}/ exist and deploy the
|
|
753
794
|
// agent-network skill on first load. resources_discover lets Pi find it.
|
|
754
795
|
try {
|
|
@@ -761,9 +802,9 @@ const extension = (pi) => {
|
|
|
761
802
|
_refreshPairingsCache();
|
|
762
803
|
pi.on("resources_discover", () => ({ skillPaths: [skillsDir()] }));
|
|
763
804
|
// Plano 20: agent_send + agent_request tools so the LLM can drive the
|
|
764
|
-
// session network natively. Getter captures `
|
|
805
|
+
// session network natively. Getter captures `_meshNode` live so the
|
|
765
806
|
// tool always sees the current state.
|
|
766
|
-
registerAgentTools(pi, () =>
|
|
807
|
+
registerAgentTools(pi, () => _meshNode?.peer() ?? null);
|
|
767
808
|
// Tool calls execute without prompting the remote user. The Pi SDK has no
|
|
768
809
|
// native `requiresApproval` per tool, and a hardcoded gate (Bash/Edit/Write)
|
|
769
810
|
// misfired on every custom tool from third-party packages. Approval will
|
|
@@ -791,19 +832,29 @@ const extension = (pi) => {
|
|
|
791
832
|
const modelName = m?.name ?? m?.id;
|
|
792
833
|
if (!modelName)
|
|
793
834
|
return;
|
|
794
|
-
|
|
795
|
-
//
|
|
796
|
-
//
|
|
797
|
-
|
|
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;
|
|
798
850
|
if (_myRoomMeta)
|
|
799
|
-
_myRoomMeta = { ..._myRoomMeta,
|
|
851
|
+
_myRoomMeta = { ..._myRoomMeta, thinking: level };
|
|
800
852
|
if (!_relay || !_myRoomId)
|
|
801
853
|
return;
|
|
802
|
-
console.error(`[remote-pi] model_select → ${modelName}`);
|
|
803
854
|
_relay.sendControl({
|
|
804
855
|
type: "room_meta_update",
|
|
805
856
|
room_id: _myRoomId,
|
|
806
|
-
meta: {
|
|
857
|
+
meta: { thinking: level },
|
|
807
858
|
});
|
|
808
859
|
});
|
|
809
860
|
pi.on("message_update", (event) => {
|
|
@@ -831,9 +882,14 @@ const extension = (pi) => {
|
|
|
831
882
|
pi.on("tool_execution_end", (event) => {
|
|
832
883
|
if (!_anyPeerActive())
|
|
833
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);
|
|
834
890
|
const msg = event.isError
|
|
835
|
-
? { type: "tool_result", tool_call_id: event.toolCallId, error:
|
|
836
|
-
: { 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 };
|
|
837
893
|
_broadcastToActive(msg);
|
|
838
894
|
});
|
|
839
895
|
// Cumulative session buffer fed via `message_end`, which fires once per
|
|
@@ -850,6 +906,21 @@ const extension = (pi) => {
|
|
|
850
906
|
if (m.role === "user" || m.role === "assistant" || m.role === "toolResult") {
|
|
851
907
|
_messageBuffer.push(m);
|
|
852
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
|
+
}
|
|
853
924
|
});
|
|
854
925
|
pi.on("agent_end", () => {
|
|
855
926
|
// Buffer is fed by `message_end`; here we only finalize the outbound
|
|
@@ -864,18 +935,71 @@ const extension = (pi) => {
|
|
|
864
935
|
// mid-turn (and `received` once idle). Fire-and-forget — if the broker
|
|
865
936
|
// can't be reached, the worst case is a bad ACK answer; recovery is the
|
|
866
937
|
// next turn boundary. Skip silently when no mesh session is joined.
|
|
867
|
-
pi.on("turn_start", () => {
|
|
868
|
-
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)
|
|
869
959
|
return;
|
|
870
|
-
void
|
|
960
|
+
void _meshNode.send("broker", { type: "turn_state", busy: true })
|
|
871
961
|
.catch(() => { });
|
|
872
962
|
});
|
|
873
963
|
pi.on("turn_end", () => {
|
|
874
|
-
|
|
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)
|
|
875
971
|
return;
|
|
876
|
-
void
|
|
972
|
+
void _meshNode.send("broker", { type: "turn_state", busy: false })
|
|
877
973
|
.catch(() => { });
|
|
878
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
|
+
});
|
|
879
1003
|
// ── Commands ──────────────────────────────────────────────────────────────
|
|
880
1004
|
//
|
|
881
1005
|
// Final surface: 8 commands. Pre-2026-05-23 we had 20 commands covering
|
|
@@ -937,10 +1061,10 @@ const extension = (pi) => {
|
|
|
937
1061
|
await _cmdPeers(ctx);
|
|
938
1062
|
}
|
|
939
1063
|
else if (sub.startsWith("create")) {
|
|
940
|
-
_cmdCreate(sub.slice("create".length).trim(), ctx);
|
|
1064
|
+
await _cmdCreate(sub.slice("create".length).trim(), ctx);
|
|
941
1065
|
}
|
|
942
1066
|
else if (sub.startsWith("remove")) {
|
|
943
|
-
_cmdRemove(sub.slice("remove".length).trim(), ctx);
|
|
1067
|
+
await _cmdRemove(sub.slice("remove".length).trim(), ctx);
|
|
944
1068
|
}
|
|
945
1069
|
else if (sub === "daemons") {
|
|
946
1070
|
await _cmdDaemonsList(ctx);
|
|
@@ -993,12 +1117,12 @@ const extension = (pi) => {
|
|
|
993
1117
|
// Daemon registry (plan/26 Wave 1) — create + remove. start/stop/send/
|
|
994
1118
|
// status/install/uninstall come in later waves with the supervisor.
|
|
995
1119
|
pi.registerCommand("remote-pi create", {
|
|
996
|
-
description: "Register a folder as a daemon (
|
|
997
|
-
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); },
|
|
998
1122
|
});
|
|
999
1123
|
pi.registerCommand("remote-pi remove", {
|
|
1000
|
-
description: "
|
|
1001
|
-
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); },
|
|
1002
1126
|
});
|
|
1003
1127
|
// Fleet ops via the supervisor (plan/26 W2). `/remote-pi stop` stays as
|
|
1004
1128
|
// local stop — fleet stop is `/remote-pi daemon stop`.
|
|
@@ -1011,6 +1135,18 @@ const extension = (pi) => {
|
|
|
1011
1135
|
// Service install / uninstall (plan/26 W3)
|
|
1012
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 }); } });
|
|
1013
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
|
+
}
|
|
1014
1150
|
};
|
|
1015
1151
|
export default extension;
|
|
1016
1152
|
// ── Command implementations ───────────────────────────────────────────────────
|
|
@@ -1025,8 +1161,8 @@ function _cmdStatus(ctx) {
|
|
|
1025
1161
|
const relayUrl = _relayUrl ?? resolveRelayUrl().url;
|
|
1026
1162
|
// Mesh line
|
|
1027
1163
|
let meshLine;
|
|
1028
|
-
if (
|
|
1029
|
-
const name =
|
|
1164
|
+
if (_meshNode) {
|
|
1165
|
+
const name = _meshNode.name();
|
|
1030
1166
|
meshLine = `🟢 Local mesh: connected as "${name}" (${_sessionPeerCount} peer${_sessionPeerCount === 1 ? "" : "s"})`;
|
|
1031
1167
|
}
|
|
1032
1168
|
else {
|
|
@@ -1058,13 +1194,13 @@ function _cmdStatus(ctx) {
|
|
|
1058
1194
|
* their machine vs. on a paired sibling Pi.
|
|
1059
1195
|
*/
|
|
1060
1196
|
async function _cmdPeers(ctx) {
|
|
1061
|
-
if (!
|
|
1197
|
+
if (!_meshNode) {
|
|
1062
1198
|
ctx.ui.notify("[remote-pi] Not on the local mesh. Run /remote-pi to join.", "warning");
|
|
1063
1199
|
return;
|
|
1064
1200
|
}
|
|
1065
1201
|
let peers;
|
|
1066
1202
|
try {
|
|
1067
|
-
const reply = await
|
|
1203
|
+
const reply = await _meshNode.request("broker", { type: "list_peers" }, 2000);
|
|
1068
1204
|
peers = reply.body?.peers ?? [];
|
|
1069
1205
|
}
|
|
1070
1206
|
catch (err) {
|
|
@@ -1073,7 +1209,7 @@ async function _cmdPeers(ctx) {
|
|
|
1073
1209
|
}
|
|
1074
1210
|
// Exclude self from the printed list — `list_peers` returns every peer
|
|
1075
1211
|
// registered with the broker including the caller, which is noise here.
|
|
1076
|
-
const selfName =
|
|
1212
|
+
const selfName = _meshNode.name();
|
|
1077
1213
|
ctx.ui.notify(`[remote-pi] peers:\n${formatPeerInventory(peers, selfName)}`, "info");
|
|
1078
1214
|
}
|
|
1079
1215
|
/**
|
|
@@ -1109,30 +1245,25 @@ async function _cmdRoot(ctx) {
|
|
|
1109
1245
|
return;
|
|
1110
1246
|
}
|
|
1111
1247
|
const baseDefault = defaultAgentName(cwd);
|
|
1112
|
-
const
|
|
1248
|
+
const newConfig = await runSetupWizard(ui, {
|
|
1113
1249
|
agent_name: baseDefault,
|
|
1114
1250
|
use_relay: true,
|
|
1115
|
-
enable_daemon: false,
|
|
1116
1251
|
});
|
|
1117
|
-
if (!
|
|
1252
|
+
if (!newConfig) {
|
|
1118
1253
|
ctx.ui.notify("[remote-pi] Setup cancelled.", "info");
|
|
1119
1254
|
return;
|
|
1120
1255
|
}
|
|
1121
|
-
// `enable_daemon` is wizard-only state, not part of LocalConfig.
|
|
1122
|
-
const { enable_daemon, ...newConfig } = wizardResult;
|
|
1123
1256
|
saveLocalConfig(cwd, newConfig);
|
|
1124
1257
|
ctx.ui.notify(`[remote-pi] Config saved to ${cwd}/.pi/remote-pi/config.json`, "info");
|
|
1125
1258
|
await _cmdJoin(ctx);
|
|
1126
1259
|
if (effectiveAutoStartRelay(newConfig))
|
|
1127
1260
|
await _cmdStart(ctx);
|
|
1128
|
-
if (enable_daemon)
|
|
1129
|
-
_cmdInstall(ctx, { linkCli: true });
|
|
1130
1261
|
_cmdStatus(ctx);
|
|
1131
1262
|
return;
|
|
1132
1263
|
}
|
|
1133
1264
|
// Returning user with config: auto-start if requested + currently inactive.
|
|
1134
1265
|
const config = loadLocalConfig(cwd);
|
|
1135
|
-
if (effectiveAutoStartRelay(config) && !
|
|
1266
|
+
if (effectiveAutoStartRelay(config) && !_meshNode) {
|
|
1136
1267
|
await _cmdJoin(ctx);
|
|
1137
1268
|
if (_state === "idle")
|
|
1138
1269
|
await _cmdStart(ctx);
|
|
@@ -1152,23 +1283,16 @@ async function _cmdSetup(ctx) {
|
|
|
1152
1283
|
}
|
|
1153
1284
|
const current = loadLocalConfig(cwd);
|
|
1154
1285
|
const baseDefault = defaultAgentName(cwd);
|
|
1155
|
-
const
|
|
1286
|
+
const newConfig = await runSetupWizard(ui, {
|
|
1156
1287
|
agent_name: current.agent_name ?? baseDefault,
|
|
1157
1288
|
use_relay: effectiveAutoStartRelay(current),
|
|
1158
|
-
// No way to introspect launchd/systemd here without shelling out;
|
|
1159
|
-
// default the wizard to "off" — re-running setup is for changing
|
|
1160
|
-
// your mind, not for re-confirming current OS state.
|
|
1161
|
-
enable_daemon: false,
|
|
1162
1289
|
});
|
|
1163
|
-
if (!
|
|
1290
|
+
if (!newConfig) {
|
|
1164
1291
|
ctx.ui.notify("[remote-pi] Setup cancelled.", "info");
|
|
1165
1292
|
return;
|
|
1166
1293
|
}
|
|
1167
|
-
const { enable_daemon, ...newConfig } = wizardResult;
|
|
1168
1294
|
saveLocalConfig(cwd, newConfig);
|
|
1169
1295
|
ctx.ui.notify("[remote-pi] Config updated. Run /remote-pi to apply now.", "info");
|
|
1170
|
-
if (enable_daemon)
|
|
1171
|
-
_cmdInstall(ctx, { linkCli: true });
|
|
1172
1296
|
}
|
|
1173
1297
|
async function _cmdStart(ctx) {
|
|
1174
1298
|
if (_state !== "idle") {
|
|
@@ -1186,17 +1310,55 @@ async function _cmdStart(ctx) {
|
|
|
1186
1310
|
// Same name we send in pair_ok — keeps room_meta.name and the per-pair
|
|
1187
1311
|
// session_name aligned so the app shows consistent labels.
|
|
1188
1312
|
const sessionName = _displayName(cwd);
|
|
1189
|
-
//
|
|
1190
|
-
//
|
|
1191
|
-
//
|
|
1192
|
-
//
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
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 */ }
|
|
1196
1356
|
const roomMeta = { name: sessionName, cwd };
|
|
1197
1357
|
const modelName = _currentModelName();
|
|
1198
1358
|
if (modelName)
|
|
1199
1359
|
roomMeta.model = modelName;
|
|
1360
|
+
if (_currentThinking)
|
|
1361
|
+
roomMeta.thinking = _currentThinking;
|
|
1200
1362
|
// Persist so _attemptReconnect can replay the same hello payload — without
|
|
1201
1363
|
// this, reconnect issues a bare hello and the relay creates a "default room"
|
|
1202
1364
|
// entry that surfaces in the app as a phantom legacy session.
|
|
@@ -1269,24 +1431,25 @@ async function _cmdStart(ctx) {
|
|
|
1269
1431
|
display: true,
|
|
1270
1432
|
});
|
|
1271
1433
|
},
|
|
1272
|
-
// Plan/25 Wave D: keep
|
|
1434
|
+
// Plan/25 Wave D: keep the cross-PC sibling list in sync with
|
|
1273
1435
|
// mesh_versions. The poller fires this whenever the union of Pi
|
|
1274
|
-
// members across owners changes — adding/removing a sibling, or
|
|
1275
|
-
//
|
|
1276
|
-
//
|
|
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.
|
|
1277
1439
|
onMembersChanged: (siblings) => {
|
|
1278
|
-
|
|
1440
|
+
_meshNode?.setSiblings(siblings);
|
|
1279
1441
|
},
|
|
1442
|
+
// Silent log: routine self-revoke audit and per-Owner fetch
|
|
1443
|
+
// failures don't belong in the TUI chat panel. User-facing
|
|
1444
|
+
// revocation events flow through `onRevoke` → `pi.sendMessage`.
|
|
1445
|
+
log: { info: () => { }, warn: () => { }, error: () => { } },
|
|
1280
1446
|
});
|
|
1281
1447
|
_selfRevoke.start();
|
|
1282
1448
|
}
|
|
1283
1449
|
// Plan/25 Wave B/C: bring up cross-PC routing if local broker is ready.
|
|
1284
|
-
// No-op when we're a follower (
|
|
1285
|
-
//
|
|
1286
|
-
|
|
1287
|
-
void _ensureBrokerRemote().catch((err) => {
|
|
1288
|
-
console.error(`[remote-pi] _ensureBrokerRemote failed: ${String(err)}`);
|
|
1289
|
-
});
|
|
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();
|
|
1290
1453
|
ctx.ui.notify(`[remote-pi] state: started (peer=${myShort}) — Connected to relay ${relayUrl}`, "info");
|
|
1291
1454
|
}
|
|
1292
1455
|
/**
|
|
@@ -1315,7 +1478,7 @@ async function _cmdPair(ctx) {
|
|
|
1315
1478
|
return;
|
|
1316
1479
|
}
|
|
1317
1480
|
ctx.ui.notify("[remote-pi] Starting mesh + relay before pairing…", "info");
|
|
1318
|
-
if (!
|
|
1481
|
+
if (!_meshNode)
|
|
1319
1482
|
await _cmdJoin(ctx);
|
|
1320
1483
|
if (_state === "idle")
|
|
1321
1484
|
await _cmdStart(ctx);
|
|
@@ -1362,7 +1525,7 @@ async function _cmdPair(ctx) {
|
|
|
1362
1525
|
* `/remote-pi` again.
|
|
1363
1526
|
*/
|
|
1364
1527
|
async function _cmdStop(ctx) {
|
|
1365
|
-
const meshUp =
|
|
1528
|
+
const meshUp = _meshNode !== null;
|
|
1366
1529
|
const relayUp = _state !== "idle";
|
|
1367
1530
|
if (!meshUp && !relayUp) {
|
|
1368
1531
|
ctx.ui.notify("[remote-pi] Already stopped — nothing to do.", "info");
|
|
@@ -1370,10 +1533,10 @@ async function _cmdStop(ctx) {
|
|
|
1370
1533
|
}
|
|
1371
1534
|
if (meshUp) {
|
|
1372
1535
|
try {
|
|
1373
|
-
await
|
|
1536
|
+
await _meshNode.close();
|
|
1374
1537
|
}
|
|
1375
1538
|
catch { /* best-effort */ }
|
|
1376
|
-
|
|
1539
|
+
_meshNode = null;
|
|
1377
1540
|
_sessionName = null;
|
|
1378
1541
|
_sessionPeerCount = 0;
|
|
1379
1542
|
}
|
|
@@ -1474,7 +1637,7 @@ function _cmdSetRelay(arg, ctx) {
|
|
|
1474
1637
|
* on an existing daemon is idempotent at this layer; the registry
|
|
1475
1638
|
* itself rejects duplicate cwds.
|
|
1476
1639
|
*/
|
|
1477
|
-
function _cmdCreate(arg, ctx) {
|
|
1640
|
+
async function _cmdCreate(arg, ctx) {
|
|
1478
1641
|
// Parse `[cwd] [--name "value with spaces" | --name word]` in any order.
|
|
1479
1642
|
// The first non-flag token is the cwd; the rest of the line after
|
|
1480
1643
|
// `--name` (quoted or unquoted) is the display name.
|
|
@@ -1506,6 +1669,23 @@ function _cmdCreate(arg, ctx) {
|
|
|
1506
1669
|
}
|
|
1507
1670
|
const finalName = loadLocalConfig(result.cwd).agent_name ?? defaultAgentName(result.cwd);
|
|
1508
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
|
+
}
|
|
1509
1689
|
}
|
|
1510
1690
|
/**
|
|
1511
1691
|
* `/remote-pi remove <id>`
|
|
@@ -1515,12 +1695,35 @@ function _cmdCreate(arg, ctx) {
|
|
|
1515
1695
|
* stays on disk — re-creating later with the same cwd is a no-op
|
|
1516
1696
|
* because the existing config wins.
|
|
1517
1697
|
*/
|
|
1518
|
-
function _cmdRemove(arg, ctx) {
|
|
1698
|
+
async function _cmdRemove(arg, ctx) {
|
|
1519
1699
|
const id = arg.trim();
|
|
1520
1700
|
if (!id) {
|
|
1521
1701
|
ctx.ui.notify("[remote-pi] Usage: /remote-pi remove <id>. Run /remote-pi daemons to see ids.", "warning");
|
|
1522
1702
|
return;
|
|
1523
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
|
+
}
|
|
1524
1727
|
let result;
|
|
1525
1728
|
try {
|
|
1526
1729
|
result = removeDaemon(id);
|
|
@@ -1530,13 +1733,12 @@ function _cmdRemove(arg, ctx) {
|
|
|
1530
1733
|
return;
|
|
1531
1734
|
}
|
|
1532
1735
|
if (!result.removed) {
|
|
1533
|
-
// Surface the registered ids for a quick visual diff.
|
|
1534
1736
|
const known = listDaemons().map((d) => d.id).join(", ") || "(none)";
|
|
1535
1737
|
ctx.ui.notify(`[remote-pi] No daemon with id "${id}". Known ids: ${known}`, "warning");
|
|
1536
1738
|
return;
|
|
1537
1739
|
}
|
|
1538
|
-
ctx.ui.notify(`[remote-pi] Daemon removed: id=${id} cwd=${result.cwd}. ` +
|
|
1539
|
-
`
|
|
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");
|
|
1540
1742
|
}
|
|
1541
1743
|
// ── Fleet-ops commands (plan/26 W2) — talk to the supervisor over UDS ─────────
|
|
1542
1744
|
//
|
|
@@ -1790,6 +1992,84 @@ function _deployAgentNetworkSkill() {
|
|
|
1790
1992
|
}
|
|
1791
1993
|
catch { /* best-effort */ }
|
|
1792
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
|
+
}
|
|
1793
2073
|
/**
|
|
1794
2074
|
* Joins the fixed local UDS mesh ("local" session — see LOCAL_SESSION_NAME).
|
|
1795
2075
|
* Called by `_cmdRoot` on first run and on subsequent runs when the relay
|
|
@@ -1802,7 +2082,7 @@ async function _cmdJoin(ctx) {
|
|
|
1802
2082
|
const local = loadLocalConfig(cwd);
|
|
1803
2083
|
const sessionName = LOCAL_SESSION_NAME;
|
|
1804
2084
|
const agentName = local.agent_name || defaultAgentName(cwd);
|
|
1805
|
-
if (
|
|
2085
|
+
if (_meshNode) {
|
|
1806
2086
|
ctx.ui.notify("[remote-pi] Already on the local mesh.", "warning");
|
|
1807
2087
|
return;
|
|
1808
2088
|
}
|
|
@@ -1810,7 +2090,7 @@ async function _cmdJoin(ctx) {
|
|
|
1810
2090
|
mkdirSync(join(skillsDir(), "..", "sessions", sessionName), { recursive: true });
|
|
1811
2091
|
const sock = sessionSockPath(sessionName);
|
|
1812
2092
|
const audit = sessionAuditPath(sessionName);
|
|
1813
|
-
const peer = new
|
|
2093
|
+
const peer = new MeshNode({ sockPath: sock, name: agentName, auditPath: audit });
|
|
1814
2094
|
peer.onMessage((env) => {
|
|
1815
2095
|
const body = env.body;
|
|
1816
2096
|
// Broker system events: re-query broker for authoritative count.
|
|
@@ -1823,11 +2103,12 @@ async function _cmdJoin(ctx) {
|
|
|
1823
2103
|
void peer.request("broker", { type: "list_peers" }, 2000)
|
|
1824
2104
|
.then((reply) => {
|
|
1825
2105
|
const peers = reply.body?.peers;
|
|
1826
|
-
if (Array.isArray(peers)
|
|
2106
|
+
if (Array.isArray(peers)) {
|
|
1827
2107
|
// Strip remote-prefixed entries — onLocalPeersChanged wants
|
|
1828
|
-
// 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).
|
|
1829
2110
|
const local = peers.filter((p) => !p.includes(":"));
|
|
1830
|
-
|
|
2111
|
+
peer.onLocalPeersChanged(local);
|
|
1831
2112
|
}
|
|
1832
2113
|
})
|
|
1833
2114
|
.catch(() => { });
|
|
@@ -1835,47 +2116,24 @@ async function _cmdJoin(ctx) {
|
|
|
1835
2116
|
}
|
|
1836
2117
|
if (env.from === "broker")
|
|
1837
2118
|
return; // other broker control messages — ignore
|
|
1838
|
-
//
|
|
1839
|
-
//
|
|
1840
|
-
//
|
|
1841
|
-
|
|
1842
|
-
// the `id` so the LLM can echo it via `agent_send(..., re=<id>)` when
|
|
1843
|
-
// replying (otherwise the sender's agent_request times out).
|
|
1844
|
-
if (!_pi)
|
|
1845
|
-
return;
|
|
1846
|
-
const bodyText = typeof env.body === "string" ? env.body : JSON.stringify(env.body);
|
|
1847
|
-
const header = `[agent-network] message from "${env.from}" (id=${env.id}${env.re ? `, re=${env.re}` : ""}):`;
|
|
1848
|
-
const footer = env.re
|
|
1849
|
-
? "(This is a reply to a previous message of yours.)"
|
|
1850
|
-
: `(If a reply is expected, call agent_send with to="${env.from}" and re="${env.id}".)`;
|
|
1851
|
-
_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);
|
|
1852
2123
|
});
|
|
1853
2124
|
// After failover (leader died, we re-elected): the new broker's peers map
|
|
1854
2125
|
// starts fresh, but our cached `_sessionPeerCount` is stale. Re-seed it so
|
|
1855
2126
|
// surviving peers don't carry the pre-failover count forever.
|
|
1856
2127
|
//
|
|
1857
|
-
//
|
|
1858
|
-
//
|
|
1859
|
-
//
|
|
1860
|
-
// process. Recreate ours here. Followers stay no-op (broker_remote only
|
|
1861
|
-
// runs on the leader). Idempotent — _ensureBrokerRemote short-circuits
|
|
1862
|
-
// when one is already wired.
|
|
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.
|
|
1863
2131
|
peer.onReconnect(() => {
|
|
1864
2132
|
_refreshSessionPeerCount(peer, ctx);
|
|
1865
|
-
if (peer.currentRole() === "leader") {
|
|
1866
|
-
// Tear down any previous instance first — its broker reference is now
|
|
1867
|
-
// stale (it pointed at the broker we hosted before the prior
|
|
1868
|
-
// disconnect). The new broker comes from `peer.localBroker()` after
|
|
1869
|
-
// reconnect.
|
|
1870
|
-
_teardownBrokerRemote();
|
|
1871
|
-
void _ensureBrokerRemote().catch((err) => {
|
|
1872
|
-
console.error(`[remote-pi] _ensureBrokerRemote (post-failover) failed: ${String(err)}`);
|
|
1873
|
-
});
|
|
1874
|
-
}
|
|
1875
2133
|
});
|
|
1876
2134
|
try {
|
|
1877
|
-
const assigned = await peer.
|
|
1878
|
-
|
|
2135
|
+
const assigned = await peer.connect();
|
|
2136
|
+
_meshNode = peer;
|
|
1879
2137
|
_sessionName = sessionName;
|
|
1880
2138
|
_sessionPeerCount = 1; // optimistic — overwritten by list_peers below
|
|
1881
2139
|
// Broker broadcasts `peer_joined` only to existing peers when a new one
|
|
@@ -1888,9 +2146,7 @@ async function _cmdJoin(ctx) {
|
|
|
1888
2146
|
// Plan/25 Wave B/C: try to bring up cross-PC routing now that the
|
|
1889
2147
|
// local broker exists. No-op if the relay isn't up yet (will fire
|
|
1890
2148
|
// again from `_cmdStart`).
|
|
1891
|
-
|
|
1892
|
-
console.error(`[remote-pi] _ensureBrokerRemote (post-join) failed: ${String(err)}`);
|
|
1893
|
-
});
|
|
2149
|
+
_attachBridgeIfReady();
|
|
1894
2150
|
}
|
|
1895
2151
|
catch (err) {
|
|
1896
2152
|
ctx.ui.notify(`[remote-pi] join failed: ${String(err)}`, "error");
|
|
@@ -1919,12 +2175,6 @@ export function _routeClientMessageFrom(sender, msg, ctx) {
|
|
|
1919
2175
|
return;
|
|
1920
2176
|
switch (msg.type) {
|
|
1921
2177
|
case "user_message": {
|
|
1922
|
-
// Reverse-lookup of the sender's appPeerId from `_activePeers` (the
|
|
1923
|
-
// PlainPeerChannel's `remotePeerId` is private). Purely diagnostic.
|
|
1924
|
-
const senderId = [..._activePeers.entries()].find(([, ch]) => ch === sender)?.[0] ?? "unknown";
|
|
1925
|
-
console.error(`[remote-pi] user_message from ${senderId.slice(0, 8)} ` +
|
|
1926
|
-
`id=${msg.id} text=${JSON.stringify(msg.text).slice(0, 60)} ` +
|
|
1927
|
-
`activePeers=[${[..._activePeers.keys()].map((k) => k.slice(0, 8)).join(", ")}]`);
|
|
1928
2178
|
// Source-of-truth rebroadcast (plan/24 W2D fix). Echo the message
|
|
1929
2179
|
// back to every attached owner (sender included) BEFORE handing it
|
|
1930
2180
|
// off to the agent — so that:
|
|
@@ -1936,9 +2186,26 @@ export function _routeClientMessageFrom(sender, msg, ctx) {
|
|
|
1936
2186
|
// The user_message is also recorded in _messageBuffer indirectly
|
|
1937
2187
|
// via `pi.on("message_end")` after the SDK persists the turn — so
|
|
1938
2188
|
// a later `session_sync` returns it in the history events.
|
|
1939
|
-
|
|
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);
|
|
1940
2196
|
_currentTurnId = msg.id;
|
|
1941
|
-
|
|
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
|
+
}
|
|
1942
2209
|
break;
|
|
1943
2210
|
}
|
|
1944
2211
|
case "approve_tool":
|
|
@@ -1959,6 +2226,48 @@ export function _routeClientMessageFrom(sender, msg, ctx) {
|
|
|
1959
2226
|
// Already paired — ignore subsequent pair_request to maintain idempotency.
|
|
1960
2227
|
// (Token is already consumed and peer is in peers.json.)
|
|
1961
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;
|
|
1962
2271
|
}
|
|
1963
2272
|
}
|
|
1964
2273
|
/**
|
|
@@ -2008,6 +2317,33 @@ function _handleSessionSync(sender, msg) {
|
|
|
2008
2317
|
truncated,
|
|
2009
2318
|
});
|
|
2010
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
|
+
}
|
|
2011
2347
|
function _stringifyContent(content) {
|
|
2012
2348
|
if (typeof content === "string")
|
|
2013
2349
|
return content;
|
|
@@ -2022,6 +2358,59 @@ function _stringifyContent(content) {
|
|
|
2022
2358
|
})
|
|
2023
2359
|
.join("");
|
|
2024
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
|
+
}
|
|
2025
2414
|
/**
|
|
2026
2415
|
* Maps SDK AgentMessage[] (UserMessage / AssistantMessage / ToolResultMessage)
|
|
2027
2416
|
* into the flat SessionHistoryEvent[] shape consumed by the app.
|
|
@@ -2036,15 +2425,31 @@ export function _mapAgentMessagesToEvents(messages) {
|
|
|
2036
2425
|
let lastUserId = null;
|
|
2037
2426
|
for (const m of messages) {
|
|
2038
2427
|
const ts = typeof m.timestamp === "number" ? m.timestamp : 0;
|
|
2039
|
-
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") {
|
|
2040
2438
|
const id = `sync_${ts}`;
|
|
2041
2439
|
lastUserId = id;
|
|
2042
|
-
|
|
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 = {
|
|
2043
2445
|
ts,
|
|
2044
2446
|
type: "user_input",
|
|
2045
2447
|
id,
|
|
2046
2448
|
text: _stringifyContent(m.content),
|
|
2047
|
-
}
|
|
2449
|
+
};
|
|
2450
|
+
if (images.length > 0)
|
|
2451
|
+
ev.images = images;
|
|
2452
|
+
events.push(ev);
|
|
2048
2453
|
}
|
|
2049
2454
|
else if (m.role === "assistant") {
|
|
2050
2455
|
const content = Array.isArray(m.content) ? m.content : [];
|
|
@@ -2080,7 +2485,8 @@ export function _mapAgentMessagesToEvents(messages) {
|
|
|
2080
2485
|
}
|
|
2081
2486
|
}
|
|
2082
2487
|
else if (m.role === "toolResult") {
|
|
2083
|
-
|
|
2488
|
+
// Same helper as the live `tool_execution_end` broadcast → live == re-sync.
|
|
2489
|
+
const text = _stringifyToolResult(m.content);
|
|
2084
2490
|
const tcid = String(m.toolCallId ?? "");
|
|
2085
2491
|
events.push(m.isError
|
|
2086
2492
|
? { ts, type: "tool_result", tool_call_id: tcid, error: text }
|
|
@@ -2090,7 +2496,15 @@ export function _mapAgentMessagesToEvents(messages) {
|
|
|
2090
2496
|
return events;
|
|
2091
2497
|
}
|
|
2092
2498
|
// ── Standalone CLI ────────────────────────────────────────────────────────────
|
|
2093
|
-
|
|
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()) {
|
|
2094
2508
|
const [, , subcmd, ...cliArgs] = process.argv;
|
|
2095
2509
|
if (subcmd === "devices" || subcmd === "list") {
|
|
2096
2510
|
const peers = await listPeers();
|
|
@@ -2146,13 +2560,13 @@ if (import.meta.url === `file://${process.argv[1]}`) {
|
|
|
2146
2560
|
// parser (shared with the slash-command path) sees the same shape
|
|
2147
2561
|
// as it would from a Pi interactive prompt.
|
|
2148
2562
|
const joined = cliArgs.map((a) => (/\s/.test(a) ? `"${a}"` : a)).join(" ");
|
|
2149
|
-
_cmdCreate(joined, {
|
|
2563
|
+
await _cmdCreate(joined, {
|
|
2150
2564
|
ui: { notify: (msg) => console.log(msg) },
|
|
2151
2565
|
});
|
|
2152
2566
|
}
|
|
2153
2567
|
else if (subcmd === "remove") {
|
|
2154
2568
|
const id = (cliArgs[0] ?? "").trim();
|
|
2155
|
-
_cmdRemove(id, {
|
|
2569
|
+
await _cmdRemove(id, {
|
|
2156
2570
|
ui: { notify: (msg) => console.log(msg) },
|
|
2157
2571
|
});
|
|
2158
2572
|
}
|
|
@@ -2187,6 +2601,9 @@ if (import.meta.url === `file://${process.argv[1]}`) {
|
|
|
2187
2601
|
console.log("Usage: remote-pi daemon <start|stop|restart|status|send <id> \"<text>\">");
|
|
2188
2602
|
}
|
|
2189
2603
|
}
|
|
2604
|
+
else if (subcmd === "claude") {
|
|
2605
|
+
await _cmdClaudeCli(cliArgs);
|
|
2606
|
+
}
|
|
2190
2607
|
else if (subcmd === "install") {
|
|
2191
2608
|
// CLI mode = user installed via `npm install -g remote-pi`, so the
|
|
2192
2609
|
// `remote-pi` / `pi-supervisord` bins are already on $PATH via npm's
|
|
@@ -2200,14 +2617,141 @@ if (import.meta.url === `file://${process.argv[1]}`) {
|
|
|
2200
2617
|
_cmdUninstall(stubCtx, { linkCli: false });
|
|
2201
2618
|
}
|
|
2202
2619
|
else {
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
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"));
|
|
2211
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
|
+
});
|
|
2212
2756
|
}
|
|
2213
2757
|
//# sourceMappingURL=index.js.map
|