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.
Files changed (48) 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 +8 -0
  17. package/dist/daemon/control_protocol.js.map +1 -1
  18. package/dist/daemon/rpc_child.d.ts +24 -0
  19. package/dist/daemon/rpc_child.js +41 -2
  20. package/dist/daemon/rpc_child.js.map +1 -1
  21. package/dist/daemon/supervisor.d.ts +11 -0
  22. package/dist/daemon/supervisor.js +56 -4
  23. package/dist/daemon/supervisor.js.map +1 -1
  24. package/dist/index.d.ts +6 -0
  25. package/dist/index.js +753 -209
  26. package/dist/index.js.map +1 -1
  27. package/dist/mcp/mesh_server.d.ts +16 -0
  28. package/dist/mcp/mesh_server.js +207 -0
  29. package/dist/mcp/mesh_server.js.map +1 -0
  30. package/dist/pairing/storage.js +12 -10
  31. package/dist/pairing/storage.js.map +1 -1
  32. package/dist/protocol/types.d.ts +103 -0
  33. package/dist/session/bridge.d.ts +39 -0
  34. package/dist/session/bridge.js +41 -0
  35. package/dist/session/bridge.js.map +1 -0
  36. package/dist/session/mesh_node.d.ts +123 -0
  37. package/dist/session/mesh_node.js +203 -0
  38. package/dist/session/mesh_node.js.map +1 -0
  39. package/dist/session/setup_wizard.d.ts +6 -23
  40. package/dist/session/setup_wizard.js +6 -15
  41. package/dist/session/setup_wizard.js.map +1 -1
  42. package/dist/session/tools.js +0 -6
  43. package/dist/session/tools.js.map +1 -1
  44. package/dist/transport/relay_client.d.ts +8 -0
  45. package/dist/transport/relay_client.js +50 -2
  46. package/dist/transport/relay_client.js.map +1 -1
  47. package/package.json +4 -2
  48. 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 "@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 { 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,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
- * 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
- 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
- function _teardownBrokerRemote() {
172
- _brokerRemote?.detach();
173
- _brokerRemote = null;
174
- _piForwardClient?.detach();
175
- _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(() => { });
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: _sessionPeer?.name(),
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 (_sessionPeer)
374
- return _sessionPeer.name();
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
- _teardownBrokerRemote();
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
- _teardownBrokerRemote();
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
- void _ensureBrokerRemote().catch((err) => {
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 `_sessionPeer` live so the
805
+ // session network natively. Getter captures `_meshNode` live so the
765
806
  // tool always sees the current state.
766
- registerAgentTools(pi, () => _sessionPeer);
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
- _currentModel = modelName;
795
- // Keep the cached room_meta fresh so a future reconnect carries the
796
- // current model in its hello (otherwise the post-reconnect hello would
797
- // 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;
798
850
  if (_myRoomMeta)
799
- _myRoomMeta = { ..._myRoomMeta, model: modelName };
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: { model: modelName },
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: String(event.result) }
836
- : { 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 };
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 (!_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)
869
959
  return;
870
- void _sessionPeer.send("broker", { type: "turn_state", busy: true })
960
+ void _meshNode.send("broker", { type: "turn_state", busy: true })
871
961
  .catch(() => { });
872
962
  });
873
963
  pi.on("turn_end", () => {
874
- 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)
875
971
  return;
876
- void _sessionPeer.send("broker", { type: "turn_state", busy: false })
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 (will be supervised once `install` runs)",
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: "Unregister a daemon by id (local config is preserved)",
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 (_sessionPeer) {
1029
- const name = _sessionPeer.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 (!_sessionPeer) {
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 _sessionPeer.request("broker", { type: "list_peers" }, 2000);
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 = _sessionPeer.name();
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 wizardResult = await runSetupWizard(ui, {
1248
+ const newConfig = await runSetupWizard(ui, {
1113
1249
  agent_name: baseDefault,
1114
1250
  use_relay: true,
1115
- enable_daemon: false,
1116
1251
  });
1117
- if (!wizardResult) {
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) && !_sessionPeer) {
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 wizardResult = await runSetupWizard(ui, {
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 (!wizardResult) {
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
- // Initial model from ctx (ExtensionContext.model is the SDK's current
1190
- // selection set by user settings or last-used). May be undefined on
1191
- // first boot before any model_select; that's fine, room_meta omits the
1192
- // field then.
1193
- const ctxModelName = ctx.model;
1194
- if (ctxModelName)
1195
- _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 */ }
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 broker_remote's sibling list in sync with
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
- // an owner relabeling a nickname. `_brokerRemote` may be null
1276
- // 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.
1277
1439
  onMembersChanged: (siblings) => {
1278
- _brokerRemote?.setSiblings(siblings);
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 (broker_remote needs the local Broker
1285
- // instance the leader hosts). Best-effort; failures log but don't break
1286
- // single-PC operation.
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 (!_sessionPeer)
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 = _sessionPeer !== null;
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 _sessionPeer.leave();
1536
+ await _meshNode.close();
1374
1537
  }
1375
1538
  catch { /* best-effort */ }
1376
- _sessionPeer = null;
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
- `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");
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 (_sessionPeer) {
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 SessionPeer({ sockPath: sock, name: agentName, auditPath: audit });
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) && _brokerRemote) {
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
- _brokerRemote.onLocalPeersChanged(local);
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
- // Anything else is a real agent-to-agent message. SessionPeer already
1839
- // correlated replies (env.re matched a pending request) before reaching
1840
- // here what arrives now is unsolicited and needs the LLM to react.
1841
- // Inject as a user message so the model sees it as a turn input. Include
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
- // Plan/25 Wave D: when this peer was promoted to leader by the failover,
1858
- // it now hosts a fresh `Broker` instance with no `RemoteRouter` attached.
1859
- // The previous broker_remote (on the dead leader) is gone with that
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.start();
1878
- _sessionPeer = peer;
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
- void _ensureBrokerRemote().catch((err) => {
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
- _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);
1940
2196
  _currentTurnId = msg.id;
1941
- _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
+ }
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 === "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") {
2040
2438
  const id = `sync_${ts}`;
2041
2439
  lastUserId = id;
2042
- 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 = {
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
- const text = _stringifyContent(m.content);
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
- 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()) {
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
- const edKp = await getOrCreateEd25519Keypair();
2204
- const sessionName = process.cwd().split("/").slice(-2).join("/");
2205
- const { url: relayUrl, source } = resolveRelayUrl();
2206
- const roomId = roomIdForCwd(process.cwd());
2207
- console.log(`[remote-pi] relay: ${relayUrl} (source: ${source}), room: ${roomId}`);
2208
- void cliArgs;
2209
- const stop = startQRRotation(edKp.publicKey, sessionName, roomId);
2210
- 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"));
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