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.
Files changed (46) hide show
  1. package/README.md +44 -0
  2. package/dist/actions/handlers.d.ts +116 -0
  3. package/dist/actions/handlers.js +152 -0
  4. package/dist/actions/handlers.js.map +1 -0
  5. package/dist/actions/registry.d.ts +25 -0
  6. package/dist/actions/registry.js +34 -0
  7. package/dist/actions/registry.js.map +1 -0
  8. package/dist/bin/supervisord.js +43 -1
  9. package/dist/bin/supervisord.js.map +1 -1
  10. package/dist/commands/builtin_mirror.d.ts +58 -0
  11. package/dist/commands/builtin_mirror.js +71 -0
  12. package/dist/commands/builtin_mirror.js.map +1 -0
  13. package/dist/commands/list_commands.d.ts +60 -0
  14. package/dist/commands/list_commands.js +73 -0
  15. package/dist/commands/list_commands.js.map +1 -0
  16. package/dist/daemon/control_protocol.d.ts +9 -1
  17. package/dist/daemon/control_protocol.js +1 -1
  18. package/dist/daemon/control_protocol.js.map +1 -1
  19. package/dist/daemon/rpc_child.d.ts +24 -0
  20. package/dist/daemon/rpc_child.js +41 -2
  21. package/dist/daemon/rpc_child.js.map +1 -1
  22. package/dist/daemon/supervisor.d.ts +11 -0
  23. package/dist/daemon/supervisor.js +56 -4
  24. package/dist/daemon/supervisor.js.map +1 -1
  25. package/dist/index.d.ts +7 -1
  26. package/dist/index.js +749 -208
  27. package/dist/index.js.map +1 -1
  28. package/dist/mcp/mesh_server.d.ts +16 -0
  29. package/dist/mcp/mesh_server.js +207 -0
  30. package/dist/mcp/mesh_server.js.map +1 -0
  31. package/dist/protocol/types.d.ts +103 -0
  32. package/dist/session/bridge.d.ts +39 -0
  33. package/dist/session/bridge.js +41 -0
  34. package/dist/session/bridge.js.map +1 -0
  35. package/dist/session/mesh_node.d.ts +123 -0
  36. package/dist/session/mesh_node.js +203 -0
  37. package/dist/session/mesh_node.js.map +1 -0
  38. package/dist/session/setup_wizard.d.ts +6 -23
  39. package/dist/session/setup_wizard.js +6 -15
  40. package/dist/session/setup_wizard.js.map +1 -1
  41. package/dist/session/tools.d.ts +1 -1
  42. package/dist/transport/relay_client.d.ts +8 -0
  43. package/dist/transport/relay_client.js +50 -2
  44. package/dist/transport/relay_client.js.map +1 -1
  45. package/package.json +5 -3
  46. 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 { buildQRUri, qrSession, renderQRAscii, startQRRotation } from "./pairing/qr.js";
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 { PiForwardClient } from "./transport/pi_forward_client.js";
45
- import { discoverSelfLabel, discoverSiblings, fallbackLabel } from "./mesh/siblings.js";
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 { hostname } from "node:os";
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
- let _sessionPeer = null;
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
- * Bring up the cross-PC router when both prerequisites are met:
124
- * 1. local session peer is the leader (we host the Broker)
125
- * 2. relay WS is up (`_relay !== null`)
126
- *
127
- * Idempotent: safe to call from `_cmdStart`, `_cmdJoin`, post-failover
128
- * reconnect, etc. Self-heals: if siblings can't be loaded (no mesh blob
129
- * yet, network hiccup) we still attach a `BrokerRemote` with an empty
130
- * sibling set — push from a remote sibling will populate the cache later.
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
- async function _ensureBrokerRemote() {
133
- if (_brokerRemote !== null)
134
- return;
135
- if (!_sessionPeer || _sessionPeer.currentRole() !== "leader")
136
- return;
137
- const broker = _sessionPeer.localBroker();
138
- if (!broker)
139
- return;
140
- if (!_relay || !_relayUrl || !_cachedEd25519)
141
- return;
142
- const pi = new PiForwardClient(_relay);
143
- _piForwardClient = pi;
144
- const selfPubkeyB64 = Buffer.from(_cachedEd25519.publicKey).toString("base64");
145
- // Best-effort sibling + label discovery failures are non-fatal.
146
- let selfPcLabel = fallbackLabel(selfPubkeyB64);
147
- let siblings = [];
148
- try {
149
- const meshClient = new MeshClient(_relayUrl);
150
- const owners = await listOwnerPubkeys();
151
- if (owners.length > 0) {
152
- // Pass a silent `log` so per-Owner fetch failures (relay 404 before
153
- // any Owner published, transient HTTP errors, etc.) don't leak as
154
- // stderr into the TUI chat panel.
155
- const silentDiscoverLog = { warn: (_m) => { } };
156
- const [labelRes, sibs] = await Promise.all([
157
- discoverSelfLabel({ client: meshClient, ownerEpks: owners, myPubkey: _cachedEd25519.publicKey, log: silentDiscoverLog }),
158
- discoverSiblings({ client: meshClient, ownerEpks: owners, myPubkey: _cachedEd25519.publicKey, log: silentDiscoverLog }),
159
- ]);
160
- selfPcLabel = labelRes.selfPcLabel;
161
- siblings = sibs;
162
- }
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
- function _teardownBrokerRemote() {
188
- _brokerRemote?.detach();
189
- _brokerRemote = null;
190
- _piForwardClient?.detach();
191
- _piForwardClient = null;
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: _sessionPeer?.name(),
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 (_sessionPeer)
390
- return _sessionPeer.name();
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
- _teardownBrokerRemote();
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
- _teardownBrokerRemote();
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
- void _ensureBrokerRemote().catch(() => { });
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 `_sessionPeer` live so the
805
+ // session network natively. Getter captures `_meshNode` live so the
778
806
  // tool always sees the current state.
779
- registerAgentTools(pi, () => _sessionPeer);
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
- _currentModel = modelName;
808
- // Keep the cached room_meta fresh so a future reconnect carries the
809
- // current model in its hello (otherwise the post-reconnect hello would
810
- // ship the stale model that was active at _cmdStart time).
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, model: modelName };
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: { model: modelName },
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: String(event.result) }
848
- : { type: "tool_result", tool_call_id: event.toolCallId, result: event.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 (!_sessionPeer)
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 _sessionPeer.send("broker", { type: "turn_state", busy: true })
960
+ void _meshNode.send("broker", { type: "turn_state", busy: true })
883
961
  .catch(() => { });
884
962
  });
885
963
  pi.on("turn_end", () => {
886
- if (!_sessionPeer)
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 _sessionPeer.send("broker", { type: "turn_state", busy: false })
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 (will be supervised once `install` runs)",
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: "Unregister a daemon by id (local config is preserved)",
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 (_sessionPeer) {
1041
- const name = _sessionPeer.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 (!_sessionPeer) {
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 _sessionPeer.request("broker", { type: "list_peers" }, 2000);
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 = _sessionPeer.name();
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 wizardResult = await runSetupWizard(ui, {
1248
+ const newConfig = await runSetupWizard(ui, {
1125
1249
  agent_name: baseDefault,
1126
1250
  use_relay: true,
1127
- enable_daemon: false,
1128
1251
  });
1129
- if (!wizardResult) {
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) && !_sessionPeer) {
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 wizardResult = await runSetupWizard(ui, {
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 (!wizardResult) {
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
- // Initial model from ctx (ExtensionContext.model is the SDK's current
1202
- // selection set by user settings or last-used). May be undefined on
1203
- // first boot before any model_select; that's fine, room_meta omits the
1204
- // field then.
1205
- const ctxModelName = ctx.model;
1206
- if (ctxModelName)
1207
- _currentModel = ctxModelName.name ?? ctxModelName.id ?? undefined;
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 broker_remote's sibling list in sync with
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
- // an owner relabeling a nickname. `_brokerRemote` may be null
1288
- // until `_ensureBrokerRemote` finishes; the callback short-circuits.
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
- _brokerRemote?.setSiblings(siblings);
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 (broker_remote needs the local Broker
1301
- // instance the leader hosts). Best-effort; failures don't surface.
1302
- void _ensureBrokerRemote().catch(() => { });
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 (!_sessionPeer)
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 = _sessionPeer !== null;
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 _sessionPeer.leave();
1536
+ await _meshNode.close();
1387
1537
  }
1388
1538
  catch { /* best-effort */ }
1389
- _sessionPeer = null;
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
- `Local config at ${result.cwd}/.pi/remote-pi/config.json was kept.`, "info");
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 (_sessionPeer) {
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 SessionPeer({ sockPath: sock, name: agentName, auditPath: audit });
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) && _brokerRemote) {
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
- _brokerRemote.onLocalPeersChanged(local);
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
- // Anything else is a real agent-to-agent message. SessionPeer already
1852
- // correlated replies (env.re matched a pending request) before reaching
1853
- // here what arrives now is unsolicited and needs the LLM to react.
1854
- // Inject as a user message so the model sees it as a turn input. Include
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
- // Plan/25 Wave D: when this peer was promoted to leader by the failover,
1871
- // it now hosts a fresh `Broker` instance with no `RemoteRouter` attached.
1872
- // The previous broker_remote (on the dead leader) is gone with that
1873
- // process. Recreate ours here. Followers stay no-op (broker_remote only
1874
- // runs on the leader). Idempotent — _ensureBrokerRemote short-circuits
1875
- // when one is already wired.
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.start();
1889
- _sessionPeer = peer;
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
- void _ensureBrokerRemote().catch(() => { });
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
- _broadcastToActive({ type: "user_message", id: msg.id, text: msg.text });
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
- _pi.sendUserMessage(msg.text);
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 === "user") {
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
- events.push({
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
- const text = _stringifyContent(m.content);
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
- if (import.meta.url === `file://${process.argv[1]}`) {
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
- const edKp = await getOrCreateEd25519Keypair();
2207
- const sessionName = process.cwd().split("/").slice(-2).join("/");
2208
- const { url: relayUrl, source } = resolveRelayUrl();
2209
- const roomId = roomIdForCwd(process.cwd());
2210
- console.log(`[remote-pi] relay: ${relayUrl} (source: ${source}), room: ${roomId}`);
2211
- void cliArgs;
2212
- const stop = startQRRotation(edKp.publicKey, sessionName, roomId);
2213
- process.once("SIGINT", () => { stop(); process.exit(0); });
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