@geohar/un-bien 0.9.0 → 0.14.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 (136) hide show
  1. package/README.md +99 -130
  2. package/dist/actions/handlers.js.map +1 -1
  3. package/dist/actions/registry.js.map +1 -1
  4. package/dist/bin/launcher.js.map +1 -1
  5. package/dist/commands/deps.d.ts +156 -0
  6. package/dist/commands/deps.js +2 -0
  7. package/dist/commands/deps.js.map +1 -0
  8. package/dist/commands/fork_link.d.ts +4 -0
  9. package/dist/commands/fork_link.js +45 -0
  10. package/dist/commands/fork_link.js.map +1 -0
  11. package/dist/commands/housekeeping.d.ts +34 -0
  12. package/dist/commands/housekeeping.js +275 -0
  13. package/dist/commands/housekeeping.js.map +1 -0
  14. package/dist/commands/info.d.ts +43 -0
  15. package/dist/commands/info.js +127 -0
  16. package/dist/commands/info.js.map +1 -0
  17. package/dist/commands/lifecycle.d.ts +45 -0
  18. package/dist/commands/lifecycle.js +708 -0
  19. package/dist/commands/lifecycle.js.map +1 -0
  20. package/dist/commands/pairing.d.ts +26 -0
  21. package/dist/commands/pairing.js +167 -0
  22. package/dist/commands/pairing.js.map +1 -0
  23. package/dist/commands/register.d.ts +12 -0
  24. package/dist/commands/register.js +255 -0
  25. package/dist/commands/register.js.map +1 -0
  26. package/dist/commands/relay.d.ts +28 -0
  27. package/dist/commands/relay.js +81 -0
  28. package/dist/commands/relay.js.map +1 -0
  29. package/dist/commands/session_ops.d.ts +52 -0
  30. package/dist/commands/session_ops.js +75 -0
  31. package/dist/commands/session_ops.js.map +1 -0
  32. package/dist/config.d.ts +2 -1
  33. package/dist/config.js.map +1 -1
  34. package/dist/daemon/install.d.ts +4 -3
  35. package/dist/daemon/install.js +7 -1
  36. package/dist/daemon/install.js.map +1 -1
  37. package/dist/enrich_tool_args.js.map +1 -1
  38. package/dist/extension_ui_bridge.js.map +1 -1
  39. package/dist/image_codec.js.map +1 -1
  40. package/dist/index.d.ts +32 -101
  41. package/dist/index.js +818 -2867
  42. package/dist/index.js.map +1 -1
  43. package/dist/launch.d.ts +9 -1
  44. package/dist/launch.js +39 -4
  45. package/dist/launch.js.map +1 -1
  46. package/dist/launcher/launcher.js +21 -2
  47. package/dist/launcher/launcher.js.map +1 -1
  48. package/dist/mcp/mesh_result.js.map +1 -1
  49. package/dist/mcp/mesh_server.js.map +1 -1
  50. package/dist/mesh/canonical.js +1 -1
  51. package/dist/mesh/canonical.js.map +1 -1
  52. package/dist/mesh/client.js.map +1 -1
  53. package/dist/mesh/encoding.js.map +1 -1
  54. package/dist/mesh/self_revoke.js.map +1 -1
  55. package/dist/mesh/siblings.js.map +1 -1
  56. package/dist/mesh/verify.js +1 -1
  57. package/dist/mesh/verify.js.map +1 -1
  58. package/dist/pairing/crypto.js +0 -1
  59. package/dist/pairing/crypto.js.map +1 -1
  60. package/dist/pairing/peer_trust.js.map +1 -1
  61. package/dist/pairing/qr.js.map +1 -1
  62. package/dist/pairing/storage.js +4 -3
  63. package/dist/pairing/storage.js.map +1 -1
  64. package/dist/panel_bridge.js +1 -0
  65. package/dist/panel_bridge.js.map +1 -1
  66. package/dist/paths.d.ts +22 -8
  67. package/dist/paths.js +37 -10
  68. package/dist/paths.js.map +1 -1
  69. package/dist/protocol/codec.js.map +1 -1
  70. package/dist/rooms.js +1 -4
  71. package/dist/rooms.js.map +1 -1
  72. package/dist/session/bridge.js.map +1 -1
  73. package/dist/session/broker.js.map +1 -1
  74. package/dist/session/broker_remote.js.map +1 -1
  75. package/dist/session/capabilities.d.ts +11 -0
  76. package/dist/session/capabilities.js +35 -0
  77. package/dist/session/capabilities.js.map +1 -0
  78. package/dist/session/cwd_lock.js +3 -3
  79. package/dist/session/cwd_lock.js.map +1 -1
  80. package/dist/session/debug_log.js +13 -3
  81. package/dist/session/debug_log.js.map +1 -1
  82. package/dist/session/envelope.js.map +1 -1
  83. package/dist/session/global_config.d.ts +1 -1
  84. package/dist/session/global_config.js +1 -1
  85. package/dist/session/global_config.js.map +1 -1
  86. package/dist/session/ipc.d.ts +2 -2
  87. package/dist/session/ipc.js.map +1 -1
  88. package/dist/session/leader_election.js.map +1 -1
  89. package/dist/session/local_config.d.ts +37 -0
  90. package/dist/session/local_config.js +45 -1
  91. package/dist/session/local_config.js.map +1 -1
  92. package/dist/session/mesh_node.d.ts +1 -1
  93. package/dist/session/mesh_node.js +1 -1
  94. package/dist/session/mesh_node.js.map +1 -1
  95. package/dist/session/peer.js.map +1 -1
  96. package/dist/session/peer_inventory.js.map +1 -1
  97. package/dist/session/peer_limits.js.map +1 -1
  98. package/dist/session/received_images.d.ts +63 -0
  99. package/dist/session/received_images.js +273 -0
  100. package/dist/session/received_images.js.map +1 -0
  101. package/dist/session/relay_lifecycle.d.ts +219 -0
  102. package/dist/session/relay_lifecycle.js +764 -0
  103. package/dist/session/relay_lifecycle.js.map +1 -0
  104. package/dist/session/rpc_envelope.d.ts +10 -0
  105. package/dist/session/rpc_envelope.js +9 -0
  106. package/dist/session/rpc_envelope.js.map +1 -1
  107. package/dist/session/rpc_handlers.d.ts +53 -0
  108. package/dist/session/rpc_handlers.js +268 -0
  109. package/dist/session/rpc_handlers.js.map +1 -0
  110. package/dist/session/rpc_inbound.d.ts +41 -6
  111. package/dist/session/rpc_inbound.js +55 -0
  112. package/dist/session/rpc_inbound.js.map +1 -1
  113. package/dist/session/setup_wizard.js.map +1 -1
  114. package/dist/session/tools.js.map +1 -1
  115. package/dist/session/wizard.js.map +1 -1
  116. package/dist/state_migration.d.ts +45 -0
  117. package/dist/state_migration.js +104 -0
  118. package/dist/state_migration.js.map +1 -0
  119. package/dist/subagent_rooms.d.ts +19 -0
  120. package/dist/subagent_rooms.js +146 -14
  121. package/dist/subagent_rooms.js.map +1 -1
  122. package/dist/test_hooks.d.ts +180 -0
  123. package/dist/test_hooks.js +98 -0
  124. package/dist/test_hooks.js.map +1 -0
  125. package/dist/transport/peer_channel.d.ts +11 -0
  126. package/dist/transport/peer_channel.js +34 -11
  127. package/dist/transport/peer_channel.js.map +1 -1
  128. package/dist/transport/pi_forward_client.js.map +1 -1
  129. package/dist/transport/relay_client.d.ts +5 -2
  130. package/dist/transport/relay_client.js +6 -3
  131. package/dist/transport/relay_client.js.map +1 -1
  132. package/dist/ui/footer.js.map +1 -1
  133. package/docs/daemon.md +160 -170
  134. package/package.json +1 -1
  135. package/service-templates/launchd.plist.template +5 -4
  136. package/service-templates/systemd.service.template +3 -2
package/dist/index.js CHANGED
@@ -30,54 +30,39 @@
30
30
  * for integration tests.
31
31
  */
32
32
  import { randomUUID } from "node:crypto";
33
- import { SettingsManager } from "@earendil-works/pi-coding-agent";
34
- import { buildQRUri, qrSession, renderQRAscii, clampPairTtlMs, TOKEN_TTL_MS, } from "./pairing/qr.js";
35
- import { addPeer, getOrCreateEd25519Keypair, describeIdentity, KeyringUnavailableError, PairedIdentityMissingError, listPeers, removePeer, snapshotOwnerPubkeys, conditionalRemovePeer, } from "./pairing/storage.js";
36
- import { MeshClient } from "./mesh/client.js";
37
- import { SelfRevoke } from "./mesh/self_revoke.js";
38
- import { RelayClient, RoomAlreadyOpenError } from "./transport/relay_client.js";
39
- import { PlainPeerChannel } from "./transport/peer_channel.js";
33
+ import { listPeers } from "./pairing/storage.js";
40
34
  import { createExtensionUiBridge, } from "./extension_ui_bridge.js";
41
35
  import { createPanelBridge } from "./panel_bridge.js";
42
36
  import { initSubagentRooms, subagentRoomsEnabled, } from "./subagent_rooms.js";
43
- import { createRpcEnvelope, isEnvelopeFrame, helloEnvelope, } from "./session/rpc_envelope.js";
44
- import { dispatchRpcCommand, } from "./session/rpc_inbound.js";
37
+ import { createRpcEnvelope, } from "./session/rpc_envelope.js";
38
+ import { dispatchRpcCommand } from "./session/rpc_inbound.js";
45
39
  import { envLog } from "./session/debug_log.js";
46
- import { roomIdFor, roomIdForSession } from "./rooms.js";
40
+ import { roomIdForSession } from "./rooms.js";
47
41
  import { registerAgentTools } from "./session/tools.js";
48
42
  import { formatPeerInventory } from "./session/peer_inventory.js";
49
- import { MeshNode } from "./session/mesh_node.js";
50
- import { wireFromModel, } from "./actions/handlers.js";
51
- import { ensureModelRegistry } from "./actions/registry.js";
52
- import { ensureGlobalDirs, LOCAL_SESSION_NAME, sessionAuditPath, sessionSockPath, skillsDir, } from "./session/global_config.js";
53
- import { acquireCwdLock } from "./session/cwd_lock.js";
54
- import { installService, uninstallService, linkCliBinaries, unlinkCliBinaries, } from "./daemon/install.js";
55
- import { defaultAgentName, effectiveAutoStartRelay, effectiveAllowRemoteLaunch, loadLocalConfig, localConfigExists, saveLocalConfig, } from "./session/local_config.js";
56
- import { runSetupWizard } from "./session/setup_wizard.js";
43
+ import { ensureGlobalDirs, LOCAL_SESSION_NAME, sessionSockPath, skillsDir, } from "./session/global_config.js";
44
+ import { effectiveAutoStartRelay, effectiveAllowRemoteLaunch, effectiveAllowRemoteTerminate, loadLocalConfig, localConfigExists, piSessionName, resolveAgentName, saveLocalConfig, } from "./session/local_config.js";
57
45
  import { updateFooter } from "./ui/footer.js";
58
- import { join, dirname, resolve } from "node:path";
59
46
  import { fileURLToPath } from "node:url";
60
- import { chmodSync, mkdirSync, copyFileSync, existsSync, unlinkSync, readFileSync, writeFileSync, realpathSync, } from "node:fs";
61
- import { createInterface } from "node:readline";
62
- import { spawnSync } from "node:child_process";
63
- import { hostname, tmpdir } from "node:os";
64
- import { resolveRelayUrl, loadConfig, saveConfig, isValidRelayUrl, isWebSocketScheme, toWebSocketUrl, } from "./config.js";
47
+ import { realpathSync } from "node:fs";
48
+ import { loadConfig, saveConfig, isValidRelayUrl, isWebSocketScheme, } from "./config.js";
65
49
  import { _expandTilde, _launchSession } from "./launch.js";
66
50
  import { _enrichToolArgs } from "./enrich_tool_args.js";
67
- import { IMAGE_PREVIEW_MIME, _imageCacheRootDir, _imageExtension, _safeFilenameToken, _safePreviewPath, _renderablePngPathFromImage, _decodeImagePayload, } from "./image_codec.js";
68
- import { _findKnownPeer, _inspectPeerRecord, _runtimeOwnerFingerprint, } from "./pairing/peer_trust.js";
69
- import { Box, Container, Image, Text } from "@earendil-works/pi-tui";
51
+ import { _inspectPeerRecord, _runtimeOwnerFingerprint, } from "./pairing/peer_trust.js";
52
+ import { _flushPendingReceivedImagePreviews, _isReceivedImageContextMessage, _registerReceivedImageRenderer, } from "./session/received_images.js";
53
+ import { _cmdRoot, _cmdStart, } from "./commands/lifecycle.js";
54
+ import { _cmdClaudeCli, _cmdInstall, _cmdUninstall, _deployAgentNetworkSkill, } from "./commands/housekeeping.js";
55
+ import { registerUnbienCommands } from "./commands/register.js";
56
+ import { takeForkLink } from "./commands/fork_link.js";
57
+ import { createTestHooks } from "./test_hooks.js";
58
+ import { createRpcHandlers, } from "./session/rpc_handlers.js";
59
+ import { CTRL_PREFIX, _anyPeerActive, _broadcastEnvelope, _broadcastEnvelopeFlushed, _controlCtx, _detachPeerChannel, _emitRelayState, _getReconnectTimer, _getRelayLifecycleGeneration, _goIdle, _handleControl as _handleControlImpl, _headlessUi, _installAutoListener, _onPeerDisconnect as _onPeerDisconnectImpl, _onRelayClose, _panelBroadcast, _relayStatus, _setRelayLifecycleGeneration, _uiBroadcast, } from "./session/relay_lifecycle.js";
60
+ // Relay-lifecycle seam re-exports: the control-channel sentinel + the
61
+ // connectivity type moved to ./session/relay_lifecycle.ts; these keep the
62
+ // exact export surface (tests import CTRL_PREFIX from here).
63
+ export { CTRL_PREFIX };
70
64
  let _state = "idle";
71
65
  let _relay = null;
72
- /** Last `RelayConnectivity` emitted, for change-dedup. Starts "disconnected"
73
- * (the process boots with the relay down). */
74
- let _lastRelayStatus = null;
75
- /** Sentinel prefix for a transparent control message an RPC client sends on the
76
- * `prompt` channel (stdin). The `input` hook intercepts it, runs the action,
77
- * and swallows it (`action:"handled"`) so it never becomes an LLM turn or a
78
- * transcript entry. Starts with NUL so it can't collide with real user input
79
- * and doesn't begin with "/" (which would route to the command parser). */
80
- export const CTRL_PREFIX = "\x00un-bien-ctrl:";
81
66
  let _relayUrl = null; // URL used by current _relay connection
82
67
  /**
83
68
  * Owners currently connected via the relay. Key = app peer pubkey (Ed25519,
@@ -93,16 +78,24 @@ let _relayUrl = null; // URL used by current _relay connection
93
78
  */
94
79
  const _activePeers = new Map();
95
80
  let _peerShort = ""; // shortid of the most recently attached peer (UX hint only)
96
- const UNBIEN_RECEIVED_IMAGE_TYPE = "un-bien:received-image";
97
- const _pendingReceivedImagePreviews = [];
98
81
  let _myRoomId = null; // this Pi's room id (derived from the session id)
99
- /** THE App<->Pi room id for the current chat session. Prefers the STABLE pi
100
- * session id (durable across resume) so the room can't drift when the assigned
101
- * name changes on reconnect; falls back to the legacy (cwd, name) derivation
102
- * only when no session id is available yet (pre-sessionManager edge). */
103
- function _deriveRoomId(cwd, name) {
82
+ /** True when a relay start was DEFERRED because no session id existed yet
83
+ * (design 01M1CAW0). The root session_start handler re-runs the start once
84
+ * the session id becomes available. */
85
+ let _relayStartDeferred = false;
86
+ /** THE App<->Pi room id for the current chat session: ALWAYS derived from the
87
+ * STABLE pi session id (durable across resume) so the room can't drift when
88
+ * the assigned name changes on reconnect. Returns null when no session id
89
+ * exists yet — the legacy (cwd, name) fallback is RETIRED (design 01M1CAW0):
90
+ * it was identical for same-cwd processes, so a pre-session subprocess could
91
+ * announce the SAME room an earlier session occupies (and a parent's keeper
92
+ * then stamped set-once parentage onto that shared room). Callers must SKIP
93
+ * the room announce/join on null — never guess a cwd-derived room id.
94
+ * `cwd`/`name` are still accepted because callers derive them for room_meta
95
+ * labels, but they no longer key the room. */
96
+ function _deriveRoomId(_cwd, _name) {
104
97
  const sid = _rootState().sessionManager?.getSessionId();
105
- return sid ? roomIdForSession(sid) : roomIdFor(cwd, name);
98
+ return sid ? roomIdForSession(sid) : null;
106
99
  }
107
100
  // Plan/28 Wave D.1: `thinking` published alongside `model` so the app's
108
101
  // Quick Actions sheet hydrates the thinking segmented control on first
@@ -211,193 +204,6 @@ function _publishWorking(working) {
211
204
  });
212
205
  }
213
206
  }
214
- function _sendReceivedImagePreviewNow(details) {
215
- if (!_pi)
216
- return;
217
- try {
218
- _pi.sendMessage({
219
- customType: UNBIEN_RECEIVED_IMAGE_TYPE,
220
- content: "",
221
- display: true,
222
- details,
223
- });
224
- }
225
- catch {
226
- // TUI preview is best-effort; skip on failure.
227
- }
228
- }
229
- function _shouldDeferReceivedImagePreview() {
230
- return _rootState().turnId !== null || _myRoomMeta?.working === true;
231
- }
232
- function _sendReceivedImagePreview(details, delivery = "immediate") {
233
- if (delivery === "defer" || _shouldDeferReceivedImagePreview()) {
234
- _pendingReceivedImagePreviews.push(details);
235
- return;
236
- }
237
- _sendReceivedImagePreviewNow(details);
238
- }
239
- function _flushPendingReceivedImagePreviews() {
240
- if (_pendingReceivedImagePreviews.length === 0)
241
- return;
242
- const pending = _pendingReceivedImagePreviews.splice(0);
243
- for (const details of pending)
244
- _sendReceivedImagePreviewNow(details);
245
- }
246
- async function _collectReceivedImagePreviews(msg) {
247
- if (!msg.images || msg.images.length === 0)
248
- return [];
249
- const previews = [];
250
- const text = typeof msg.text === "string" ? msg.text : "";
251
- const dir = _imageCacheRootDir();
252
- for (let i = 0; i < msg.images.length; i += 1) {
253
- const image = msg.images[i];
254
- const mime = typeof image?.mime === "string" ? image.mime : "unknown";
255
- if (!image || typeof image.data !== "string") {
256
- console.error(`[un-bien] malformed image in message ${msg.id} index=${i}`);
257
- previews.push({
258
- messageId: msg.id,
259
- index: i,
260
- mime,
261
- ...(text ? { text } : {}),
262
- error: "malformed image payload",
263
- reason: "missing mime/data payload fields",
264
- });
265
- continue;
266
- }
267
- const decoded = _decodeImagePayload(image.data, image.mime);
268
- if (!decoded.ok) {
269
- console.error(`[un-bien] skipped image id=${msg.id} index=${i}: ${decoded.reason}`);
270
- previews.push({
271
- messageId: msg.id,
272
- index: i,
273
- mime: image.mime,
274
- ...(text ? { text } : {}),
275
- error: "invalid image payload",
276
- reason: decoded.reason,
277
- });
278
- continue;
279
- }
280
- const ext = _imageExtension(image.mime);
281
- if (!ext) {
282
- console.error(`[un-bien] unsupported image mime in message ${msg.id} index=${i}: ${image.mime}`);
283
- previews.push({
284
- messageId: msg.id,
285
- index: i,
286
- mime: image.mime,
287
- ...(text ? { text } : {}),
288
- error: "invalid image payload",
289
- reason: `unsupported mime: ${image.mime}`,
290
- });
291
- continue;
292
- }
293
- const filename = `${_safeFilenameToken(msg.id)}-${i}.${ext}`;
294
- const path = join(dir, filename);
295
- try {
296
- writeFileSync(path, decoded.decoded, { mode: 0o600 });
297
- try {
298
- chmodSync(path, 0o600);
299
- }
300
- catch {
301
- /* best-effort permission hardening */
302
- }
303
- const previewPath = image.mime === IMAGE_PREVIEW_MIME
304
- ? undefined
305
- : await _renderablePngPathFromImage(image.data, image.mime, _safePreviewPath(dir, msg.id, i));
306
- previews.push({
307
- messageId: msg.id,
308
- index: i,
309
- mime: image.mime,
310
- size: decoded.size,
311
- path,
312
- ...(previewPath ? { previewPath } : {}),
313
- ...(text ? { text } : {}),
314
- });
315
- }
316
- catch (error) {
317
- const detail = error instanceof Error ? error.message : String(error);
318
- console.error(`[un-bien] failed saving image id=${msg.id} index=${i}: ${detail}`);
319
- previews.push({
320
- messageId: msg.id,
321
- index: i,
322
- mime: image.mime,
323
- ...(text ? { text } : {}),
324
- path,
325
- error: "failed to save image",
326
- reason: detail,
327
- });
328
- }
329
- }
330
- return previews;
331
- }
332
- async function _emitReceivedImagePreviews(msg, delivery = "immediate") {
333
- const previews = await _collectReceivedImagePreviews(msg);
334
- for (const preview of previews)
335
- _sendReceivedImagePreview(preview, delivery);
336
- }
337
- function _registerReceivedImageRenderer(pi) {
338
- pi.registerMessageRenderer(UNBIEN_RECEIVED_IMAGE_TYPE, (message, _options, theme) => {
339
- const details = (message.details ?? {});
340
- const path = typeof details.path === "string" ? details.path : "";
341
- const previewPath = typeof details.previewPath === "string" ? details.previewPath : "";
342
- const mime = typeof details.mime === "string"
343
- ? details.mime
344
- : "application/octet-stream";
345
- const inlineImagePath = previewPath.length > 0
346
- ? previewPath
347
- : mime === IMAGE_PREVIEW_MIME
348
- ? path
349
- : "";
350
- const size = typeof details.size === "number" ? details.size : undefined;
351
- const index = typeof details.index === "number" ? details.index : undefined;
352
- const text = typeof details.text === "string" ? details.text.trim() : "";
353
- const messageId = typeof details.messageId === "string" ? details.messageId : "unknown";
354
- const error = typeof details.error === "string" ? details.error : undefined;
355
- const reason = typeof details.reason === "string" ? details.reason : undefined;
356
- const label = `📷 Photo from Android (${messageId}${index === undefined ? "" : ` #${index}`})`;
357
- const lines = [
358
- theme.fg("customMessageLabel", label),
359
- theme.fg("customMessageText", `Saved: ${path || "(not saved)"}`),
360
- ];
361
- if (size !== undefined)
362
- lines.push(theme.fg("customMessageText", `Size: ${size} bytes`));
363
- if (mime)
364
- lines.push(theme.fg("customMessageText", `MIME: ${mime}`));
365
- if (error)
366
- lines.push(theme.fg("customMessageText", `Error: ${error}`));
367
- if (reason)
368
- lines.push(theme.fg("customMessageText", `Reason: ${reason}`));
369
- if (text)
370
- lines.push(theme.fg("customMessageText", `Text: ${text}`));
371
- const container = new Container();
372
- const metadata = new Box(1, 1, (line) => theme.bg("customMessageBg", line));
373
- metadata.addChild(new Text(lines.join("\n")));
374
- container.addChild(metadata);
375
- if (inlineImagePath && !error) {
376
- try {
377
- const imageData = readFileSync(inlineImagePath).toString("base64");
378
- if (imageData.length > 0) {
379
- const image = new Image(imageData, IMAGE_PREVIEW_MIME, {
380
- fallbackColor: (str) => theme.fg("customMessageText", str),
381
- });
382
- // Keep Kitty image rows out of Box padding/background so pi-tui can
383
- // preserve the empty reserved rows that make inline images visible.
384
- container.addChild(image);
385
- }
386
- }
387
- catch {
388
- // Keep the metadata-only fallback on any IO/terminal issue.
389
- }
390
- }
391
- return container;
392
- });
393
- }
394
- function _isReceivedImageContextMessage(message) {
395
- return (typeof message === "object" &&
396
- message !== null &&
397
- message.role === "custom" &&
398
- message.customType ===
399
- UNBIEN_RECEIVED_IMAGE_TYPE);
400
- }
401
207
  /**
402
208
  * Issue #105 — pure-data events must not reach the model.
403
209
  *
@@ -430,57 +236,6 @@ function _filterInternalMessagesFromContext(messages) {
430
236
  !_isPureDataContextMessage(message))
431
237
  : [];
432
238
  }
433
- function _contentFromUserMessage(msg) {
434
- return msg.images && msg.images.length > 0
435
- ? [
436
- ...msg.images.map((img) => ({
437
- type: "image",
438
- data: img.data,
439
- mimeType: img.mime,
440
- })),
441
- { type: "text", text: msg.text },
442
- ]
443
- : msg.text;
444
- }
445
- async function _deliverImageUserMessage(sender, msg, shouldSteer) {
446
- const previewDelivery = shouldSteer || _rootState().turnId !== null || _myRoomMeta?.working === true
447
- ? "defer"
448
- : "immediate";
449
- const emitPreview = async () => {
450
- try {
451
- await _emitReceivedImagePreviews(msg, previewDelivery);
452
- }
453
- catch (error) {
454
- const detail = error instanceof Error ? error.message : String(error);
455
- console.error(`[un-bien] failed emitting image preview id=${msg.id}: ${detail}`);
456
- }
457
- };
458
- if (previewDelivery === "immediate") {
459
- await emitPreview();
460
- }
461
- else {
462
- void emitPreview().finally(() => {
463
- if (!_shouldDeferReceivedImagePreview())
464
- _flushPendingReceivedImagePreviews();
465
- });
466
- }
467
- const previousTurnId = _rootState().turnId;
468
- const seededTurnId = !shouldSteer || _rootState().turnId === null;
469
- if (seededTurnId)
470
- _rootState().turnId = msg.id;
471
- const wake = _wakeAgent(_contentFromUserMessage(msg), `app user_message id=${msg.id} (+${msg.images?.length ?? 0} image)`, "steer");
472
- if (!wake.ok) {
473
- if (seededTurnId)
474
- _rootState().turnId = previousTurnId;
475
- sender.send({
476
- type: "error",
477
- code: "internal_error",
478
- in_reply_to: msg.id,
479
- message: `Agent rejected incoming message: ${wake.detail}`,
480
- });
481
- return;
482
- }
483
- }
484
239
  // ── Cross-PC mesh wiring (plan/25 Wave B/C) ───────────────────────────────────
485
240
  /**
486
241
  * Hand the live relay to MeshNode so it can bring up the cross-PC bridge
@@ -513,13 +268,14 @@ function _attachBridgeIfReady() {
513
268
  });
514
269
  }
515
270
  /**
516
- * Prefer an explicit ctx, then the always-fresh session_start ctx, then the
517
- * last command ctx. Relay/async paths must not rely on `_lastCtx` alone —
518
- * the SDK marks captured command ctxs stale after session replacement.
271
+ * Prefer an explicit ctx, else the always-fresh session_start ctx
272
+ * (`_lastEventCtx`, re-captured on every session_start so it is never a stale
273
+ * capture). Relay/async paths reach the ctx-only surfaces (ui/abort/compact)
274
+ * through this, since they fire outside any live pi event.
519
275
  * @see https://github.com/jacobaraujo7/remote_pi/issues/55
520
276
  */
521
277
  function _liveCtx(preferred) {
522
- return preferred ?? _lastEventCtx ?? _lastCtx ?? null;
278
+ return preferred ?? _lastEventCtx ?? null;
523
279
  }
524
280
  /**
525
281
  * Read `ctx.ui` without letting a stale-ctx getter become an uncaughtException.
@@ -573,7 +329,7 @@ function _refreshFooter(ctx) {
573
329
  // `devicePaired` now reflects "any owner currently attached" — picks one
574
330
  // shortid representatively (multi-owner UX detail surfaces in the
575
331
  // `/unbien status` line, not the footer slot).
576
- devicePaired: _anyPeerActive() ? _peerShort : undefined,
332
+ devicePaired: _anyPeerActive(relayDeps) ? _peerShort : undefined,
577
333
  hasPairings: _hasGlobalPairings,
578
334
  agentName: _meshNode?.name(),
579
335
  };
@@ -596,155 +352,6 @@ let _pendingMeshMessages = [];
596
352
  // agent-run active/generation now live PER-SESSION in _stateFor(sid).agentRun;
597
353
  // mesh delivery targets the ROOT run, so the drain reads _rootState().agentRun.
598
354
  let _meshDrainScheduled = false;
599
- /** Test-only override of the message buffer. */
600
- /**
601
- * Test-only: emulate what `/unbien` does on the returning-user path
602
- * (join the local mesh, then start the relay) without touching the FS for
603
- * a `localConfigExists()` lookup. Lets tests bring the relay up without
604
- * mocking the wizard or the local config storage.
605
- *
606
- * Typed loosely to accept any ctx shape with `ui.notify` + `cwd` — the
607
- * unit tests use minimal mocks that don't satisfy the full
608
- * `ExtensionContext` interface.
609
- */
610
- export async function _connectForTest(ctx) {
611
- const real = ctx;
612
- await _cmdJoin(real);
613
- await _cmdStart(real);
614
- }
615
- /** Test-only: tear everything down (mirrors `/unbien stop`). */
616
- export async function _stopForTest(ctx) {
617
- await _cmdStop(ctx);
618
- }
619
- /** Test-only: read/reset the `_disposed` flag. Production clears it only when
620
- * a host reuses this module for a replacement session; tests share one module
621
- * across cases, so they also reset it to avoid cross-test pollution. */
622
- export function _getDisposedForTest() {
623
- return _disposed;
624
- }
625
- export function _setDisposedForTest(v) {
626
- _disposed = v;
627
- }
628
- /** Test-only: reset the once-per-session auto-init gate so session_start re-runs it. */
629
- export function _resetAutoInitedForTest() {
630
- _autoInited = false;
631
- }
632
- /** Test-only: clear the globalThis panel/ui bridge ownership so each fresh
633
- * `extension(pi)` in a shared test process can (re)claim and rebuild bridges. */
634
- export function _resetBridgeOwnersForTest() {
635
- const g = globalThis;
636
- delete g[_ROOT_SESSION_OWNER_KEY];
637
- _panelBridge?.dispose();
638
- _panelBridge = null;
639
- _rpcEnvelope?.dispose();
640
- _rpcEnvelope = null;
641
- _subagentRooms?.dispose();
642
- _subagentRooms = null;
643
- _extensionUiBridge?.dispose();
644
- _extensionUiBridge = null;
645
- }
646
- /** Test-only: reset the keyed per-session state at a TEST BOUNDARY (beforeEach).
647
- * Must NOT be folded into _resetBridgeOwnersForTest — that fires mid-test on
648
- * every captureEventHandler call and would wipe state a test seeds across two
649
- * captures (e.g. input seeds turnId, message_update reads it). */
650
- export function _resetSessionsForTest() {
651
- _sessions.clear();
652
- _rootSessionId = null;
653
- }
654
- /** Test-only: set the auto-init gate for lifecycle replacement tests. */
655
- export function _setAutoInitedForTest(value) {
656
- _autoInited = value;
657
- }
658
- /** Test-only: true when this instance holds a live local-mesh node. */
659
- export function _hasMeshNodeForTest() {
660
- return _meshNode !== null;
661
- }
662
- /** Test-only: drive the current real SelfRevoke producer through one sweep. */
663
- export async function _checkSelfRevokeForTest() {
664
- await _selfRevoke?.checkOnce();
665
- }
666
- /** Test-only: the effective (possibly `#N`-suffixed) name the cwd-lock reserved. */
667
- export function _getLockedNameForTest() {
668
- return _lockedName;
669
- }
670
- /** Test-only: release + clear the cwd lock (the lock normally survives stop). */
671
- export function _resetCwdLockForTest() {
672
- try {
673
- _cwdLock?.release();
674
- }
675
- catch {
676
- /* ignored */
677
- }
678
- _cwdLock = null;
679
- _lockedName = null;
680
- }
681
- /**
682
- * Test-only: relay-only startup, no UDS mesh join. Replaces the old
683
- * `unbien relay start` handler that some tests captured to bring up
684
- * the relay in isolation (e.g. ping/pong tests that don't care about the
685
- * agent-network broker).
686
- */
687
- export async function _startRelayForTest(ctx) {
688
- await _cmdStart(ctx);
689
- }
690
- /** Test-only: public marker for canceled-keypair cache regression checks. */
691
- export function _getCachedPublicKeyForTest() {
692
- return _cachedEd25519
693
- ? Buffer.from(_cachedEd25519.publicKey).toString("base64")
694
- : null;
695
- }
696
- /** Test-only override of session started timestamp. */
697
- export function _setSessionStartedAtForTest(ts) {
698
- _sessionStartedAt = ts;
699
- }
700
- /** Test-only: reset the cached model name (between tests). */
701
- export function _setCurrentModelForTest(name) {
702
- _currentModel = name;
703
- }
704
- /** Test-only: read the active turn id used for plain `cancel` routing. */
705
- export function _getCurrentTurnIdForTest() {
706
- return _rootState().turnId;
707
- }
708
- /** Test-only: override the bound AgentSession so a spy can capture the
709
- * content handed to `sendUserMessage` (plan/30 multimodal ingest). */
710
- export function _setPiForTest(pi) {
711
- _pi = pi;
712
- }
713
- /**
714
- * Persist a model change to the PROJECT settings (`<cwd>/.pi/settings.json`) so
715
- * a model picked from the app survives a Pi/daemon restart. `pi.setModel` only
716
- * sets the LIVE model — on the next restart a fresh session reads the saved
717
- * default and reverts (the reported bug). We write the PROJECT scope, NOT
718
- * global, deliberately: the SDK merges global←project with PROJECT winning
719
- * (`SettingsManager`), so a folder that already has a project default (every
720
- * created daemon does) would shadow a global write like the TUI's. Project
721
- * scope is also correct for a fleet — each daemon keeps its own model rather
722
- * than leaking one default globally.
723
- *
724
- * Read-merge-write + best-effort: preserves other keys and never throws (a
725
- * settings write must not fail the live model change, which already applied).
726
- */
727
- function _persistModelDefault(provider, modelId) {
728
- try {
729
- const path = join(process.cwd(), ".pi", "settings.json");
730
- let obj = {};
731
- try {
732
- const parsed = JSON.parse(readFileSync(path, "utf8"));
733
- if (parsed && typeof parsed === "object")
734
- obj = parsed;
735
- }
736
- catch {
737
- /* no existing/parseable file → start fresh */
738
- }
739
- obj["defaultProvider"] = provider;
740
- obj["defaultModel"] = modelId;
741
- mkdirSync(dirname(path), { recursive: true });
742
- writeFileSync(path, JSON.stringify(obj, null, 2));
743
- }
744
- catch {
745
- /* best-effort — model change already applied live */
746
- }
747
- }
748
355
  const _sessions = new Map();
749
356
  // The session bound to the app room. null until the ROOT session_start fires;
750
357
  // while null, everything is treated as root (single-session / test harness).
@@ -820,29 +427,14 @@ let _cwdLock = null;
820
427
  // registers under this name; the broker confirms it (and may bump it again under
821
428
  // a live race). Null until the lock is acquired.
822
429
  let _lockedName = null;
823
- // ── Relay reconnect state ─────────────────────────────────────────────────────
824
- // Backoffs in ms: 1s, 2s, 5s, 10s, 30s, then stays at 30s.
825
- const RECONNECT_BACKOFFS_MS = [1_000, 2_000, 5_000, 10_000, 30_000];
826
- let _reconnectTimer = null;
827
- let _reconnectAttempt = 0;
828
- // Every initial connect/reconnect candidate captures this generation. Stop,
829
- // relay-off, and an unexpected close invalidate older async continuations.
830
- let _relayLifecycleGeneration = 0;
831
430
  // Root startup has pre-candidate awaits (cwd lock, wizard) that relay/mesh
832
431
  // generations cannot safely represent: child startup intentionally advances
833
432
  // those generations. Stop/off/session replacement advance this separate epoch
834
433
  // so a queued root can never regain authority by creating a newer child.
835
434
  let _rootLifecycleGeneration = 0;
836
- // Coalesces concurrent `/unbien` startup paths inside ONE extension instance.
837
- // Separate Pi processes still keep the existing #N behavior via the cwd lock.
838
- let _cmdRootInFlight = null;
839
435
  function _isCurrentRootLifecycle(generation) {
840
436
  return !_disposed && generation === _rootLifecycleGeneration;
841
437
  }
842
- /** Test-only: exposes pending reconnect timer state. */
843
- export function _hasPendingReconnect() {
844
- return _reconnectTimer !== null;
845
- }
846
438
  /**
847
439
  * Public state-snapshot helper. Returns the derived UX state, not the raw
848
440
  * `_state` enum: the W2D refactor collapsed the internal machine to
@@ -855,19 +447,6 @@ export function _getState() {
855
447
  return "idle";
856
448
  return _activePeers.size > 0 ? "paired" : "started";
857
449
  }
858
- /** Test-only: number of owners currently attached via PlainPeerChannel. */
859
- export function _getActivePeerCountForTest() {
860
- return _activePeers.size;
861
- }
862
- /** Test-only: true if a specific peer (base64 std) has an attached channel. */
863
- export function _hasActivePeerForTest(appPeerIdStd) {
864
- return _activePeers.has(appPeerIdStd);
865
- }
866
- // ── Multi-channel helpers ─────────────────────────────────────────────────────
867
- /** Returns true when at least one owner is attached. Derived `paired` UX. */
868
- function _anyPeerActive() {
869
- return _activePeers.size > 0;
870
- }
871
450
  // ── Hidden e2e UI test harness (dev-only, undocumented) ───────────────────
872
451
  // Broadcasts CANNED frames to paired apps so the app UI can be exercised
873
452
  // end-to-end without a real agent turn. For plan/subagents/rich-ask it EMITS the
@@ -880,6 +459,7 @@ const _TEST_SVG_B64 = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg" viewB
880
459
  ' font-family="sans-serif">un-bien</text></svg>').toString("base64");
881
460
  function _emitTestBus(channel, data) {
882
461
  try {
462
+ ;
883
463
  _pi?.events?.emit(channel, data);
884
464
  }
885
465
  catch {
@@ -892,7 +472,7 @@ function _runTestScenario(scenario) {
892
472
  const id = `test-${Date.now()}`;
893
473
  switch (s) {
894
474
  case "ask-select":
895
- _broadcastEnvelope({
475
+ _broadcastEnvelope(relayDeps, {
896
476
  rpc: {
897
477
  type: "extension_ui_request",
898
478
  id,
@@ -903,7 +483,7 @@ function _runTestScenario(scenario) {
903
483
  });
904
484
  return "sent ask-select";
905
485
  case "ask-confirm":
906
- _broadcastEnvelope({
486
+ _broadcastEnvelope(relayDeps, {
907
487
  rpc: {
908
488
  type: "extension_ui_request",
909
489
  id,
@@ -914,7 +494,7 @@ function _runTestScenario(scenario) {
914
494
  });
915
495
  return "sent ask-confirm";
916
496
  case "ask-input":
917
- _broadcastEnvelope({
497
+ _broadcastEnvelope(relayDeps, {
918
498
  rpc: {
919
499
  type: "extension_ui_request",
920
500
  id,
@@ -925,7 +505,7 @@ function _runTestScenario(scenario) {
925
505
  });
926
506
  return "sent ask-input";
927
507
  case "ask-editor":
928
- _broadcastEnvelope({
508
+ _broadcastEnvelope(relayDeps, {
929
509
  rpc: {
930
510
  type: "extension_ui_request",
931
511
  id,
@@ -936,7 +516,7 @@ function _runTestScenario(scenario) {
936
516
  });
937
517
  return "sent ask-editor";
938
518
  case "ask-notify":
939
- _broadcastEnvelope({
519
+ _broadcastEnvelope(relayDeps, {
940
520
  rpc: {
941
521
  type: "extension_ui_request",
942
522
  id,
@@ -1020,8 +600,8 @@ function _runTestScenario(scenario) {
1020
600
  // `{content:[{type:"image",data,mimeType}]}`) and renders them below the
1021
601
  // card (WireImageView -> SVGImageView). Deliver the SVG that way.
1022
602
  const tc = `tc-svg-${Date.now()}`;
1023
- _broadcastEnvelope({ rpc: { type: "turn_start" } });
1024
- _broadcastEnvelope({
603
+ _broadcastEnvelope(relayDeps, { rpc: { type: "turn_start" } });
604
+ _broadcastEnvelope(relayDeps, {
1025
605
  rpc: {
1026
606
  type: "tool_execution_start",
1027
607
  toolCallId: tc,
@@ -1029,7 +609,7 @@ function _runTestScenario(scenario) {
1029
609
  args: { note: "test svg" },
1030
610
  },
1031
611
  });
1032
- _broadcastEnvelope({
612
+ _broadcastEnvelope(relayDeps, {
1033
613
  rpc: {
1034
614
  type: "tool_execution_end",
1035
615
  toolCallId: tc,
@@ -1046,13 +626,13 @@ function _runTestScenario(scenario) {
1046
626
  isError: false,
1047
627
  },
1048
628
  });
1049
- _broadcastEnvelope({ rpc: { type: "agent_settled" } });
629
+ _broadcastEnvelope(relayDeps, { rpc: { type: "agent_settled" } });
1050
630
  return "sent svg (envelope tool_execution_end + image)";
1051
631
  }
1052
632
  case "tool": {
1053
633
  const tc = `tc-${Date.now()}`;
1054
- _broadcastEnvelope({ rpc: { type: "turn_start" } });
1055
- _broadcastEnvelope({
634
+ _broadcastEnvelope(relayDeps, { rpc: { type: "turn_start" } });
635
+ _broadcastEnvelope(relayDeps, {
1056
636
  rpc: {
1057
637
  type: "tool_execution_start",
1058
638
  toolCallId: tc,
@@ -1060,7 +640,7 @@ function _runTestScenario(scenario) {
1060
640
  args: { command: "echo hello" },
1061
641
  },
1062
642
  });
1063
- _broadcastEnvelope({
643
+ _broadcastEnvelope(relayDeps, {
1064
644
  rpc: {
1065
645
  type: "tool_execution_end",
1066
646
  toolCallId: tc,
@@ -1068,7 +648,7 @@ function _runTestScenario(scenario) {
1068
648
  isError: false,
1069
649
  },
1070
650
  });
1071
- _broadcastEnvelope({ rpc: { type: "agent_settled" } });
651
+ _broadcastEnvelope(relayDeps, { rpc: { type: "agent_settled" } });
1072
652
  return "sent tool pair (envelope)";
1073
653
  }
1074
654
  case "diff": {
@@ -1086,8 +666,8 @@ function _runTestScenario(scenario) {
1086
666
  ],
1087
667
  },
1088
668
  ];
1089
- _broadcastEnvelope({ rpc: { type: "turn_start" } });
1090
- _broadcastEnvelope({
669
+ _broadcastEnvelope(relayDeps, { rpc: { type: "turn_start" } });
670
+ _broadcastEnvelope(relayDeps, {
1091
671
  rpc: {
1092
672
  type: "tool_execution_start",
1093
673
  toolCallId: tc,
@@ -1100,7 +680,7 @@ function _runTestScenario(scenario) {
1100
680
  },
1101
681
  aux: { hunks },
1102
682
  });
1103
- _broadcastEnvelope({
683
+ _broadcastEnvelope(relayDeps, {
1104
684
  rpc: {
1105
685
  type: "tool_execution_end",
1106
686
  toolCallId: tc,
@@ -1108,15 +688,15 @@ function _runTestScenario(scenario) {
1108
688
  isError: false,
1109
689
  },
1110
690
  });
1111
- _broadcastEnvelope({ rpc: { type: "agent_settled" } });
691
+ _broadcastEnvelope(relayDeps, { rpc: { type: "agent_settled" } });
1112
692
  return "sent diff (edit + aux hunks — shows the Diff⇄Content toggle)";
1113
693
  }
1114
694
  case "code-shell": {
1115
695
  // bash-family result → the app classifies it into a `code` block (lang
1116
696
  // shell), syntax-highlighted. OUTPUT is app-side now — no aux stamped.
1117
697
  const tc = `tc-sh-${Date.now()}`;
1118
- _broadcastEnvelope({ rpc: { type: "turn_start" } });
1119
- _broadcastEnvelope({
698
+ _broadcastEnvelope(relayDeps, { rpc: { type: "turn_start" } });
699
+ _broadcastEnvelope(relayDeps, {
1120
700
  rpc: {
1121
701
  type: "tool_execution_start",
1122
702
  toolCallId: tc,
@@ -1124,7 +704,7 @@ function _runTestScenario(scenario) {
1124
704
  args: { command: "ls -la" },
1125
705
  },
1126
706
  });
1127
- _broadcastEnvelope({
707
+ _broadcastEnvelope(relayDeps, {
1128
708
  rpc: {
1129
709
  type: "tool_execution_end",
1130
710
  toolCallId: tc,
@@ -1132,15 +712,15 @@ function _runTestScenario(scenario) {
1132
712
  isError: false,
1133
713
  },
1134
714
  });
1135
- _broadcastEnvelope({ rpc: { type: "agent_settled" } });
715
+ _broadcastEnvelope(relayDeps, { rpc: { type: "agent_settled" } });
1136
716
  return "sent code-shell (bash output → code block, lang shell)";
1137
717
  }
1138
718
  case "code-file": {
1139
719
  // read-family with a *.swift path → `code` block, lang inferred from the
1140
720
  // extension (swift) and highlighted.
1141
721
  const tc = `tc-rd-${Date.now()}`;
1142
- _broadcastEnvelope({ rpc: { type: "turn_start" } });
1143
- _broadcastEnvelope({
722
+ _broadcastEnvelope(relayDeps, { rpc: { type: "turn_start" } });
723
+ _broadcastEnvelope(relayDeps, {
1144
724
  rpc: {
1145
725
  type: "tool_execution_start",
1146
726
  toolCallId: tc,
@@ -1148,7 +728,7 @@ function _runTestScenario(scenario) {
1148
728
  args: { path: "/src/Greeter.swift" },
1149
729
  },
1150
730
  });
1151
- _broadcastEnvelope({
731
+ _broadcastEnvelope(relayDeps, {
1152
732
  rpc: {
1153
733
  type: "tool_execution_end",
1154
734
  toolCallId: tc,
@@ -1156,15 +736,15 @@ function _runTestScenario(scenario) {
1156
736
  isError: false,
1157
737
  },
1158
738
  });
1159
- _broadcastEnvelope({ rpc: { type: "agent_settled" } });
739
+ _broadcastEnvelope(relayDeps, { rpc: { type: "agent_settled" } });
1160
740
  return "sent code-file (read .swift → highlighted code block)";
1161
741
  }
1162
742
  case "diff-output": {
1163
743
  // A tool whose RESULT already embeds a unified diff → the app parses it
1164
744
  // into a `diff` block (re-reading persisted text; replay-safe).
1165
745
  const tc = `tc-do-${Date.now()}`;
1166
- _broadcastEnvelope({ rpc: { type: "turn_start" } });
1167
- _broadcastEnvelope({
746
+ _broadcastEnvelope(relayDeps, { rpc: { type: "turn_start" } });
747
+ _broadcastEnvelope(relayDeps, {
1168
748
  rpc: {
1169
749
  type: "tool_execution_start",
1170
750
  toolCallId: tc,
@@ -1172,7 +752,7 @@ function _runTestScenario(scenario) {
1172
752
  args: { command: "git diff" },
1173
753
  },
1174
754
  });
1175
- _broadcastEnvelope({
755
+ _broadcastEnvelope(relayDeps, {
1176
756
  rpc: {
1177
757
  type: "tool_execution_end",
1178
758
  toolCallId: tc,
@@ -1180,7 +760,7 @@ function _runTestScenario(scenario) {
1180
760
  isError: false,
1181
761
  },
1182
762
  });
1183
- _broadcastEnvelope({ rpc: { type: "agent_settled" } });
763
+ _broadcastEnvelope(relayDeps, { rpc: { type: "agent_settled" } });
1184
764
  return "sent diff-output (result embeds a unified diff → diff block)";
1185
765
  }
1186
766
  case "write": {
@@ -1188,8 +768,8 @@ function _runTestScenario(scenario) {
1188
768
  // card shows the Content view (new text as a code block, replay-safe).
1189
769
  const tc = `tc-wr-${Date.now()}`;
1190
770
  const content = "export function add(a: number, b: number): number {\n return a + b;\n}";
1191
- _broadcastEnvelope({ rpc: { type: "turn_start" } });
1192
- _broadcastEnvelope({
771
+ _broadcastEnvelope(relayDeps, { rpc: { type: "turn_start" } });
772
+ _broadcastEnvelope(relayDeps, {
1193
773
  rpc: {
1194
774
  type: "tool_execution_start",
1195
775
  toolCallId: tc,
@@ -1197,7 +777,7 @@ function _runTestScenario(scenario) {
1197
777
  args: { path: "/src/math.ts", content },
1198
778
  },
1199
779
  });
1200
- _broadcastEnvelope({
780
+ _broadcastEnvelope(relayDeps, {
1201
781
  rpc: {
1202
782
  type: "tool_execution_end",
1203
783
  toolCallId: tc,
@@ -1205,12 +785,12 @@ function _runTestScenario(scenario) {
1205
785
  isError: false,
1206
786
  },
1207
787
  });
1208
- _broadcastEnvelope({ rpc: { type: "agent_settled" } });
788
+ _broadcastEnvelope(relayDeps, { rpc: { type: "agent_settled" } });
1209
789
  return "sent write (args.content → content-as-code block)";
1210
790
  }
1211
791
  case "agent": {
1212
- _broadcastEnvelope({ rpc: { type: "turn_start" } });
1213
- _broadcastEnvelope({
792
+ _broadcastEnvelope(relayDeps, { rpc: { type: "turn_start" } });
793
+ _broadcastEnvelope(relayDeps, {
1214
794
  rpc: {
1215
795
  type: "message_update",
1216
796
  assistantMessageEvent: {
@@ -1219,7 +799,7 @@ function _runTestScenario(scenario) {
1219
799
  },
1220
800
  },
1221
801
  });
1222
- _broadcastEnvelope({
802
+ _broadcastEnvelope(relayDeps, {
1223
803
  rpc: {
1224
804
  type: "message_update",
1225
805
  assistantMessageEvent: {
@@ -1228,7 +808,7 @@ function _runTestScenario(scenario) {
1228
808
  },
1229
809
  },
1230
810
  });
1231
- _broadcastEnvelope({
811
+ _broadcastEnvelope(relayDeps, {
1232
812
  rpc: {
1233
813
  type: "message_end",
1234
814
  message: {
@@ -1237,11 +817,11 @@ function _runTestScenario(scenario) {
1237
817
  },
1238
818
  },
1239
819
  });
1240
- _broadcastEnvelope({ rpc: { type: "agent_settled" } });
820
+ _broadcastEnvelope(relayDeps, { rpc: { type: "agent_settled" } });
1241
821
  return "sent agent message (envelope)";
1242
822
  }
1243
823
  case "error":
1244
- _broadcastEnvelope({
824
+ _broadcastEnvelope(relayDeps, {
1245
825
  rpc: {
1246
826
  type: "message_end",
1247
827
  message: {
@@ -1251,7 +831,7 @@ function _runTestScenario(scenario) {
1251
831
  },
1252
832
  },
1253
833
  });
1254
- _broadcastEnvelope({ rpc: { type: "agent_settled" } });
834
+ _broadcastEnvelope(relayDeps, { rpc: { type: "agent_settled" } });
1255
835
  return "sent error (envelope message_end/error)";
1256
836
  case "all":
1257
837
  for (const sc of [
@@ -1296,150 +876,7 @@ function _routeRpcCommandFrom(sender, env) {
1296
876
  // session_sync (reconstruction) is un-bien's OWN protocol — dispatched on the
1297
877
  // un plane by _routeUnBienPlaneFrom, NOT here. Only byte-faithful pi rpc
1298
878
  // commands + extension_ui_response ride this rpc dispatch.
1299
- const handlers = {
1300
- prompt: async (message, opts) => {
1301
- // Full parity with the retired stock user_message handler:
1302
- // - ALWAYS hand off with deliverAs:"steer" — the SDK ignores it while idle
1303
- // but REQUIRES it when a turn is running or still settling right after
1304
- // agent return; without it the message is rejected as busy.
1305
- // - `shouldSteer` (echo label + steer tracking) = requested OR inferred
1306
- // busy-room send.
1307
- // - seed `_rootState().turnId` for a fresh (non-steer) turn so the agent's
1308
- // reply chunks/done have a target; restore it if the handoff fails.
1309
- // The APP owns the steer-vs-followUp semantic switch (design 01M14T6J5W):
1310
- // pass its chosen streamingBehavior straight through to pi. The extension does
1311
- // NOT force steer over a followUp anymore. `shouldSteer` below is now only
1312
- // BOOKKEEPING (turn-seeding + image-preview defer), not the delivery verb.
1313
- const requestedSteer = opts.streamingBehavior === "steer";
1314
- // Authoritative busy signal from pi's OWN state (AgentSession.isStreaming),
1315
- // correct across subagent lifecycles (turnId/working stick busy after a
1316
- // subagent run).
1317
- const streaming = _pi?.isStreaming === true;
1318
- const shouldSteer = requestedSteer || streaming;
1319
- const msg = {
1320
- type: "user_message",
1321
- id: opts.id ?? _rootState().turnId ?? String(Date.now()),
1322
- text: message,
1323
- images: opts.images,
1324
- };
1325
- // Image path mirrors the stock handler (SDK handoff WITH images + echo).
1326
- if (msg.images && msg.images.length > 0) {
1327
- await _deliverImageUserMessage(sender, msg, shouldSteer);
1328
- return;
1329
- }
1330
- const previousTurnId = _rootState().turnId;
1331
- const seededTurnId = !shouldSteer || _rootState().turnId === null;
1332
- if (seededTurnId)
1333
- _rootState().turnId = msg.id;
1334
- // PASS-THROUGH the app's verb (design 01M14T6J5W). pi's prompt(): idle
1335
- // ignores streamingBehavior (fresh run); streaming+"steer" -> _queueSteer;
1336
- // streaming+"followUp" -> _queueFollowUp; streaming+none -> throws. The
1337
- // `?? (streaming ? "steer" : undefined)` is a MECHANICAL safety net (not
1338
- // semantic inference) so a racing/old client's no-behavior busy send
1339
- // defensively steers instead of throwing (keeps plan/43).
1340
- const wake = _wakeAgent(message, "app rpc prompt", opts.streamingBehavior === "followUp"
1341
- ? "followUp"
1342
- : opts.streamingBehavior === "steer" || streaming
1343
- ? "steer"
1344
- : undefined);
1345
- if (!wake.ok) {
1346
- if (seededTurnId)
1347
- _rootState().turnId = previousTurnId;
1348
- throw new Error(wake.detail);
1349
- }
1350
- },
1351
- steer: async (message) => {
1352
- const wake = _wakeAgent(message, "app rpc steer", "steer");
1353
- if (!wake.ok)
1354
- throw new Error(wake.detail);
1355
- },
1356
- followUp: async (message) => {
1357
- const wake = _wakeAgent(message, "app rpc follow_up", "followUp");
1358
- if (!wake.ok)
1359
- throw new Error(wake.detail);
1360
- },
1361
- abort: async () => {
1362
- if (!_abortCurrentTurn())
1363
- throw new Error("no active turn to abort");
1364
- },
1365
- setModel: async (provider, modelId) => {
1366
- if (!_pi)
1367
- throw new Error("agent session not bound");
1368
- const actionCtx = (_lastEventCtx ?? _lastCtx);
1369
- const reg = actionCtx?.modelRegistry ?? ensureModelRegistry(actionCtx);
1370
- reg.refresh();
1371
- const model = reg.find(provider, modelId);
1372
- if (!model)
1373
- throw new Error(`model "${provider}/${modelId}" not in registry`);
1374
- // Route via the minimal ActionPi view (matches handleModelSet): the
1375
- // registry's `find` returns the minimal SdkModelLike, structurally fine
1376
- // for setModel at runtime.
1377
- // SAFETY: _pi (checked non-null above) is the concrete AgentSession; its
1378
- // setModel accepts the minimal SdkModelLike the registry's find() returns,
1379
- // matching handleModelSet's ActionPi view.
1380
- const ok = await _pi.setModel(model);
1381
- if (!ok)
1382
- throw new Error("no auth configured for this model");
1383
- _persistModelDefault(model.provider, model.id); // survive restart, mirrors stock model_set
1384
- return wireFromModel(model);
1385
- },
1386
- setThinkingLevel: async (level) => {
1387
- if (!_pi)
1388
- throw new Error("agent session not bound");
1389
- _pi.setThinkingLevel(level);
1390
- },
1391
- getAvailableModels: async () => {
1392
- const actionCtx = (_lastEventCtx ?? _lastCtx);
1393
- const reg = actionCtx?.modelRegistry ?? ensureModelRegistry(actionCtx);
1394
- reg.refresh();
1395
- const models = reg.getAvailable().map(wireFromModel);
1396
- const current = actionCtx?.getModel?.();
1397
- return { models, current: current ? wireFromModel(current) : undefined };
1398
- },
1399
- compact: async (customInstructions) => {
1400
- const actionCtx = (_lastEventCtx ?? _lastCtx);
1401
- if (!actionCtx?.compact)
1402
- throw new Error("compact unavailable (no active session ctx)");
1403
- actionCtx.compact(customInstructions ? { customInstructions } : undefined);
1404
- return {};
1405
- },
1406
- newSession: async () => {
1407
- const actionCtx = (_lastEventCtx ?? _lastCtx);
1408
- if (!actionCtx?.newSession) {
1409
- throw new Error("new_session unavailable (no command ctx)");
1410
- }
1411
- await actionCtx.newSession({ withSession: async () => { } });
1412
- // Restamp the session clock (parity with the retired stock session_new):
1413
- // session_sync_end carries it so the app can detect the pi restart.
1414
- _resetSessionForNew();
1415
- return { cancelled: false };
1416
- },
1417
- clearQueue: async () => {
1418
- if (!_pi)
1419
- throw new Error("agent session not bound");
1420
- // SAFETY: _pi (checked non-null above) is the concrete AgentSession,
1421
- // which implements clearQueue(); the public ExtensionAPI type omits it.
1422
- return _pi.clearQueue();
1423
- },
1424
- getEntries: async (since) => {
1425
- // Native pi get_entries: the app reconstructs the transcript itself from
1426
- // the raw entry log (each message entry carries an AgentMessage). `since`
1427
- // slices to entries AFTER that id (delta cursor); leafId is the cursor to
1428
- // resume from next time. The extension does NOT replay these — see the app's
1429
- // SessionState.applyEntries (design 01M15FMQ).
1430
- const sm = _rootState().sessionManager;
1431
- if (!sm)
1432
- return { entries: [], leafId: null };
1433
- const all = sm.getEntries();
1434
- const sliced = typeof since === "string"
1435
- ? (() => {
1436
- const i = all.findIndex((e) => e.id === since);
1437
- return i === -1 ? all : all.slice(i + 1);
1438
- })()
1439
- : all;
1440
- return { entries: sliced, leafId: sm.getLeafId() };
1441
- },
1442
- };
879
+ const handlers = createRpcHandlers(rpcDeps, sender);
1443
880
  void dispatchRpcCommand(frame, handlers)
1444
881
  .then((resp) => {
1445
882
  // Envelope-native ONLY: no stock fallback. An unhandled rpc type is
@@ -1481,16 +918,99 @@ function _routeUnBienPlaneFrom(sender, env) {
1481
918
  envLog(`session_sync(ub): panels=${panels.length} + ui (transcript is the app's get_entries rpc)`);
1482
919
  // Terminator/ack on the ub plane; carries the session clock so the app can
1483
920
  // detect a pi restart. `truncated`/`limit` are gone (a replay concern;
1484
- // get_entries is unbounded / since-delta).
921
+ // get_entries is unbounded / since-delta). `session_name` (pre-release
922
+ // 2026-09-18): the NAME PULL — heals a missed session_info_changed push
923
+ // (an iOS backgrounded socket misses live frames; the relay's stored meta
924
+ // is stale until our next hello, so reconnects can't carry it either).
925
+ // session_sync is issued on EVERY open + reconnect, so the name heals on
926
+ // every natural cycle. Old apps ignore the field; old extensions omit it.
927
+ //
928
+ // SOURCE = the room's DISPLAY name (myRoomMeta.name), NOT deps.sessionName:
929
+ // the latter is `_sessionName`, which `_cmdJoin` sets to the mesh session
930
+ // id LOCAL_SESSION_NAME ("local"). Sending that here clobbered the tile
931
+ // label with "local" on every reconnect (the flaky-name bug) — the
932
+ // non-live pull disagreed with the live session_info push. myRoomMeta.name
933
+ // is the same value pair_ok sends (deps.displayName) and tracks live
934
+ // renames (session_info_changed updates it), so pull == push now.
935
+ const displayName = deps.myRoomMeta?.name;
936
+ // Fork auto-nav: a fork-born session's FIRST sync echoes the app's
937
+ // originating fork request id (design: fork switch). take = read+unlink,
938
+ // so it fires once (the app navigates on first receipt; later reconnects
939
+ // must not re-jump). Keyed by the session id, so it survives the extension
940
+ // module re-eval that ctx.fork triggers.
941
+ const forkSm = _rootState().sessionManager;
942
+ const forkedFromReq = forkSm && typeof forkSm.getSessionDir === "function"
943
+ ? takeForkLink(forkSm.getSessionDir(), forkSm.getSessionId())
944
+ : undefined;
1485
945
  sender.sendEnvelope({
1486
946
  ub: {
1487
947
  type: "session_sync_end",
1488
948
  ...(typeof f.id === "string" ? { in_reply_to: f.id } : {}),
1489
949
  session_started_at: _sessionStartedAt ?? 0,
950
+ ...(displayName ? { session_name: displayName } : {}),
951
+ ...(forkedFromReq ? { forked_from_req: forkedFromReq } : {}),
1490
952
  },
1491
953
  });
1492
954
  return;
1493
955
  }
956
+ if (type === "terminate") {
957
+ // app->ext: kill THIS session. Root room only — a child room's terminate
958
+ // is handled by the child-room router in subagent_rooms (never reaches
959
+ // here; that room's conn dispatches its own ub frames). Gate on local
960
+ // config; the app confirms + shows red before sending.
961
+ if (!effectiveAllowRemoteTerminate(loadLocalConfig(process.cwd()))) {
962
+ envLog("terminate(ub): remote terminate disabled on this machine");
963
+ return;
964
+ }
965
+ envLog(`terminate(ub): graceful host shutdown requested${typeof frame.reason === "string" ? ` (${String(frame.reason)})` : ""}`);
966
+ _safeNotify("Terminated from the app", "warning");
967
+ try {
968
+ // ctx.shutdown() IS the /quit equivalent — pi's own source lists
969
+ // "extension shutdown()" alongside Ctrl+D / /quit (interactive-mode.js
970
+ // 3229: same graceful this.shutdown(): drain input, ui.stop() restores
971
+ // cooked mode + cursor, extension disposal, resume hint, exit). It sits
972
+ // DIRECTLY on the event ctx (ExtensionContext.shutdown — there is no
973
+ // ctx.actions sub-object; the first two attempts failed exactly there).
974
+ //
975
+ // MID-TURN DEFERRAL: the interactive handler fires immediately only when
976
+ // idle; otherwise it sets `shutdownRequested` and waits for
977
+ // `agent_settled` — which never arrives when nothing is running. So
978
+ // abort() FIRST: aborting a running turn forces the settle that
979
+ // re-checks the flag; on an idle session abort is a no-op and the
980
+ // idle fast-path fires directly.
981
+ //
982
+ // NO process.exit fallback, ever: a raw exit leaves the TTY in raw
983
+ // mode (keypresses echo escape codes until `stty sane`). No live ctx =>
984
+ // log loudly and stay up; the user quits on the machine.
985
+ const ctx = _liveCtx();
986
+ if (ctx?.shutdown) {
987
+ ctx.abort?.();
988
+ ctx.shutdown();
989
+ }
990
+ else {
991
+ envLog("terminate(ub): no live ctx — refusing raw exit (would leave the terminal in raw mode); staying up");
992
+ }
993
+ }
994
+ catch (err) {
995
+ envLog(`terminate(ub) error: ${err instanceof Error ? err.message : String(err)}`);
996
+ }
997
+ return;
998
+ }
999
+ if (type === "close_child_room") {
1000
+ // app->ext(PARENT): permanently close one of MY child rooms (done-subagent
1001
+ // removal — the room lingers by design otherwise). Tombstone + dispose in
1002
+ // subagent_rooms; the relay fires room_ended when the last conn drops.
1003
+ if (!effectiveAllowRemoteTerminate(loadLocalConfig(process.cwd()))) {
1004
+ envLog("close_child_room(ub): remote terminate disabled on this machine");
1005
+ return;
1006
+ }
1007
+ const roomId = frame.room_id;
1008
+ if (typeof roomId !== "string" || roomId.length === 0)
1009
+ return;
1010
+ const closed = _subagentRooms?.closeChildRoom(roomId) ?? false;
1011
+ envLog(`close_child_room(ub): room=${roomId.slice(0, 8)} closed=${closed}`);
1012
+ return;
1013
+ }
1494
1014
  if (type === "session_launch") {
1495
1015
  const f = frame;
1496
1016
  const cwd = _expandTilde(typeof f.cwd === "string" && f.cwd.length > 0 ? f.cwd : process.cwd());
@@ -1506,67 +1026,42 @@ function _routeUnBienPlaneFrom(sender, env) {
1506
1026
  envLog(`session_launch(ub) error: ${launchError}`);
1507
1027
  return;
1508
1028
  }
1509
- }
1510
- /** Broadcast for the extension_ui bridge. The bridge only ever emits
1511
- * `extension_ui_request`, sent ENVELOPE-ONLY as a `{rpc}` frame (the wire
1512
- * shape mirrors the SDK rpc contract 1:1). No stock fallback. */
1513
- function _uiBroadcast(msg) {
1514
- if (msg.type === "extension_ui_request")
1515
- _broadcastEnvelope({ rpc: msg });
1516
- }
1517
- /** Broadcast for the panel bridge. The bridge only ever emits `panel_update`,
1518
- * forwarded ENVELOPE-ONLY as `{evt:{channel:"panel", data}}` (the {evt} plane);
1519
- * the app folds it into its panel store. No stock fallback. */
1520
- function _panelBroadcast(msg) {
1521
- if (msg.type === "panel_update")
1522
- _broadcastEnvelope({ evt: { channel: "panel", data: msg } });
1523
- }
1524
- /** Fan an rpc-envelope frame out to every attached peer (base64 ct via each
1525
- * channel) — the single owner-fanout path for `{ rpc | evt }` messages. */
1526
- function _broadcastEnvelope(env) {
1527
- {
1528
- // Observability only (not a route gate): watch the {rpc|evt} wire during
1529
- // e2e bring-up. Frame type only — payloads can be large / carry images.
1530
- const kind = env.rpc
1531
- ? `rpc:${env.rpc.type ?? "?"}`
1532
- : `evt:${env.evt?.channel ?? "?"}`;
1533
- envLog(`envelope -> ${_activePeers.size} peer(s): ${kind}`);
1534
- }
1535
- for (const ch of _activePeers.values()) {
1536
- try {
1537
- ch.sendEnvelope(env);
1029
+ if (type === "session_fork" || type === "session_navigate") {
1030
+ // app->ext: fork a NEW session / branch IN PLACE from a conversation entry.
1031
+ // ctx.fork / ctx.navigateTree live ONLY on the command ctx, so we can't act
1032
+ // here (this is the peer-channel dispatch, not a command). Self-dispatch the
1033
+ // registered `/unbien fork|branch` command via sendUserMessage — pi runs it
1034
+ // with a real ExtensionCommandContext. No stashed ctx, no startup bootstrap:
1035
+ // the command ctx lives exactly as long as the op that needs it. Not gated
1036
+ // beyond being paired — these are no more privileged than a prompt on this
1037
+ // same room (which the app can already send).
1038
+ const f = frame;
1039
+ const entryId = typeof f.entry_id === "string" ? f.entry_id : "";
1040
+ if (!entryId) {
1041
+ envLog(`${String(type)}(ub): missing entry_id — ignored`);
1042
+ return;
1538
1043
  }
1539
- catch {
1540
- /* best-effort per channel */
1044
+ if (type === "session_fork") {
1045
+ // Thread the request id (reqId) so the new session can echo it back as
1046
+ // forked_from_req on its first sync — the app's auto-nav correlation.
1047
+ // Order: `<entryId> <reqId> [pos]` (see _cmdFork's parse).
1048
+ const reqId = typeof f.id === "string" ? f.id : "";
1049
+ const posArg = f.position === "at" || f.position === "before" ? ` ${f.position}` : "";
1050
+ const reqArg = reqId ? ` ${reqId}` : "";
1051
+ envLog(`session_fork(ub): entry=${entryId.slice(0, 8)}${posArg}`);
1052
+ _pi?.sendUserMessage(`/unbien fork ${entryId}${reqArg}${posArg}`, {
1053
+ deliverAs: "followUp",
1054
+ expandPromptTemplates: true,
1055
+ });
1056
+ }
1057
+ else {
1058
+ envLog(`session_navigate(ub): entry=${entryId.slice(0, 8)}`);
1059
+ _pi?.sendUserMessage(`/unbien branch ${entryId}`, {
1060
+ deliverAs: "followUp",
1061
+ expandPromptTemplates: true,
1062
+ });
1541
1063
  }
1542
- }
1543
- }
1544
- /**
1545
- * Adds an owner's channel to `_activePeers`. Also updates the UX hint
1546
- * `_peerShort` (last-attached shortid) so the footer + status can pick
1547
- * a representative device when only one is connected.
1548
- */
1549
- function _attachPeerChannel(appPeerId, channel) {
1550
- _activePeers.set(appPeerId, channel);
1551
- _peerShort = appPeerId.slice(0, 8);
1552
- }
1553
- /** Detaches a single owner's channel + removes it from the map. Used by
1554
- * `_onPeerDisconnect`, `_cmdRevoke`, and the SelfRevoke callback. */
1555
- function _detachPeerChannel(appPeerId) {
1556
- const ch = _activePeers.get(appPeerId);
1557
- if (!ch)
1558
1064
  return;
1559
- try {
1560
- ch.detach();
1561
- }
1562
- catch {
1563
- /* best-effort */
1564
- }
1565
- _activePeers.delete(appPeerId);
1566
- if (_peerShort === appPeerId.slice(0, 8)) {
1567
- // Pick a different remaining peer for the UX hint, or clear when none.
1568
- const next = _activePeers.keys().next().value;
1569
- _peerShort = next ? next.slice(0, 8) : "";
1570
1065
  }
1571
1066
  }
1572
1067
  // ── Display-name helpers ──────────────────────────────────────────────────────
@@ -1588,12 +1083,14 @@ function _detachPeerChannel(appPeerId) {
1588
1083
  * inline at three different sites (pair_ok, room_meta, QR URI); this
1589
1084
  * helper consolidates them and lifts the user's configured name above
1590
1085
  * the raw cwd path.
1086
+ *
1087
+ * A session-scoped name (pi `-n`, set by the remote launcher) slots in just
1088
+ * below the broker-assigned mesh name — see `resolveAgentName`.
1591
1089
  */
1592
1090
  function _displayName(cwd) {
1593
1091
  if (_meshNode)
1594
1092
  return _meshNode.name();
1595
- const local = loadLocalConfig(cwd);
1596
- return local.agent_name || defaultAgentName(cwd);
1093
+ return resolveAgentName(cwd, piSessionName(_pi));
1597
1094
  }
1598
1095
  function _reportRevocationByFingerprint(canonicalOwnerPubkey) {
1599
1096
  const fingerprint = _runtimeOwnerFingerprint(canonicalOwnerPubkey);
@@ -1609,299 +1106,10 @@ function _revokeActiveOwnerRuntime(canonicalOwnerPubkey) {
1609
1106
  if (!_activePeers.has(canonicalOwnerPubkey))
1610
1107
  return;
1611
1108
  _refreshPairingsCache();
1612
- _detachPeerChannel(canonicalOwnerPubkey);
1109
+ _detachPeerChannel(relayDeps, canonicalOwnerPubkey);
1613
1110
  _refreshFooter();
1614
1111
  _reportRevocationByFingerprint(canonicalOwnerPubkey);
1615
1112
  }
1616
- // ── Transition helpers ────────────────────────────────────────────────────────
1617
- /**
1618
- * Full teardown: stop listener, detach channel, close relay → idle.
1619
- */
1620
- function _goIdle() {
1621
- _rootLifecycleGeneration += 1;
1622
- _relayLifecycleGeneration += 1;
1623
- // Cancel any pending reconnect attempt. Critical: /unbien stop must
1624
- // win the race against a scheduled reconnect.
1625
- if (_reconnectTimer !== null) {
1626
- clearTimeout(_reconnectTimer);
1627
- _reconnectTimer = null;
1628
- }
1629
- _reconnectAttempt = 0;
1630
- _stopAutoListener?.();
1631
- _stopAutoListener = null;
1632
- // Tear down every per-owner channel and clear the map.
1633
- for (const ch of _activePeers.values()) {
1634
- try {
1635
- ch.detach();
1636
- }
1637
- catch {
1638
- /* best-effort */
1639
- }
1640
- }
1641
- _activePeers.clear();
1642
- _peerShort = "";
1643
- _rootState().turnId = null;
1644
- _pendingReceivedImagePreviews.length = 0;
1645
- // Invalidate async producers and bridge ownership before closing the host
1646
- // Relay. A synchronous/delayed close callback must observe stale identity.
1647
- const producer = _selfRevoke;
1648
- _selfRevoke = null;
1649
- _selfRevokeEpoch += 1;
1650
- _selfRevokeTopologyReadyEpoch = -1;
1651
- _selfRevokeTopology = null;
1652
- producer?.stop();
1653
- _meshNode?.detachBridge();
1654
- const relay = _relay;
1655
- _relay = null;
1656
- _relayUrl = null;
1657
- relay?.close();
1658
- // Preserve _sessionStartedAt + _messageBuffer across stop/start cycles.
1659
- // The Pi agent session outlives the relay connection — `message_end` keeps
1660
- // firing for terminal turns even while idle, and the buffer must survive
1661
- // so those turns appear in the next session_sync. Only a Pi process
1662
- // restart resets these (init-time values).
1663
- _state = "idle";
1664
- _refreshFooter();
1665
- _emitRelayState(); // → disconnected
1666
- }
1667
- /**
1668
- * Called when the relay WS closes unexpectedly (network drop, relay restart,
1669
- * etc.). Does a **partial** teardown — keeps `_sessionStartedAt`, `_messageBuffer`,
1670
- * `_relayUrl`, `_cachedEd25519`, `_peerShort` so the session can resume on
1671
- * reconnect — and schedules an `_attemptReconnect`.
1672
- *
1673
- * Peer (app) reconnect after a successful relay reconnect is handled by the
1674
- * existing auto-listener via `peers.json` lookup, so we don't need to track
1675
- * the prior peer here; we just go back to `started` and wait.
1676
- */
1677
- function _onRelayClose(closedRelay) {
1678
- if (_relay !== closedRelay)
1679
- return; // delayed close from a replaced Relay
1680
- if (_state === "idle")
1681
- return; // already torn down (e.g. /unbien stop)
1682
- _relayLifecycleGeneration += 1;
1683
- _stopAutoListener?.();
1684
- _stopAutoListener = null;
1685
- // Detach every per-owner channel — relay is gone, none can route. The
1686
- // auto-listener re-attaches owners after `_attemptReconnect` succeeds
1687
- // (via the same known-peer + pair_request paths used on first connect).
1688
- for (const ch of _activePeers.values()) {
1689
- try {
1690
- ch.detach();
1691
- }
1692
- catch {
1693
- /* best-effort */
1694
- }
1695
- }
1696
- _activePeers.clear();
1697
- _peerShort = "";
1698
- _rootState().turnId = null;
1699
- _relay = null; // _relayUrl preserved for retry
1700
- // Cross-PC routing relies on _relay; bring it down. Will be re-instated
1701
- // by _attemptReconnect on success.
1702
- _meshNode?.detachBridge();
1703
- _state = "started";
1704
- _refreshFooter();
1705
- _emitRelayState(); // → reconnecting
1706
- const reconnectUrl = _relayUrl;
1707
- if (reconnectUrl) {
1708
- _scheduleReconnect(_relayLifecycleGeneration, reconnectUrl);
1709
- }
1710
- }
1711
- function _isCurrentReconnect(lifecycleGeneration, url) {
1712
- return (lifecycleGeneration === _relayLifecycleGeneration &&
1713
- _state === "started" &&
1714
- _relay === null &&
1715
- _relayUrl === url);
1716
- }
1717
- function _scheduleReconnect(lifecycleGeneration, url) {
1718
- if (_reconnectTimer !== null)
1719
- return; // already scheduled
1720
- if (!_cachedEd25519)
1721
- return; // can't reconnect without the cached identity
1722
- if (!_isCurrentReconnect(lifecycleGeneration, url))
1723
- return;
1724
- const idx = Math.min(_reconnectAttempt, RECONNECT_BACKOFFS_MS.length - 1);
1725
- const delay = RECONNECT_BACKOFFS_MS[idx];
1726
- _reconnectAttempt += 1;
1727
- // The timer belongs to the lifecycle that scheduled it. Re-check that exact
1728
- // generation + URL before constructing a candidate so a dequeued old timer
1729
- // cannot act on a newer stop/start lifecycle.
1730
- _reconnectTimer = setTimeout(() => {
1731
- _reconnectTimer = null;
1732
- if (!_isCurrentReconnect(lifecycleGeneration, url))
1733
- return;
1734
- void _attemptReconnect(lifecycleGeneration, url);
1735
- }, delay);
1736
- }
1737
- async function _attemptReconnect(lifecycleGeneration, url) {
1738
- if (!_cachedEd25519)
1739
- return;
1740
- if (!_isCurrentReconnect(lifecycleGeneration, url))
1741
- return;
1742
- const edKp = _cachedEd25519;
1743
- // _relayUrl is stored in canonical http(s):// form — convert at the
1744
- // WS boundary, same as _cmdStart.
1745
- const relay = new RelayClient(toWebSocketUrl(url), edKp);
1746
- try {
1747
- // Replay the same room identity from _cmdStart. Without this the relay
1748
- // would log this WS as a default-room peer and the app would see a
1749
- // phantom "legacy session" appear (regression of plano 17 + 18).
1750
- await relay.connect({
1751
- ...(_myRoomId ? { roomId: _myRoomId } : {}),
1752
- ...(_myRoomMeta ? { roomMeta: _myRoomMeta } : {}),
1753
- });
1754
- }
1755
- catch {
1756
- // A reconnect candidate stays local until publication; every rejected
1757
- // candidate is deterministically closed before stale-return or retry.
1758
- try {
1759
- relay.close();
1760
- }
1761
- catch {
1762
- /* best-effort rejected candidate cleanup */
1763
- }
1764
- if (!_isCurrentReconnect(lifecycleGeneration, url))
1765
- return;
1766
- _scheduleReconnect(lifecycleGeneration, url);
1767
- return;
1768
- }
1769
- if (!_isCurrentReconnect(lifecycleGeneration, url)) {
1770
- try {
1771
- relay.close();
1772
- }
1773
- catch {
1774
- /* best-effort stale candidate cleanup */
1775
- }
1776
- return;
1777
- }
1778
- _relay = relay;
1779
- _reconnectAttempt = 0;
1780
- relay.on("close", () => _onRelayClose(relay));
1781
- _stopAutoListener = _installAutoListener(relay);
1782
- // Plan/25 Wave B/C: relay is back; bring cross-PC routing back online.
1783
- _attachBridgeIfReady();
1784
- // _state stays "started"; peer reconnect (if previously paired) flows
1785
- // through _installAutoListener → _findKnownPeer → _promoteToPaired
1786
- // automatically when the app sends any inner.
1787
- _emitRelayState();
1788
- }
1789
- // ── Relay state event + transparent control channel (Cockpit toggle) ─────────
1790
- /** Current relay connectivity, derived from `_state` + `_relay`. */
1791
- function _relayStatus() {
1792
- if (_getState() === "idle")
1793
- return "disconnected";
1794
- return _relay ? "connected" : "reconnecting";
1795
- }
1796
- /**
1797
- * Emit the `un-bien:relay-state` custom message so an RPC client (Cockpit)
1798
- * can render a relay on/off indicator. Pure data (`display:false`) — never
1799
- * shown in the transcript. De-duped on the connectivity value; pass
1800
- * `force=true` to answer an explicit `relay:status` query regardless.
1801
- */
1802
- function _emitRelayState(force = false) {
1803
- const status = _relayStatus();
1804
- if (!force && status === _lastRelayStatus)
1805
- return;
1806
- _lastRelayStatus = status;
1807
- // This can run inside a WebSocket 'close' callback (via _onRelayClose). After a
1808
- // session replacement (newSession/fork/switchSession/reload) the module-level
1809
- // `_pi` is stale, and `assertActive` throws synchronously inside `sendMessage`.
1810
- // An uncaught throw from a WS event callback becomes a process-level
1811
- // uncaughtException and exits pi. Swallow it here: the next relay-state
1812
- // change re-emits, so connectivity is eventually consistent. See issue #55.
1813
- try {
1814
- _pi?.sendMessage({
1815
- customType: "un-bien:relay-state",
1816
- content: `Relay ${status}`,
1817
- details: {
1818
- status,
1819
- connected: status === "connected",
1820
- ...(_relayUrl ? { relayUrl: _relayUrl } : {}),
1821
- ...(_myRoomId ? { room: _myRoomId } : {}),
1822
- },
1823
- display: false,
1824
- });
1825
- }
1826
- catch {
1827
- // _pi stale (session replaced) or extension runtime not yet bound.
1828
- }
1829
- }
1830
- /** Minimal ctx for relay start/stop driven by a control message (no command
1831
- * ctx is available in the `input` hook). cwd matches the daemon's launch dir,
1832
- * so the derived relay room is identical to the one `_cmdStart` first used. */
1833
- function _controlCtx() {
1834
- // SAFETY: _headlessUi() implements every ui method the relay start/stop path
1835
- // actually calls; the notify-forwarding shim is structurally narrower than the
1836
- // full ExtensionContext["ui"] but complete for this headless control path.
1837
- return {
1838
- ui: _headlessUi(),
1839
- cwd: process.cwd(),
1840
- };
1841
- }
1842
- /**
1843
- * `ui.notify` for headless contexts (daemon auto-init + control channel). There
1844
- * is no TUI, and the RPC client (Cockpit) already gets everything it needs via
1845
- * structured events (`un-bien:relay-state`, `un-bien:name-assigned`,
1846
- * room_meta) — so routine INFO chatter would just pollute the client's captured
1847
- * stderr. We drop info and forward only warnings/errors (kept for the
1848
- * supervisor's journal / genuine failures). The interactive Pi keeps its normal
1849
- * footer/notify path — this only affects headless ctxs.
1850
- */
1851
- function _headlessUi() {
1852
- return {
1853
- notify: (msg, type) => {
1854
- if (type === "warning" || type === "error")
1855
- process.stderr.write(`${msg}\n`);
1856
- },
1857
- };
1858
- }
1859
- /**
1860
- * Handle a transparent control command from an RPC client (Cockpit), received
1861
- * as a `CTRL_PREFIX`-tagged input the `input` hook swallowed. Toggles the relay
1862
- * WITHOUT leaving the local mesh (relay-only: `_cmdStart` up / `_goIdle` down),
1863
- * then emits the fresh state. `relay:status` just re-emits (no change) so the
1864
- * client can sync its button after (re)attaching to the RPC stream.
1865
- */
1866
- export async function _handleControl(cmd) {
1867
- // `rename:<new-name>` carries an argument, so it's matched before the
1868
- // fixed-verb switch. Renames the agent live (broker re-register + relay room
1869
- // swap) WITHOUT restarting the process or losing the SDK session.
1870
- if (cmd.startsWith("rename:")) {
1871
- await _renameAgent(cmd.slice("rename:".length).trim());
1872
- return;
1873
- }
1874
- switch (cmd) {
1875
- case "relay:on":
1876
- if (_getState() === "idle")
1877
- await _cmdStart(_controlCtx());
1878
- _emitRelayState(true);
1879
- return;
1880
- case "relay:off":
1881
- if (_getState() === "idle") {
1882
- _rootLifecycleGeneration += 1;
1883
- _relayLifecycleGeneration += 1;
1884
- }
1885
- else
1886
- _goIdle();
1887
- _emitRelayState(true);
1888
- return;
1889
- case "relay:toggle":
1890
- if (_getState() === "idle")
1891
- await _cmdStart(_controlCtx());
1892
- else
1893
- _goIdle();
1894
- _emitRelayState(true);
1895
- return;
1896
- case "relay:status":
1897
- _emitRelayState(true);
1898
- return;
1899
- default:
1900
- // Unknown control verb — ignore (forward-compat: a newer client may send
1901
- // verbs an older extension doesn't know).
1902
- return;
1903
- }
1904
- }
1905
1113
  /**
1906
1114
  * Rename the agent LIVE (plan/38/41), without restarting the process or losing
1907
1115
  * the SDK session/conversation. Touches two layers:
@@ -1931,7 +1139,7 @@ async function _renameAgent(newName) {
1931
1139
  // clean; bring it back up after with the new name.
1932
1140
  const wasStarted = _getState() !== "idle";
1933
1141
  if (wasStarted)
1934
- _goIdle();
1142
+ _goIdle(relayDeps);
1935
1143
  let assigned = newName;
1936
1144
  try {
1937
1145
  assigned = await _meshNode.rename(newName); // broker soft rejoin
@@ -1940,7 +1148,7 @@ async function _renameAgent(newName) {
1940
1148
  ctx.ui.notify(`[un-bien] rename failed: ${String(err)}`, "error");
1941
1149
  }
1942
1150
  if (wasStarted && !_disposed)
1943
- await _cmdStart(ctx); // relay back up → roomIdFor(cwd, assigned)
1151
+ await _cmdStart(deps, ctx); // relay back up → roomIdFor(cwd, assigned)
1944
1152
  _pi?.sendMessage({
1945
1153
  customType: "un-bien:name-assigned",
1946
1154
  content: assigned === newName
@@ -1950,338 +1158,48 @@ async function _renameAgent(newName) {
1950
1158
  display: false,
1951
1159
  });
1952
1160
  }
1953
- /**
1954
- * Per-owner disconnect callback. Fires when one specific owner's channel
1955
- * detaches (e.g. relay told us the peer is gone). Other owners' channels
1956
- * keep running — relay stays "started".
1957
- *
1958
- * Exported so tests can trigger the disconnect path for a specific peer.
1959
- *
1960
- * Backward-compat: a no-arg call (legacy tests / pre-W2D callers) falls
1961
- * back to detaching the most recently attached peer, mirroring the old
1962
- * singleton semantics.
1963
- */
1964
- export function _onPeerDisconnect(appPeerId) {
1965
- if (_state === "idle")
1966
- return;
1967
- const target = appPeerId ?? [..._activePeers.keys()].pop();
1968
- if (!target)
1969
- return;
1970
- if (!_activePeers.has(target))
1971
- return;
1972
- _detachPeerChannel(target);
1973
- if (_anyPeerActive()) {
1974
- // Other owners still attached — keep _rootState().turnId so they continue
1975
- // seeing the in-flight agent stream.
1976
- _refreshFooter();
1977
- return;
1978
- }
1979
- // No owner left. Conservatively clear the turn so the next pair_request
1980
- // starts cleanly.
1981
- _rootState().turnId = null;
1982
- _refreshFooter();
1983
- _safeNotify("[un-bien] All app peers disconnected, listening for reconnect", "info");
1984
- // Auto-listener stays up — same listener catches the reconnect on any peer.
1985
- }
1986
- /**
1987
- * Attaches a new owner channel to the multi-owner set. Replaces the
1988
- * pre-W2D singleton `_promoteToPaired` which set `_state = "paired"` and
1989
- * a single `_peerChannel`. The relay state remains `started`; pairing
1990
- * status is derived from `_activePeers.size`.
1991
- *
1992
- * Idempotent for the same `appPeerId` (re-attaching tears down the prior
1993
- * channel and installs a fresh one — covers reconnect from the same
1994
- * device without leaking listeners).
1995
- */
1996
- function _attachOwner(relay, appPeerId, peerName, firstInner) {
1997
- const peerShort = appPeerId.slice(0, 8);
1998
- // Drop any stale channel for this owner before re-attaching.
1999
- if (_activePeers.has(appPeerId))
2000
- _detachPeerChannel(appPeerId);
2001
- // Prefer always-fresh session_start ctx for async relay routing — `_lastCtx`
2002
- // is a captured command ctx that goes stale after session replacement (#55).
2003
- const channel = new PlainPeerChannel(relay, appPeerId, _myRoomId ?? undefined, (msg) => _routeClientMessageFrom(channel, msg, _liveCtx() ?? _noopCtx), () => _onPeerDisconnect(appPeerId), (env) => env.ub === undefined
2004
- ? _routeRpcCommandFrom(channel, env)
2005
- : _routeUnBienPlaneFrom(channel, env), () => _rootState().sessionManager?.getSessionId() ??
2006
- _rootSessionId ??
2007
- undefined);
2008
- _attachPeerChannel(appPeerId, channel);
2009
- // Envelope-native capability handshake: advertise caps up front so the app can
2010
- // enable the {rpc|evt} route + suppress stock before any session content
2011
- // arrives. Additive to the stock session_history caps (parity transition).
2012
- const _sid = _rootState().sessionManager?.getSessionId();
2013
- channel.sendEnvelope(helloEnvelope(_capabilities(), _sid));
2014
- envLog(`attach: peer=${appPeerId.slice(0, 8)} hello sent (caps + sessionId=${_sid ?? "?"}); active=${_activePeers.size}`);
2015
- // Reconstruction (transcript + panels + extension_ui) is request-driven: the
2016
- // app issues session_sync — on fresh open AND on relay reconnect — and the
2017
- // handler in _routeUnBienPlaneFrom replays all of it. Re-sync is idempotent
2018
- // (stable identify ids + ns/id panel merge), so nothing is replayed
2019
- // proactively here.
2020
- _refreshFooter();
2021
- _safeNotify(`[un-bien] Owner attached: peer=${peerShort}, name=${peerName} ` +
2022
- `(${_activePeers.size} active)`, "info");
2023
- if (firstInner) {
2024
- // The PlainPeerChannel listener fired on the same line that triggered
2025
- // attachment in some flows; we route explicitly here too to ensure the
2026
- // inner reaches the handler exactly once.
2027
- void firstInner;
2028
- }
2029
- return channel;
2030
- }
2031
- // ── Auto-listener ─────────────────────────────────────────────────────────────
2032
- //
2033
- // Installed while in 'started' state. Decodes the outer envelope as
2034
- // base64(JSON) and dispatches per sender peer_id:
2035
- // • Sender already in `_activePeers` → ignored here (the per-owner
2036
- // PlainPeerChannel listens on the same relay event and handles its own
2037
- // traffic via its `remotePeerId` filter)
2038
- // • `pair_request` from a new peer → validate token, persist peer, send
2039
- // pair_ok/pair_error, attach a new channel
2040
- // • Non-pair message from a known peer (peers.json) without an active
2041
- // channel yet → attach + route the inner (reconnect path)
2042
- // • Anything else (unknown peer + non-pair) → emit `error: unknown_peer`
2043
- function _installAutoListener(relay) {
2044
- const listenerGeneration = _relayLifecycleGeneration;
2045
- const hasListenerAuthority = () => !_disposed &&
2046
- _state === "started" &&
2047
- _relay === relay &&
2048
- _relayLifecycleGeneration === listenerGeneration;
2049
- const onMsg = async (line) => {
2050
- let outer;
1161
+ // ── Extension factory (default export) ───────────────────────────────────────
1162
+ // One-shot flag for the turn-ctx surface probe (see the turn_start handler).
1163
+ let _turnCtxProbed = false;
1164
+ /** TARGETED, PROXY-SAFE ctx probe: the ctx objects carry guarded getters
1165
+ * (throwing on invalid/stale access — see _ctxUi), so full reflection
1166
+ * under-reports or throws. Access each interesting member individually,
1167
+ * catching per property. Reflection-only: never invokes anything. */
1168
+ function _probeCtx(label, ctx) {
1169
+ const names = [
1170
+ "fork",
1171
+ "navigateTree",
1172
+ "newSession",
1173
+ "switchSession",
1174
+ "compact",
1175
+ "shutdown",
1176
+ "sendMessage",
1177
+ "prompt",
1178
+ "clearQueue",
1179
+ ];
1180
+ const parts = [];
1181
+ // SAFETY: per-property try/catch — a throwing getter (guarded proxy)
1182
+ // reports "throws" instead of failing the whole probe.
1183
+ const target = ctx;
1184
+ for (const name of names) {
2051
1185
  try {
2052
- outer = JSON.parse(line);
1186
+ const v = target[name];
1187
+ parts.push(`${name}=${typeof v === "function" ? "fn" : String(v)}`);
2053
1188
  }
2054
1189
  catch {
2055
- return;
1190
+ parts.push(`${name}=throws`);
2056
1191
  }
2057
- if (!outer.peer || !outer.ct)
2058
- return;
2059
- if (!hasListenerAuthority())
2060
- return;
2061
- // Already-attached owners: their PlainPeerChannel handles routing.
2062
- if (_activePeers.has(outer.peer))
2063
- return;
2064
- // Decode inner envelope (base64 JSON)
2065
- let inner;
2066
- try {
2067
- const plaintext = Buffer.from(outer.ct, "base64").toString("utf8");
2068
- const parsed = JSON.parse(plaintext);
2069
- if (!parsed ||
2070
- typeof parsed !== "object" ||
2071
- typeof parsed.type !== "string")
2072
- return;
2073
- inner = parsed;
2074
- }
2075
- catch {
2076
- return;
2077
- }
2078
- const appPeerId = outer.peer;
2079
- if (inner.type === "pair_request") {
2080
- await _handlePairRequest(relay, appPeerId, inner, hasListenerAuthority);
2081
- return;
2082
- }
2083
- // Reconnect path: known peer (peers.json) without an active channel
2084
- // sends a non-pair message → attach + route through the new channel.
2085
- // See pairing.md §Reconexão.
2086
- const known = await _findKnownPeer(appPeerId);
2087
- if (!hasListenerAuthority())
2088
- return;
2089
- if (known) {
2090
- const channel = _attachOwner(relay, appPeerId, known.name);
2091
- // The channel listener didn't see the line that triggered the attach, so
2092
- // route it explicitly — MIRRORING the channel's own dispatch (peer_channel
2093
- // _onLine): a real-typed envelope ("rpc"/"evt"/"ub", legacy "env") or a
2094
- // bare rpc/evt/ub body goes to the envelope dispatcher, a stock
2095
- // ClientMessage to the stock switch. Everything is on the envelope proto
2096
- // now, so the first message is normally the ub session_sync (or the rpc
2097
- // get_entries) — routing that through the stock switch dropped it. Use
2098
- // _liveCtx (session_start-fresh), not #55.
2099
- const innerObj = inner;
2100
- if (isEnvelopeFrame(innerObj)) {
2101
- {
2102
- // SAFETY: isEnvelopeFrame confirmed rpc/evt/ub envelope keys are
2103
- // present, so this ClientMessage is byte-compatible with EnvelopeMessage.
2104
- const innerEnv = inner;
2105
- if (innerEnv.ub === undefined)
2106
- _routeRpcCommandFrom(channel, innerEnv);
2107
- else
2108
- _routeUnBienPlaneFrom(channel, innerEnv);
2109
- }
2110
- }
2111
- else {
2112
- _routeClientMessageFrom(channel, inner, _liveCtx() ?? _noopCtx);
2113
- }
2114
- return;
2115
- }
2116
- // Unknown peer with non-pair_request inner — signal so the app can react
2117
- // (peer was revoked / never paired). pair_request from unknown peer was
2118
- // already handled above as a legitimate path. We never log inner contents,
2119
- // only inner.type.
2120
- const errReply = {
2121
- type: "error",
2122
- code: "unknown_peer",
2123
- message: "Peer not paired — re-scan QR",
2124
- };
2125
- const errCt = Buffer.from(JSON.stringify(errReply)).toString("base64");
2126
- relay.send(JSON.stringify({ peer: appPeerId, ct: errCt }));
2127
- };
2128
- relay.on("message", onMsg);
2129
- return () => relay.off("message", onMsg);
2130
- }
2131
- /**
2132
- * Plan/27 Wave A: lazily resolve the pi-extension package version from
2133
- * disk so the `pair_ok.harness.version` field reflects what's actually
2134
- * shipped. The lookup is best-effort — a parse failure (or running this
2135
- * file out-of-tree) falls back to "0.0.0" which is still semver-valid
2136
- * and the app tolerates it. Cached at module load.
2137
- */
2138
- function _readExtensionVersion() {
2139
- try {
2140
- const here = fileURLToPath(import.meta.url);
2141
- // dist/index.js → ../package.json. src/index.ts under tsx → also one level up.
2142
- const pkgPath = join(here, "..", "..", "package.json");
2143
- const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
2144
- return typeof pkg.version === "string" ? pkg.version : "0.0.0";
2145
- }
2146
- catch {
2147
- return "0.0.0";
2148
- }
2149
- }
2150
- const _HARNESS = {
2151
- name: "Pi coding agent",
2152
- version: _readExtensionVersion(),
2153
- };
2154
- const _HOSTNAME = hostname();
2155
- // un-bien capability handshake. PROTOCOL_VERSION bumps on a HARD (breaking)
2156
- // wire change; the app gates UI on capability PRESENCE, not this number.
2157
- const PROTOCOL_VERSION = 1;
2158
- // Features this extension supports, advertised on attach (session_history) + pair_ok.
2159
- // `remote_launch` is conditional (added only when local config opts in) — see
2160
- // `_capabilities()`. Passive server->app extras (images/panels) are listed so
2161
- // the app can also gate any future *controls* it grows for them.
2162
- const _BASE_CAPABILITIES = [
2163
- "thinking",
2164
- "models",
2165
- "cancel",
2166
- "queued_messages",
2167
- "images",
2168
- "tool_result_images",
2169
- "panels",
2170
- "rpc_envelope",
2171
- ];
2172
- /** The capability set to advertise right now (config-dependent bits included). */
2173
- function _capabilities() {
2174
- const caps = [..._BASE_CAPABILITIES];
2175
- // `remote_launch` is advertised ONLY when the machine opts in via local
2176
- // config — single choke point so the advertised set and honored behavior
2177
- // can't drift. Read the session cwd's config (pi runs in the session cwd).
2178
- if (effectiveAllowRemoteLaunch(loadLocalConfig(process.cwd()))) {
2179
- caps.push("remote_launch");
2180
1192
  }
2181
- return caps;
1193
+ envLog(`${label} targeted: ${parts.join(" ")}`);
2182
1194
  }
2183
- async function _handlePairRequest(relay, appPeerId, inner, hasListenerAuthority) {
2184
- const sendInner = (msg) => {
2185
- const ct = Buffer.from(JSON.stringify(msg)).toString("base64");
2186
- relay.send(JSON.stringify({ peer: appPeerId, ct }));
2187
- };
2188
- const sendError = (code, message) => {
2189
- sendInner({ type: "pair_error", in_reply_to: inner.id, code, message });
2190
- };
2191
- const status = qrSession.consumeToken(inner.token);
2192
- if (status !== "ok") {
2193
- const code = status === "expired"
2194
- ? "token_expired"
2195
- : status === "consumed"
2196
- ? "token_consumed"
2197
- : "token_unknown";
2198
- const msg = code === "token_expired"
2199
- ? "Ephemeral token expired. Generate a new QR with /unbien pair."
2200
- : code === "token_consumed"
2201
- ? "Token already consumed by another pair_request."
2202
- : "Token was not issued by this Pi.";
2203
- sendError(code, msg);
2204
- return;
2205
- }
2206
- // A delayed signed revoke must lose authority before the same-process
2207
- // re-pair enters storage; the replacement owns a fresh token snapshot.
2208
- const producer = _selfRevoke;
2209
- const producerEpoch = _selfRevokeEpoch;
2210
- producer?.invalidateStorageAuthority();
2211
- const pairedAt = new Date().toISOString();
2212
- try {
2213
- await addPeer({
2214
- name: inner.device_name,
2215
- remote_epk: appPeerId,
2216
- paired_at: pairedAt,
2217
- });
2218
- if (!hasListenerAuthority())
2219
- return;
2220
- _refreshPairingsCache();
2221
- if (producer &&
2222
- _selfRevoke === producer &&
2223
- _selfRevokeEpoch === producerEpoch) {
2224
- void producer.requestFreshCheck().catch(() => {
2225
- // The regular cadence retries; pairing itself already succeeded.
2226
- });
2227
- }
2228
- }
2229
- catch (err) {
2230
- if (!hasListenerAuthority())
2231
- return;
2232
- sendError("internal_error", `Failed to persist peer: ${String(err)}`);
2233
- return;
2234
- }
2235
- const cwd = _lastCtx && "cwd" in _lastCtx
2236
- ? _lastCtx.cwd
2237
- : process.cwd();
2238
- // Prefer the user-configured agent_name (with broker suffix when on the
2239
- // mesh) over the legacy parent/folder path — matches what the user sees
2240
- // in the terminal title and in /unbien status.
2241
- const sessionName = _displayName(cwd);
2242
- _attachOwner(relay, appPeerId, inner.device_name);
2243
- sendInner({
2244
- type: "pair_ok",
2245
- in_reply_to: inner.id,
2246
- session_name: sessionName,
2247
- session_started_at: _sessionStartedAt ?? Date.now(),
2248
- // App uses this to address subsequent inner messages to the right room
2249
- // when this Pi runs alongside others with the same epk. Defensive fallback
2250
- // to roomIdFor(cwd, name) covers the edge case where pair_request lands
2251
- // before _cmdStart could set _myRoomId (shouldn't happen in practice) —
2252
- // and stays plan/41-consistent (same (cwd, name) derivation as the announce).
2253
- room_id: _myRoomId ?? _deriveRoomId(cwd, sessionName),
2254
- // Plan/27 Wave A — surface the host coding-agent identity + machine
2255
- // hostname so the app can render a meaningful device row (and tell
2256
- // two PCs apart even when nicknames collide).
2257
- harness: _HARNESS,
2258
- hostname: _HOSTNAME,
2259
- protocol_version: PROTOCOL_VERSION,
2260
- capabilities: _capabilities(),
2261
- });
2262
- // Notify local RPC clients (e.g. Cockpit) that pairing completed, so they can
2263
- // close the QR screen and show the new device. Pure data event (display:false)
2264
- // — still emitted to the RPC stdout via the session stream.
2265
- _pi?.sendMessage({
2266
- customType: "un-bien:paired",
2267
- content: `Paired with ${inner.device_name}`,
2268
- details: { name: inner.device_name, peerId: appPeerId, pairedAt },
2269
- display: false,
2270
- });
2271
- }
2272
- // ── Extension factory (default export) ───────────────────────────────────────
2273
- // Stores most recent command context so the auto-listener can use ui.notify.
2274
- // NOTE: this is a CAPTURED command ctx — the SDK marks it stale after a
2275
- // session replacement (newSession/fork/switch/reload). We re-capture it via
2276
- // `withSession` when WE drive a newSession (see the session_new dispatch).
2277
- let _lastCtx = null;
2278
1195
  // Freshest base ExtensionContext, re-captured on EVERY `session_start`
2279
1196
  // (startup/new/fork/reload/resume). The session_start ctx is always bound to
2280
1197
  // the CURRENT session, so compact + cancel (base-ctx methods) routed through
2281
1198
  // here never hit a stale ctx — regardless of who triggered the replacement
2282
1199
  // (an app Quick Action OR a `/new` typed in the Pi TUI). It carries only
2283
- // base-ctx methods (no newSession — that's command-ctx only), so command ops
2284
- // keep using `_lastCtx`.
1200
+ // base-ctx methods (no newSession — that's command-ctx only); command-only ops
1201
+ // (fork/new/branch) reach a command ctx by self-dispatching a slash command
1202
+ // instead of retaining one.
2285
1203
  let _lastEventCtx = null;
2286
1204
  const _noopCtx = { ui: { notify: () => undefined }, abort: () => undefined };
2287
1205
  // A single Pi process can load this extension TWICE in the SAME session:
@@ -2341,6 +1259,466 @@ function _releaseRootSession(pi) {
2341
1259
  if (g[_ROOT_SESSION_OWNER_KEY] === pi)
2342
1260
  delete g[_ROOT_SESSION_OWNER_KEY];
2343
1261
  }
1262
+ // ── Received-image pipeline seam ──────────────────────────────────────────
1263
+ //
1264
+ // The image-preview domain lives in ./session/received_images.ts and reaches
1265
+ // this module's state + helpers ONLY through this object (that module never
1266
+ // imports ../index.js — no circular imports). Same pattern as CommandDeps.
1267
+ const imageDeps = {
1268
+ get pi() {
1269
+ return _pi;
1270
+ },
1271
+ rootState: _rootState,
1272
+ get myRoomMeta() {
1273
+ return _myRoomMeta;
1274
+ },
1275
+ wakeAgent: _wakeAgent,
1276
+ };
1277
+ // ── Rpc-inbound handler seam ──────────────────────────────────────────────
1278
+ //
1279
+ // The rpc-command handler implementations live in ./session/rpc_handlers.ts
1280
+ // (a factory this module's `_routeRpcCommandFrom` — which owns channel/sender
1281
+ // routing — calls per dispatch). Same pattern as CommandDeps: that module
1282
+ // never imports ../index.js — no circular imports.
1283
+ const rpcDeps = {
1284
+ get pi() {
1285
+ return _pi;
1286
+ },
1287
+ rootState: _rootState,
1288
+ get lastEventCtx() {
1289
+ return _lastEventCtx;
1290
+ },
1291
+ imageDeps,
1292
+ wakeAgent: _wakeAgent,
1293
+ abortCurrentTurn: () => _abortCurrentTurn(),
1294
+ };
1295
+ // ── Relay lifecycle seam ──────────────────────────────────────────────────
1296
+ //
1297
+ // The relay lifecycle + owner-management code lives in
1298
+ // ./session/relay_lifecycle.ts and reaches this module's state and helpers
1299
+ // ONLY through this object (that module never imports ../index.js — no
1300
+ // circular imports). Same pattern as CommandDeps. The two wrappers below
1301
+ // preserve the exact `_handleControl` / `_onPeerDisconnect` export signatures
1302
+ // the integration tests import.
1303
+ const relayDeps = {
1304
+ get state() {
1305
+ return _state;
1306
+ },
1307
+ set state(v) {
1308
+ _state = v;
1309
+ },
1310
+ get relay() {
1311
+ return _relay;
1312
+ },
1313
+ set relay(v) {
1314
+ _relay = v;
1315
+ },
1316
+ get relayUrl() {
1317
+ return _relayUrl;
1318
+ },
1319
+ set relayUrl(v) {
1320
+ _relayUrl = v;
1321
+ },
1322
+ get peerShort() {
1323
+ return _peerShort;
1324
+ },
1325
+ set peerShort(v) {
1326
+ _peerShort = v;
1327
+ },
1328
+ get stopAutoListener() {
1329
+ return _stopAutoListener;
1330
+ },
1331
+ set stopAutoListener(v) {
1332
+ _stopAutoListener = v;
1333
+ },
1334
+ get selfRevoke() {
1335
+ return _selfRevoke;
1336
+ },
1337
+ set selfRevoke(v) {
1338
+ _selfRevoke = v;
1339
+ },
1340
+ get selfRevokeEpoch() {
1341
+ return _selfRevokeEpoch;
1342
+ },
1343
+ set selfRevokeEpoch(v) {
1344
+ _selfRevokeEpoch = v;
1345
+ },
1346
+ get selfRevokeTopologyReadyEpoch() {
1347
+ return _selfRevokeTopologyReadyEpoch;
1348
+ },
1349
+ set selfRevokeTopologyReadyEpoch(v) {
1350
+ _selfRevokeTopologyReadyEpoch = v;
1351
+ },
1352
+ get selfRevokeTopology() {
1353
+ return _selfRevokeTopology;
1354
+ },
1355
+ set selfRevokeTopology(v) {
1356
+ _selfRevokeTopology = v;
1357
+ },
1358
+ get rootLifecycleGeneration() {
1359
+ return _rootLifecycleGeneration;
1360
+ },
1361
+ set rootLifecycleGeneration(v) {
1362
+ _rootLifecycleGeneration = v;
1363
+ },
1364
+ get meshNode() {
1365
+ return _meshNode;
1366
+ },
1367
+ get cachedEd25519() {
1368
+ return _cachedEd25519;
1369
+ },
1370
+ get myRoomId() {
1371
+ return _myRoomId;
1372
+ },
1373
+ get myRoomMeta() {
1374
+ return _myRoomMeta;
1375
+ },
1376
+ get sessionStartedAt() {
1377
+ return _sessionStartedAt;
1378
+ },
1379
+ sessionCwd: () => {
1380
+ const sm = _rootState().sessionManager;
1381
+ return sm && typeof sm.getCwd === "function" ? sm.getCwd() : process.cwd();
1382
+ },
1383
+ get disposed() {
1384
+ return _disposed;
1385
+ },
1386
+ get pi() {
1387
+ return _pi;
1388
+ },
1389
+ get rootSessionId() {
1390
+ return _rootSessionId;
1391
+ },
1392
+ get commandDeps() {
1393
+ return deps;
1394
+ },
1395
+ activePeers: _activePeers,
1396
+ rootState: _rootState,
1397
+ getState: _getState,
1398
+ refreshFooter: _refreshFooter,
1399
+ safeNotify: _safeNotify,
1400
+ refreshPairingsCache: _refreshPairingsCache,
1401
+ displayName: _displayName,
1402
+ attachBridgeIfReady: _attachBridgeIfReady,
1403
+ routeClientMessageFrom: _routeClientMessageFrom,
1404
+ routeRpcCommandFrom: _routeRpcCommandFrom,
1405
+ routeUnBienPlaneFrom: _routeUnBienPlaneFrom,
1406
+ liveCtx: _liveCtx,
1407
+ noopCtx: _noopCtx,
1408
+ renameAgent: _renameAgent,
1409
+ };
1410
+ export async function _handleControl(cmd) {
1411
+ await _handleControlImpl(relayDeps, cmd);
1412
+ }
1413
+ export function _onPeerDisconnect(appPeerId) {
1414
+ _onPeerDisconnectImpl(relayDeps, appPeerId);
1415
+ }
1416
+ // ── Command seam ──────────────────────────────────────────────────────────────
1417
+ //
1418
+ // The /unbien handlers live in ./commands/ and reach this module's state
1419
+ // and helpers ONLY through this object (command modules never import
1420
+ // ../index.js — no circular imports). Mutable module state is exposed as
1421
+ // accessor closures so handlers and this module always see the same
1422
+ // variables.
1423
+ const deps = {
1424
+ get state() {
1425
+ return _state;
1426
+ },
1427
+ set state(v) {
1428
+ _state = v;
1429
+ },
1430
+ get relay() {
1431
+ return _relay;
1432
+ },
1433
+ set relay(v) {
1434
+ _relay = v;
1435
+ },
1436
+ get relayUrl() {
1437
+ return _relayUrl;
1438
+ },
1439
+ set relayUrl(v) {
1440
+ _relayUrl = v;
1441
+ },
1442
+ get meshNode() {
1443
+ return _meshNode;
1444
+ },
1445
+ set meshNode(v) {
1446
+ _meshNode = v;
1447
+ },
1448
+ get sessionName() {
1449
+ return _sessionName;
1450
+ },
1451
+ set sessionName(v) {
1452
+ _sessionName = v;
1453
+ },
1454
+ get sessionPeerCount() {
1455
+ return _sessionPeerCount;
1456
+ },
1457
+ set sessionPeerCount(v) {
1458
+ _sessionPeerCount = v;
1459
+ },
1460
+ get cachedEd25519() {
1461
+ return _cachedEd25519;
1462
+ },
1463
+ set cachedEd25519(v) {
1464
+ _cachedEd25519 = v;
1465
+ },
1466
+ get currentModel() {
1467
+ return _currentModel;
1468
+ },
1469
+ set currentModel(v) {
1470
+ _currentModel = v;
1471
+ },
1472
+ get currentThinking() {
1473
+ return _currentThinking;
1474
+ },
1475
+ set currentThinking(v) {
1476
+ _currentThinking = v;
1477
+ },
1478
+ get myRoomId() {
1479
+ return _myRoomId;
1480
+ },
1481
+ set myRoomId(v) {
1482
+ _myRoomId = v;
1483
+ },
1484
+ get myRoomMeta() {
1485
+ return _myRoomMeta;
1486
+ },
1487
+ set myRoomMeta(v) {
1488
+ _myRoomMeta = v;
1489
+ },
1490
+ get peerShort() {
1491
+ return _peerShort;
1492
+ },
1493
+ set peerShort(v) {
1494
+ _peerShort = v;
1495
+ },
1496
+ get sessionStartedAt() {
1497
+ return _sessionStartedAt;
1498
+ },
1499
+ set sessionStartedAt(v) {
1500
+ _sessionStartedAt = v;
1501
+ },
1502
+ get cwdLock() {
1503
+ return _cwdLock;
1504
+ },
1505
+ set cwdLock(v) {
1506
+ _cwdLock = v;
1507
+ },
1508
+ get lockedName() {
1509
+ return _lockedName;
1510
+ },
1511
+ set lockedName(v) {
1512
+ _lockedName = v;
1513
+ },
1514
+ get relayLifecycleGeneration() {
1515
+ return _getRelayLifecycleGeneration();
1516
+ },
1517
+ set relayLifecycleGeneration(v) {
1518
+ _setRelayLifecycleGeneration(v);
1519
+ },
1520
+ get relayStartDeferred() {
1521
+ return _relayStartDeferred;
1522
+ },
1523
+ set relayStartDeferred(v) {
1524
+ _relayStartDeferred = v;
1525
+ },
1526
+ get rootLifecycleGeneration() {
1527
+ return _rootLifecycleGeneration;
1528
+ },
1529
+ set rootLifecycleGeneration(v) {
1530
+ _rootLifecycleGeneration = v;
1531
+ },
1532
+ get meshJoinGeneration() {
1533
+ return _meshJoinGeneration;
1534
+ },
1535
+ set meshJoinGeneration(v) {
1536
+ _meshJoinGeneration = v;
1537
+ },
1538
+ get selfRevoke() {
1539
+ return _selfRevoke;
1540
+ },
1541
+ set selfRevoke(v) {
1542
+ _selfRevoke = v;
1543
+ },
1544
+ get selfRevokeEpoch() {
1545
+ return _selfRevokeEpoch;
1546
+ },
1547
+ set selfRevokeEpoch(v) {
1548
+ _selfRevokeEpoch = v;
1549
+ },
1550
+ get selfRevokeTopologyReadyEpoch() {
1551
+ return _selfRevokeTopologyReadyEpoch;
1552
+ },
1553
+ set selfRevokeTopologyReadyEpoch(v) {
1554
+ _selfRevokeTopologyReadyEpoch = v;
1555
+ },
1556
+ get selfRevokeTopology() {
1557
+ return _selfRevokeTopology;
1558
+ },
1559
+ set selfRevokeTopology(v) {
1560
+ _selfRevokeTopology = v;
1561
+ },
1562
+ get stopAutoListener() {
1563
+ return _stopAutoListener;
1564
+ },
1565
+ set stopAutoListener(v) {
1566
+ _stopAutoListener = v;
1567
+ },
1568
+ get hasGlobalPairings() {
1569
+ return _hasGlobalPairings;
1570
+ },
1571
+ get disposed() {
1572
+ return _disposed;
1573
+ },
1574
+ get lastEventCtx() {
1575
+ return _lastEventCtx;
1576
+ },
1577
+ get pi() {
1578
+ return _pi;
1579
+ },
1580
+ get rootSessionId() {
1581
+ return _rootSessionId;
1582
+ },
1583
+ activePeers: _activePeers,
1584
+ isCurrentRootLifecycle: _isCurrentRootLifecycle,
1585
+ displayName: _displayName,
1586
+ deriveRoomId: _deriveRoomId,
1587
+ currentModelName: _currentModelName,
1588
+ rootState: _rootState,
1589
+ goIdle: () => _goIdle(relayDeps),
1590
+ onRelayClose: (closedRelay) => _onRelayClose(relayDeps, closedRelay),
1591
+ installAutoListener: (relay) => _installAutoListener(relayDeps, relay),
1592
+ refreshFooter: _refreshFooter,
1593
+ revokeActiveOwnerRuntime: _revokeActiveOwnerRuntime,
1594
+ attachBridgeIfReady: _attachBridgeIfReady,
1595
+ emitRelayState: (force) => _emitRelayState(relayDeps, force),
1596
+ refreshSessionPeerCount: _refreshSessionPeerCount,
1597
+ deliverMeshMessageToAgent: _deliverMeshMessageToAgent,
1598
+ refreshPairingsCache: _refreshPairingsCache,
1599
+ detachPeerChannel: (appPeerId) => _detachPeerChannel(relayDeps, appPeerId),
1600
+ handleControl: _handleControl,
1601
+ relayStatus: () => _relayStatus(relayDeps),
1602
+ getState: _getState,
1603
+ runTestScenario: _runTestScenario,
1604
+ safeNotify: _safeNotify,
1605
+ renameAgent: _renameAgent,
1606
+ };
1607
+ // ── Test-only hooks seam ──────────────────────────────────────────────────────
1608
+ //
1609
+ // The _xForTest surface lives in ./test_hooks.ts (a factory over this module's
1610
+ // state + helpers — that module never imports ../index.js). The one-line
1611
+ // re-exports below preserve the exact export names every existing test
1612
+ // (extension.test.ts + the colocated command tests) imports.
1613
+ const _testHooks = createTestHooks({
1614
+ commandDeps: deps,
1615
+ get disposed() {
1616
+ return _disposed;
1617
+ },
1618
+ set disposed(v) {
1619
+ _disposed = v;
1620
+ },
1621
+ set autoInited(v) {
1622
+ _autoInited = v;
1623
+ },
1624
+ get panelBridge() {
1625
+ return _panelBridge;
1626
+ },
1627
+ set panelBridge(v) {
1628
+ _panelBridge = v;
1629
+ },
1630
+ get rpcEnvelope() {
1631
+ return _rpcEnvelope;
1632
+ },
1633
+ set rpcEnvelope(v) {
1634
+ _rpcEnvelope = v;
1635
+ },
1636
+ get subagentRooms() {
1637
+ return _subagentRooms;
1638
+ },
1639
+ set subagentRooms(v) {
1640
+ _subagentRooms = v;
1641
+ },
1642
+ get extensionUiBridge() {
1643
+ return _extensionUiBridge;
1644
+ },
1645
+ set extensionUiBridge(v) {
1646
+ _extensionUiBridge = v;
1647
+ },
1648
+ set rootSessionId(v) {
1649
+ _rootSessionId = v;
1650
+ },
1651
+ get cwdLock() {
1652
+ return _cwdLock;
1653
+ },
1654
+ set cwdLock(v) {
1655
+ _cwdLock = v;
1656
+ },
1657
+ get lockedName() {
1658
+ return _lockedName;
1659
+ },
1660
+ set lockedName(v) {
1661
+ _lockedName = v;
1662
+ },
1663
+ set sessionStartedAt(v) {
1664
+ _sessionStartedAt = v;
1665
+ },
1666
+ set currentModel(v) {
1667
+ _currentModel = v;
1668
+ },
1669
+ set pi(v) {
1670
+ _pi = v;
1671
+ },
1672
+ set relayStartDeferred(v) {
1673
+ _relayStartDeferred = v;
1674
+ },
1675
+ rootSessionOwnerKey: _ROOT_SESSION_OWNER_KEY,
1676
+ sessions: _sessions,
1677
+ get meshNode() {
1678
+ return _meshNode;
1679
+ },
1680
+ get selfRevoke() {
1681
+ return _selfRevoke;
1682
+ },
1683
+ get cachedEd25519() {
1684
+ return _cachedEd25519;
1685
+ },
1686
+ get reconnectTimer() {
1687
+ return _getReconnectTimer();
1688
+ },
1689
+ activePeers: _activePeers,
1690
+ rootState: _rootState,
1691
+ seedRootSession(sid) {
1692
+ _rootSessionId = sid;
1693
+ _stateFor(sid).sessionManager = {
1694
+ getSessionId: () => sid,
1695
+ };
1696
+ },
1697
+ deliverMeshMessageToAgent: _deliverMeshMessageToAgent,
1698
+ });
1699
+ export const _connectForTest = _testHooks.connectForTest;
1700
+ export const _stopForTest = _testHooks.stopForTest;
1701
+ export const _getDisposedForTest = _testHooks.getDisposedForTest;
1702
+ export const _setDisposedForTest = _testHooks.setDisposedForTest;
1703
+ export const _resetAutoInitedForTest = _testHooks.resetAutoInitedForTest;
1704
+ export const _resetBridgeOwnersForTest = _testHooks.resetBridgeOwnersForTest;
1705
+ export const _resetSessionsForTest = _testHooks.resetSessionsForTest;
1706
+ export const _seedRootSessionForTest = _testHooks.seedRootSessionForTest;
1707
+ export const _setAutoInitedForTest = _testHooks.setAutoInitedForTest;
1708
+ export const _hasMeshNodeForTest = _testHooks.hasMeshNodeForTest;
1709
+ export const _checkSelfRevokeForTest = _testHooks.checkSelfRevokeForTest;
1710
+ export const _getLockedNameForTest = _testHooks.getLockedNameForTest;
1711
+ export const _resetCwdLockForTest = _testHooks.resetCwdLockForTest;
1712
+ export const _startRelayForTest = _testHooks.startRelayForTest;
1713
+ export const _getCachedPublicKeyForTest = _testHooks.getCachedPublicKeyForTest;
1714
+ export const _setSessionStartedAtForTest = _testHooks.setSessionStartedAtForTest;
1715
+ export const _setCurrentModelForTest = _testHooks.setCurrentModelForTest;
1716
+ export const _getCurrentTurnIdForTest = _testHooks.getCurrentTurnIdForTest;
1717
+ export const _setPiForTest = _testHooks.setPiForTest;
1718
+ export const _hasPendingReconnect = _testHooks.hasPendingReconnect;
1719
+ export const _getActivePeerCountForTest = _testHooks.getActivePeerCountForTest;
1720
+ export const _hasActivePeerForTest = _testHooks.hasActivePeerForTest;
1721
+ export const _deliverMeshMessageToAgentForTest = _testHooks.deliverMeshMessageToAgentForTest;
2344
1722
  const extension = (pi) => {
2345
1723
  const applied = _appliedRegistry();
2346
1724
  if (applied.has(pi))
@@ -2365,13 +1743,13 @@ const extension = (pi) => {
2365
1743
  if (_claimRootSession(pi)) {
2366
1744
  _pi = pi;
2367
1745
  _extensionUiBridge?.dispose();
2368
- _extensionUiBridge = createExtensionUiBridge(pi, _uiBroadcast);
1746
+ _extensionUiBridge = createExtensionUiBridge(pi, (msg) => _uiBroadcast(relayDeps, msg));
2369
1747
  _panelBridge?.dispose();
2370
- _panelBridge = createPanelBridge(pi, _panelBroadcast, {
1748
+ _panelBridge = createPanelBridge(pi, (msg) => _panelBroadcast(relayDeps, msg), {
2371
1749
  suppressAgents: subagentRoomsEnabled(),
2372
1750
  });
2373
1751
  _rpcEnvelope?.dispose();
2374
- _rpcEnvelope = createRpcEnvelope(pi, _broadcastEnvelope, {
1752
+ _rpcEnvelope = createRpcEnvelope(pi, (env) => _broadcastEnvelope(relayDeps, env), {
2375
1753
  enrichArgs: (tool, args) => {
2376
1754
  const e = _enrichToolArgs(tool, args, _resolveToolCwd());
2377
1755
  return Array.isArray(e.hunks) ? { hunks: e.hunks } : null;
@@ -2381,11 +1759,11 @@ const extension = (pi) => {
2381
1759
  _subagentRooms = initSubagentRooms(pi, {
2382
1760
  getParentRoomId: () => _myRoomId,
2383
1761
  getParentSessionId: () => _rootSessionId,
2384
- broadcastPanel: _panelBroadcast,
1762
+ broadcastPanel: (msg) => _panelBroadcast(relayDeps, msg),
2385
1763
  });
2386
1764
  }
2387
- // Plano 19: ensure ~/.pi/un-bien/{sessions,skills}/ exist and deploy the
2388
- // agent-network skill on first load. resources_discover lets Pi find it.
1765
+ // Plano 19: ensure the state root's `sessions/` + `skills/` dirs exist and
1766
+ // deploy the agent-network skill on first load. resources_discover lets Pi find it.
2389
1767
  try {
2390
1768
  ensureGlobalDirs();
2391
1769
  _deployAgentNetworkSkill();
@@ -2427,7 +1805,7 @@ const extension = (pi) => {
2427
1805
  void _handleControl(event.text.slice(CTRL_PREFIX.length).trim());
2428
1806
  return { action: "handled" };
2429
1807
  }
2430
- if (!_anyPeerActive())
1808
+ if (!_anyPeerActive(relayDeps))
2431
1809
  return;
2432
1810
  if (event.source === "extension")
2433
1811
  return;
@@ -2508,7 +1886,7 @@ const extension = (pi) => {
2508
1886
  // only clear the turn id used for queue/turn correlation.
2509
1887
  if (st.turnId)
2510
1888
  st.turnId = null;
2511
- _flushPendingReceivedImagePreviews();
1889
+ _flushPendingReceivedImagePreviews(imageDeps);
2512
1890
  settleRun();
2513
1891
  });
2514
1892
  // plan/34: the broker no longer gates delivery on busy state, so we no
@@ -2516,6 +1894,17 @@ const extension = (pi) => {
2516
1894
  // room_meta over the relay (plan/32) below — that's independent of the
2517
1895
  // broker and drives the app's working indicator.
2518
1896
  pi.on("turn_start", (_event, ctx) => {
1897
+ // One-shot TURN CTX TARGETED PROBE (see the session_start probe note):
1898
+ // per-property, proxy-safe. Turn ctxs fire while the agent RUNS — if
1899
+ // they carry the session-control powers, the always-warm capture
1900
+ // replaces the stash entirely.
1901
+ if (!_turnCtxProbed) {
1902
+ _turnCtxProbed = true;
1903
+ if (ctx)
1904
+ _probeCtx("turn ctx", ctx);
1905
+ else
1906
+ envLog("turn ctx targeted: ctx is undefined at turn_start");
1907
+ }
2519
1908
  const sid = _sidOf(ctx);
2520
1909
  const st = _stateFor(sid);
2521
1910
  // Each session records its OWN sessionManager (no cross-session clobber).
@@ -2594,6 +1983,48 @@ const extension = (pi) => {
2594
1983
  if (!_isNonRootSid(_sidOf(ctx)))
2595
1984
  _publishWorking(false);
2596
1985
  });
1986
+ // RENAME FORWARD (pre-release 2026-09-18): the session display name
1987
+ // changed — via /name in the TUI, RPC, or the app's own set_session_name
1988
+ // command (pi's native verb; the reply IS the standard rpc response). Two
1989
+ // effects: (1) forward to the app over the relay-OPAQUE envelope plane
1990
+ // (works on every deployed relay — the relay's room_meta_update patch only
1991
+ // merges its typed fields, so the control plane CANNOT carry a name); (2)
1992
+ // refresh the local announce state so the NEXT hello re-announces the fresh
1993
+ // name — the relay's stored meta self-heals at the extension's next connect.
1994
+ // Root sessions only (v1): a subagent child's rename gets its own forward
1995
+ // when child renames matter.
1996
+ pi.on("session_info_changed", (event, ctx) => {
1997
+ const name = event?.name;
1998
+ if (typeof name !== "string" || name.length === 0)
1999
+ return;
2000
+ if (_isNonRootSid(_sidOf(ctx)))
2001
+ return;
2002
+ _sessionName = name;
2003
+ if (_myRoomMeta)
2004
+ _myRoomMeta = { ..._myRoomMeta, name };
2005
+ _broadcastEnvelope(relayDeps, {
2006
+ evt: { channel: "session_info", data: { name } },
2007
+ });
2008
+ });
2009
+ // LEAF BEACON on tree navigation (branch). pi fires session_tree AFTER a leaf
2010
+ // move for EVERY source — TUI /tree, edit-resubmit, and our own /unbien branch
2011
+ // (event.fromExtension) — carrying the new leaf directly. Push it so the app
2012
+ // re-derives its active path (evt session_info leafId → SessionState.derivePath
2013
+ // walks parentId from the new leaf over its already-cached tree; no prefetch).
2014
+ // THE single source for all leaf moves: without it a TUI /tree only reflected
2015
+ // on the next get_entries refetch (stale path until then). Root room only — a
2016
+ // subagent's tree move must not re-path the app's root session.
2017
+ pi.on("session_tree", (event, ctx) => {
2018
+ if (_isNonRootSid(_sidOf(ctx)))
2019
+ return;
2020
+ const leafId = event
2021
+ ?.newLeafId;
2022
+ if (typeof leafId === "string" && leafId.length > 0) {
2023
+ _broadcastEnvelope(relayDeps, {
2024
+ evt: { channel: "session_info", data: { leafId } },
2025
+ });
2026
+ }
2027
+ });
2597
2028
  // Re-capture the freshest base ctx on every session replacement so compact
2598
2029
  // never operates on a stale captured ctx — this is the fix for the
2599
2030
  // "stale after session replacement" crash when the app taps Compact after a
@@ -2610,6 +2041,13 @@ const extension = (pi) => {
2610
2041
  // subagent steals the base ctx and root-scoped notifies silently drop.
2611
2042
  if (!_isNonRootSid(sid))
2612
2043
  _lastEventCtx = ctx;
2044
+ // EVENT-CTX TARGETED PROBE (run 2026-09-18, round 3): the ctx objects
2045
+ // are guarded (throwing getters — proxies), so full reflection
2046
+ // under-reports or throws. Per-property access, each caught, is the
2047
+ // proxy-safe probe.
2048
+ if (!_isNonRootSid(sid) && ctx) {
2049
+ _probeCtx("event ctx", ctx);
2050
+ }
2613
2051
  if (ctx?.sessionManager)
2614
2052
  _stateFor(sid).sessionManager = ctx.sessionManager;
2615
2053
  // session_shutdown disposes per-session pi-ask subscriptions. A host that
@@ -2624,13 +2062,13 @@ const extension = (pi) => {
2624
2062
  if (ctx?.sessionManager)
2625
2063
  _rootSessionId = sid;
2626
2064
  if (!_extensionUiBridge)
2627
- _extensionUiBridge = createExtensionUiBridge(pi, _uiBroadcast);
2065
+ _extensionUiBridge = createExtensionUiBridge(pi, (msg) => _uiBroadcast(relayDeps, msg));
2628
2066
  if (!_panelBridge)
2629
- _panelBridge = createPanelBridge(pi, _panelBroadcast, {
2067
+ _panelBridge = createPanelBridge(pi, (msg) => _panelBroadcast(relayDeps, msg), {
2630
2068
  suppressAgents: subagentRoomsEnabled(),
2631
2069
  });
2632
2070
  if (!_rpcEnvelope)
2633
- _rpcEnvelope = createRpcEnvelope(pi, _broadcastEnvelope, {
2071
+ _rpcEnvelope = createRpcEnvelope(pi, (env) => _broadcastEnvelope(relayDeps, env), {
2634
2072
  enrichArgs: (tool, args) => {
2635
2073
  const e = _enrichToolArgs(tool, args, _resolveToolCwd());
2636
2074
  return Array.isArray(e.hunks) ? { hunks: e.hunks } : null;
@@ -2640,7 +2078,7 @@ const extension = (pi) => {
2640
2078
  _subagentRooms = initSubagentRooms(pi, {
2641
2079
  getParentRoomId: () => _myRoomId,
2642
2080
  getParentSessionId: () => _rootSessionId,
2643
- broadcastPanel: _panelBroadcast,
2081
+ broadcastPanel: (msg) => _panelBroadcast(relayDeps, msg),
2644
2082
  });
2645
2083
  }
2646
2084
  else if (_isNonRootSid(sid)) {
@@ -2648,6 +2086,17 @@ const extension = (pi) => {
2648
2086
  // `pi` here is the CHILD's ExtensionAPI (per-activation).
2649
2087
  _subagentRooms?.onChildSession(pi, ctx);
2650
2088
  }
2089
+ // design 01M1CAW0 (announce waits for the session id): a relay start that
2090
+ // was deferred because no session id existed re-runs NOW — the root
2091
+ // session's sessionManager was captured above, so _deriveRoomId has an id
2092
+ // to announce. Root session_starts only (a child never owned the relay);
2093
+ // the flag stays armed when this session_start still carries no id.
2094
+ if (deps.relayStartDeferred && !_isNonRootSid(sid)) {
2095
+ if (_rootState().sessionManager?.getSessionId()) {
2096
+ deps.relayStartDeferred = false;
2097
+ void _cmdStart(deps, ctx);
2098
+ }
2099
+ }
2651
2100
  // Rearm a reused-but-disposed instance. The session_shutdown teardown (below)
2652
2101
  // sets _disposed=true assuming the host re-evaluates THIS module fresh for the
2653
2102
  // replacement session, yielding a new instance with _disposed=false. Some hosts
@@ -2661,7 +2110,7 @@ const extension = (pi) => {
2661
2110
  const restartAuthority = {
2662
2111
  rootLifecycleGeneration: _rootLifecycleGeneration,
2663
2112
  };
2664
- void _cmdRoot(ctx, restartAuthority);
2113
+ void _cmdRoot(deps, ctx, restartAuthority);
2665
2114
  }
2666
2115
  // Auto-start un-bien on a fresh boot when the cwd's local config has
2667
2116
  // auto_start_relay enabled (default true). Covers BOTH interactive
@@ -2702,7 +2151,7 @@ const extension = (pi) => {
2702
2151
  const initCtx = isDaemon
2703
2152
  ? { ui: _headlessUi(), cwd: process.cwd() }
2704
2153
  : ctx;
2705
- void _cmdRoot(initCtx);
2154
+ void _cmdRoot(deps, initCtx);
2706
2155
  }
2707
2156
  }
2708
2157
  });
@@ -2750,7 +2199,7 @@ const extension = (pi) => {
2750
2199
  // clears `_disposed` before its promises settle.
2751
2200
  _disposed = true;
2752
2201
  _rootLifecycleGeneration += 1;
2753
- _relayLifecycleGeneration += 1;
2202
+ _setRelayLifecycleGeneration(_getRelayLifecycleGeneration() + 1);
2754
2203
  _meshJoinGeneration += 1;
2755
2204
  // The bridge owns live pi.events subscriptions + flow TTLs. Dispose before
2756
2205
  // the outgoing session is replaced so stale listeners cannot leak or
@@ -2765,8 +2214,18 @@ const extension = (pi) => {
2765
2214
  // app can mark the session ended. Emit EXPLICITLY here, BEFORE the
2766
2215
  // producer dispose below: `_rpcEnvelope` gates on its `disposed` flag,
2767
2216
  // so once disposed it can no longer build/broadcast this frame.
2768
- if (_anyPeerActive()) {
2769
- _broadcastEnvelope({ rpc: { type: "session_shutdown" } });
2217
+ if (_anyPeerActive(relayDeps)) {
2218
+ // FLUSH-BEFORE-TEARDOWN: await the frame's ws send-completion
2219
+ // (bounded — a wedged socket can't hang the shutdown) BEFORE _goIdle
2220
+ // closes the relay below. Fire-and-forget only beat close() by
2221
+ // incidental FIFO luck; the switch flow (/resume: old instance
2222
+ // broadcasts shutdown, fresh instance takes the new room) NEEDS the
2223
+ // app to reliably mark the old session ended, or it lingers live
2224
+ // forever (the app has no disconnect-implies-ended backstop, by
2225
+ // design).
2226
+ await _broadcastEnvelopeFlushed(relayDeps, {
2227
+ rpc: { type: "session_shutdown" },
2228
+ });
2770
2229
  }
2771
2230
  _extensionUiBridge?.dispose();
2772
2231
  _extensionUiBridge = null;
@@ -2778,11 +2237,9 @@ const extension = (pi) => {
2778
2237
  _subagentRooms = null;
2779
2238
  _releaseRootSession(pi);
2780
2239
  }
2781
- // Drop captured ctxs immediately. On module-reuse hosts the same instance
2782
- // survives session replacement; leaving `_lastCtx` pointing at the now-
2783
- // stale command ctx is what crashed pi in _refreshFooter on peer reconnect
2784
- // (issue #55). session_start re-binds `_lastEventCtx` for the new session.
2785
- _lastCtx = null;
2240
+ // Drop the captured event ctx immediately. On module-reuse hosts the same
2241
+ // instance survives session replacement; the next session_start re-binds
2242
+ // `_lastEventCtx` for the new session.
2786
2243
  _lastEventCtx = null;
2787
2244
  // No bye reason: the process keeps running and the fresh instance re-joins
2788
2245
  // the SAME relay room, so an explicit offline→online flap would be wrong.
@@ -2792,7 +2249,7 @@ const extension = (pi) => {
2792
2249
  _meshNode?.detachBridge();
2793
2250
  }
2794
2251
  else {
2795
- _goIdle();
2252
+ _goIdle(relayDeps);
2796
2253
  }
2797
2254
  const meshNode = _meshNode;
2798
2255
  _meshNode = null;
@@ -2822,232 +2279,7 @@ const extension = (pi) => {
2822
2279
  /* best-effort */
2823
2280
  }
2824
2281
  });
2825
- // ── Commands ──────────────────────────────────────────────────────────────
2826
- //
2827
- // Final surface: 8 commands. Pre-2026-05-23 we had 20 commands covering
2828
- // multi-session UDS + granular relay control; in practice every install
2829
- // converged on one session and the relay was always either fully on or
2830
- // fully off. The simplified surface keeps the day-to-day path one-key
2831
- // (`/unbien`) and exposes only the actions that have distinct user
2832
- // intent: setup, status, stop, pair, devices, revoke, set-relay.
2833
- pi.registerCommand("unbien", {
2834
- description: "Connect (join local mesh + start relay), or run setup on first use",
2835
- getArgumentCompletions: async (prefix) => {
2836
- if (prefix.startsWith("revoke ") || prefix === "revoke") {
2837
- const shortPrefix = prefix === "revoke" ? "" : prefix.slice("revoke ".length);
2838
- return _shortidCompletions(shortPrefix, "revoke ");
2839
- }
2840
- return [
2841
- "setup",
2842
- "status",
2843
- "stop",
2844
- "pair",
2845
- "devices",
2846
- "revoke",
2847
- "rename",
2848
- "set-relay",
2849
- "relay",
2850
- "relay start",
2851
- "relay stop",
2852
- "relay status",
2853
- "relay url",
2854
- "config",
2855
- "identity",
2856
- "identity show",
2857
- "test", // hidden e2e UI harness (dev-only)
2858
- "peers", // plan/25 Wave D — local + cross-PC inventory
2859
- "create",
2860
- "remove",
2861
- "daemons", // daemon registry (plan/26 W1)
2862
- // Fleet ops use the `daemon` prefix so `/unbien stop` keeps
2863
- // meaning "stop this local Pi" — the local UX shipped in plan/25.
2864
- "daemon start",
2865
- "daemon stop",
2866
- "daemon restart",
2867
- "daemon send",
2868
- "daemon status",
2869
- "cron",
2870
- "cron add",
2871
- "cron list",
2872
- "cron remove",
2873
- "cron enable",
2874
- "cron disable",
2875
- "cron run",
2876
- "cron log",
2877
- "install",
2878
- "uninstall", // service install (plan/26 W3)
2879
- ]
2880
- .filter((o) => o.startsWith(prefix))
2881
- .map((o) => ({ value: o, label: o }));
2882
- },
2883
- handler: async (args, ctx) => {
2884
- _lastCtx = ctx;
2885
- const sub = args.trim();
2886
- if (sub === "") {
2887
- await _cmdRoot(ctx);
2888
- }
2889
- else if (sub === "setup") {
2890
- await _cmdSetup(ctx);
2891
- }
2892
- else if (sub === "status") {
2893
- _cmdStatus(ctx);
2894
- }
2895
- else if (sub === "stop") {
2896
- await _cmdStop(ctx);
2897
- }
2898
- else if (sub === "pair" || sub.startsWith("pair ")) {
2899
- await _cmdPair(ctx, sub.slice("pair".length).trim());
2900
- }
2901
- else if (sub === "devices") {
2902
- await _cmdList(ctx);
2903
- }
2904
- else if (sub.startsWith("revoke")) {
2905
- await _cmdRevoke(sub.slice("revoke".length).trim(), ctx);
2906
- }
2907
- else if (sub.startsWith("set-relay")) {
2908
- _cmdSetRelay(sub.slice("set-relay".length).trim(), ctx);
2909
- }
2910
- else if (sub === "relay" || sub.startsWith("relay ")) {
2911
- await _cmdRelay(sub.slice("relay".length).trim(), ctx);
2912
- }
2913
- else if (sub === "config") {
2914
- _cmdConfig(ctx);
2915
- }
2916
- else if (sub === "identity" || sub.startsWith("identity ")) {
2917
- await _cmdIdentity(ctx);
2918
- }
2919
- else if (sub === "test" || sub.startsWith("test ")) {
2920
- // Hidden dev-only e2e UI harness: broadcast canned frames to paired apps.
2921
- _safeNotify(`[un-bien test] ${_runTestScenario(sub.slice("test".length).trim())}`, "info", ctx);
2922
- }
2923
- else if (sub === "rename" || sub.startsWith("rename ")) {
2924
- await _renameAgent(sub.slice("rename".length).trim());
2925
- }
2926
- else if (sub === "peers") {
2927
- await _cmdPeers(ctx);
2928
- }
2929
- else if (sub === "install") {
2930
- _cmdInstall(ctx, { linkCli: true });
2931
- }
2932
- else if (sub === "uninstall") {
2933
- _cmdUninstall(ctx, { linkCli: true });
2934
- }
2935
- else {
2936
- await _cmdRoot(ctx);
2937
- }
2938
- },
2939
- });
2940
- // Nested registrations (one entry per public action). The flat handler
2941
- // above already routes `/unbien <sub>` — these exist for the SDK's
2942
- // command palette and slash-autocomplete in some UI modes.
2943
- pi.registerCommand("unbien setup", {
2944
- description: "Run the setup wizard and update local config",
2945
- handler: async (_, ctx) => {
2946
- _lastCtx = ctx;
2947
- await _cmdSetup(ctx);
2948
- },
2949
- });
2950
- pi.registerCommand("unbien status", {
2951
- description: "Show local mesh + relay status",
2952
- handler: async (_, ctx) => {
2953
- _lastCtx = ctx;
2954
- _cmdStatus(ctx);
2955
- },
2956
- });
2957
- pi.registerCommand("unbien stop", {
2958
- description: "Stop everything (leave local mesh + disconnect relay)",
2959
- handler: async (_, ctx) => {
2960
- _lastCtx = ctx;
2961
- await _cmdStop(ctx);
2962
- },
2963
- });
2964
- pi.registerCommand("unbien pair", {
2965
- description: "Show a QR code to pair a new mobile device (optional: --ttl <seconds>)",
2966
- handler: async (args, ctx) => {
2967
- _lastCtx = ctx;
2968
- await _cmdPair(ctx, args.trim());
2969
- },
2970
- });
2971
- pi.registerCommand("unbien devices", {
2972
- description: "List paired mobile devices",
2973
- handler: async (_, ctx) => {
2974
- _lastCtx = ctx;
2975
- await _cmdList(ctx);
2976
- },
2977
- });
2978
- pi.registerCommand("unbien rename", {
2979
- description: "Rename this agent in the current session (updates mesh + relay room)",
2980
- handler: async (args, ctx) => {
2981
- _lastCtx = ctx;
2982
- await _renameAgent(args.trim());
2983
- },
2984
- });
2985
- pi.registerCommand("unbien revoke", {
2986
- description: "Revoke a paired device by its shortid",
2987
- getArgumentCompletions: async (prefix) => _shortidCompletions(prefix),
2988
- handler: async (args, ctx) => {
2989
- _lastCtx = ctx;
2990
- await _cmdRevoke(args.trim(), ctx);
2991
- },
2992
- });
2993
- pi.registerCommand("unbien set-relay", {
2994
- description: "Persist a new relay URL to user config",
2995
- handler: async (args, ctx) => {
2996
- _lastCtx = ctx;
2997
- _cmdSetRelay(args.trim(), ctx);
2998
- },
2999
- });
3000
- pi.registerCommand("unbien config", {
3001
- description: "Show the effective relay URL and where it came from",
3002
- handler: async (_, ctx) => {
3003
- _lastCtx = ctx;
3004
- _cmdConfig(ctx);
3005
- },
3006
- });
3007
- pi.registerCommand("unbien identity", {
3008
- description: "Show this machine's identity: active EPK (public), backend, and source",
3009
- handler: async (_, ctx) => {
3010
- _lastCtx = ctx;
3011
- await _cmdIdentity(ctx);
3012
- },
3013
- });
3014
- pi.registerCommand("unbien identity show", {
3015
- description: "Show this machine's identity (EPK/backend/source) — alias of `identity`",
3016
- handler: async (_, ctx) => {
3017
- _lastCtx = ctx;
3018
- await _cmdIdentity(ctx);
3019
- },
3020
- });
3021
- pi.registerCommand("unbien relay", {
3022
- description: "Relay control: start | stop | status | url <http(s) url> (no arg toggles)",
3023
- handler: async (args, ctx) => {
3024
- _lastCtx = ctx;
3025
- await _cmdRelay(args.trim(), ctx);
3026
- },
3027
- });
3028
- // Plan/25 Wave D
3029
- pi.registerCommand("unbien peers", {
3030
- description: "List local + cross-PC mesh peers, grouped by PC label",
3031
- handler: async (_, ctx) => {
3032
- _lastCtx = ctx;
3033
- await _cmdPeers(ctx);
3034
- },
3035
- });
3036
- // Service install / uninstall — the launcher daemon as a system service.
3037
- pi.registerCommand("unbien install", {
3038
- description: "Install the un-bien launcher daemon as a system service + link the un-bien CLI (systemd/launchd/Task Scheduler; Windows prompts for admin)",
3039
- handler: async (_, ctx) => {
3040
- _lastCtx = ctx;
3041
- _cmdInstall(ctx, { linkCli: true });
3042
- },
3043
- });
3044
- pi.registerCommand("unbien uninstall", {
3045
- description: "Remove the un-bien launcher daemon system service + the CLI shims (Windows prompts for admin)",
3046
- handler: async (_, ctx) => {
3047
- _lastCtx = ctx;
3048
- _cmdUninstall(ctx, { linkCli: true });
3049
- },
3050
- });
2282
+ registerUnbienCommands(pi, deps);
3051
2283
  // Auto-init now runs from the session_start handler (above), AFTER the
3052
2284
  // SDK calls bindCore(). The original setTimeout(0) here fired before bindCore
3053
2285
  // replaced the throwing action-method stubs, so the first pi.sendMessage in
@@ -3057,960 +2289,6 @@ const extension = (pi) => {
3057
2289
  // interactive pi sessions are on remote automatically — no /unbien needed.
3058
2290
  };
3059
2291
  export default extension;
3060
- // ── Command implementations ───────────────────────────────────────────────────
3061
- /**
3062
- * `/unbien status` — full state snapshot. Two lines: local mesh + relay.
3063
- *
3064
- * Always callable; safe when nothing is up (renders the off variants).
3065
- * Reuses the same icons as the footer so terminal + status output stay
3066
- * visually consistent.
3067
- */
3068
- function _cmdStatus(ctx) {
3069
- const relayUrl = _relayUrl ?? resolveRelayUrl().url ?? "not configured";
3070
- // Mesh line
3071
- let meshLine;
3072
- if (_meshNode) {
3073
- const name = _meshNode.name();
3074
- meshLine = `🟢 Local mesh: connected as "${name}" (${_sessionPeerCount} peer${_sessionPeerCount === 1 ? "" : "s"})`;
3075
- }
3076
- else {
3077
- meshLine = "⚪ Local mesh: not connected";
3078
- }
3079
- // Relay line — paired state is derived from _activePeers.size now.
3080
- let relayLine;
3081
- if (_state === "idle") {
3082
- relayLine = `⚪ Relay: off (${relayUrl}) — run /unbien to start`;
3083
- }
3084
- else if (_activePeers.size > 0) {
3085
- const count = _activePeers.size;
3086
- const shortids = [..._activePeers.keys()]
3087
- .map((peerId) => peerId.slice(0, 8))
3088
- .join(", ");
3089
- relayLine = `🟢 Relay: ${count} owner${count === 1 ? "" : "s"} online (${shortids}) (${relayUrl})`;
3090
- }
3091
- else {
3092
- relayLine = _hasGlobalPairings
3093
- ? `🟢 Relay: on, waiting for an app to connect (${relayUrl})`
3094
- : `🟡 Relay: on, waiting for first pairing (${relayUrl})`;
3095
- }
3096
- ctx.ui.notify(`[un-bien]\n ${meshLine}\n ${relayLine}`, "info");
3097
- }
3098
- /**
3099
- * Plan/25 Wave D: `/unbien peers`.
3100
- *
3101
- * Queries the local broker for the aggregated peer inventory (`list_peers`
3102
- * returns locals + cross-PC entries prefixed with `<pc_label>:`). Formats
3103
- * the result grouped by source so users can see at a glance who's on
3104
- * their machine vs. on a paired sibling Pi.
3105
- */
3106
- async function _cmdPeers(ctx) {
3107
- if (!_meshNode) {
3108
- ctx.ui.notify("[un-bien] Not on the local mesh. Run /unbien to join.", "warning");
3109
- return;
3110
- }
3111
- let peers;
3112
- try {
3113
- const reply = await _meshNode.request("broker", { type: "list_peers" }, 2000);
3114
- peers = reply.body?.peers ?? [];
3115
- }
3116
- catch (err) {
3117
- ctx.ui.notify(`[un-bien] peers list failed: ${String(err)}`, "error");
3118
- return;
3119
- }
3120
- // Exclude self from the printed list — `list_peers` returns every peer
3121
- // registered with the broker including the caller, which is noise here.
3122
- const selfName = _meshNode.name();
3123
- ctx.ui.notify(`[un-bien] peers:\n${formatPeerInventory(peers, selfName)}`, "info");
3124
- }
3125
- /**
3126
- * Root handler for `/unbien`. On first run (no local config) drops into
3127
- * the wizard; on subsequent runs auto-joins the local mesh + starts the
3128
- * relay (if opted in during setup), then prints the status.
3129
- *
3130
- * `/unbien` is intentionally the only command users need day-to-day:
3131
- * idempotent connect + status display.
3132
- */
3133
- async function _cmdRoot(ctx, restartAuthority) {
3134
- const rootLifecycleGeneration = restartAuthority?.rootLifecycleGeneration ?? _rootLifecycleGeneration;
3135
- if (_cmdRootInFlight) {
3136
- try {
3137
- await _cmdRootInFlight;
3138
- }
3139
- catch (err) {
3140
- // Stale authority stops here. A current normal duplicate preserves the
3141
- // outgoing error, while a current replacement suppresses that old-session
3142
- // failure and falls through to start one fresh root below.
3143
- if (!_isCurrentRootLifecycle(rootLifecycleGeneration))
3144
- return;
3145
- if (!restartAuthority)
3146
- throw err;
3147
- }
3148
- if (!_isCurrentRootLifecycle(rootLifecycleGeneration))
3149
- return;
3150
- if (!restartAuthority) {
3151
- _cmdStatus(ctx);
3152
- return;
3153
- }
3154
- }
3155
- if (!_isCurrentRootLifecycle(rootLifecycleGeneration))
3156
- return;
3157
- const run = _cmdRootInner(ctx, rootLifecycleGeneration);
3158
- _cmdRootInFlight = run;
3159
- try {
3160
- await run;
3161
- }
3162
- finally {
3163
- if (_cmdRootInFlight === run)
3164
- _cmdRootInFlight = null;
3165
- }
3166
- }
3167
- async function _cmdRootInner(ctx, rootLifecycleGeneration) {
3168
- // A root retains its startup epoch through every pre-candidate await. This is
3169
- // stronger than `_disposed`, which a same-module session_start intentionally
3170
- // clears while an outgoing continuation may still be pending.
3171
- if (!_isCurrentRootLifecycle(rootLifecycleGeneration))
3172
- return;
3173
- const cwd = "cwd" in ctx ? ctx.cwd : process.cwd();
3174
- // Lock identity is (cwd, name). Several agents may run in the SAME folder; the
3175
- // requested name just has to be made unique. Derive the name the same way
3176
- // `_cmdJoin` does so the lock and the mesh registration agree on identity.
3177
- const requestedName = loadLocalConfig(cwd).agent_name || defaultAgentName(cwd);
3178
- // Per-(cwd,name) lock. Interactive agents may coexist by auto-suffixing
3179
- // (`name#2`, `name#3`, …), but supervised daemons must be singletons for their
3180
- // registered cwd/name. If a daemon silently came up as `#2`, the supervisor
3181
- // would report "running" while the mesh had duplicate peers for one repo.
3182
- if (_cwdLock === null) {
3183
- const isDaemon = process.env["UNBIEN_DAEMON"] === "1";
3184
- const maxAttempts = isDaemon ? 1 : 1000;
3185
- for (let n = 1; n <= maxAttempts; n++) {
3186
- const candidate = n === 1 ? requestedName : `${requestedName}#${n}`;
3187
- const result = await acquireCwdLock(cwd, candidate);
3188
- if (!_isCurrentRootLifecycle(rootLifecycleGeneration)) {
3189
- if (result.ok) {
3190
- try {
3191
- result.release();
3192
- }
3193
- catch {
3194
- /* best-effort stale lock cleanup */
3195
- }
3196
- }
3197
- return;
3198
- }
3199
- if (result.ok) {
3200
- _cwdLock = result;
3201
- _lockedName = candidate;
3202
- break;
3203
- }
3204
- }
3205
- if (_cwdLock === null) {
3206
- if (!_isCurrentRootLifecycle(rootLifecycleGeneration))
3207
- return;
3208
- ctx.ui.notify(process.env["UNBIEN_DAEMON"] === "1"
3209
- ? `[un-bien] Daemon not started: another live agent already owns "${requestedName}" in this folder. Stop the old Pi process, then restart the daemon.`
3210
- : `[un-bien] Could not start: too many agents named "${requestedName}" already running in this folder.`, "warning");
3211
- return;
3212
- }
3213
- }
3214
- // First-time wizard: no local config in this cwd → run interactive setup.
3215
- if (!localConfigExists(cwd)) {
3216
- // SAFETY: ctx.ui MIGHT carry the wizard's select/input methods (interactive
3217
- // Pi) or not (headless); the `typeof ui.select !== "function"` guard on the
3218
- // next line validates the structural assumption before any method call.
3219
- const ui = ctx.ui;
3220
- if (typeof ui.select !== "function") {
3221
- _cmdStatus(ctx);
3222
- return;
3223
- }
3224
- const baseDefault = defaultAgentName(cwd);
3225
- const newConfig = await runSetupWizard(ui, {
3226
- agent_name: baseDefault,
3227
- use_relay: true,
3228
- });
3229
- if (!_isCurrentRootLifecycle(rootLifecycleGeneration))
3230
- return;
3231
- if (!newConfig) {
3232
- ctx.ui.notify("[un-bien] Setup cancelled.", "info");
3233
- return;
3234
- }
3235
- saveLocalConfig(cwd, newConfig);
3236
- ctx.ui.notify(`[un-bien] Config saved to ${cwd}/.pi/un-bien/config.json`, "info");
3237
- if (!_isCurrentRootLifecycle(rootLifecycleGeneration))
3238
- return;
3239
- await _cmdJoin(ctx);
3240
- if (!_isCurrentRootLifecycle(rootLifecycleGeneration) || !_meshNode)
3241
- return;
3242
- if (effectiveAutoStartRelay(newConfig))
3243
- await _cmdStart(ctx);
3244
- if (!_isCurrentRootLifecycle(rootLifecycleGeneration) || !_meshNode)
3245
- return;
3246
- _cmdStatus(ctx);
3247
- return;
3248
- }
3249
- // Returning user with config: ALWAYS join the local UDS mesh on connect; the
3250
- // relay is the only thing gated by auto_start_relay. So auto_start_relay:false
3251
- // now means "local mesh, no relay" (matching the first-time/wizard path and
3252
- // the field's documented intent) — previously a false flag skipped the mesh
3253
- // join entirely, leaving the agent (incl. daemons) fully idle.
3254
- const config = loadLocalConfig(cwd);
3255
- if (!_isCurrentRootLifecycle(rootLifecycleGeneration))
3256
- return;
3257
- if (!_meshNode)
3258
- await _cmdJoin(ctx);
3259
- // `_cmdJoin` returns void on a canceled/failed join, so recheck both the
3260
- // root lifecycle and publication before bringing the Relay up.
3261
- if (!_isCurrentRootLifecycle(rootLifecycleGeneration) || !_meshNode)
3262
- return;
3263
- if (effectiveAutoStartRelay(config) && _state === "idle")
3264
- await _cmdStart(ctx);
3265
- if (!_isCurrentRootLifecycle(rootLifecycleGeneration) || !_meshNode)
3266
- return;
3267
- _cmdStatus(ctx);
3268
- }
3269
- /**
3270
- * `/unbien setup` — re-run the wizard. Defaults pre-fill from the
3271
- * existing config so it doubles as an "edit" flow.
3272
- */
3273
- async function _cmdSetup(ctx) {
3274
- const cwd = "cwd" in ctx ? ctx.cwd : process.cwd();
3275
- // SAFETY: ctx.ui MIGHT carry the wizard's select/input methods (interactive
3276
- // Pi) or not (headless); the `typeof ui.select !== "function"` guard on the
3277
- // next line validates the structural assumption before any method call.
3278
- const ui = ctx.ui;
3279
- if (typeof ui.select !== "function") {
3280
- ctx.ui.notify("[un-bien] Setup requires an interactive UI.", "warning");
3281
- return;
3282
- }
3283
- const current = loadLocalConfig(cwd);
3284
- const baseDefault = defaultAgentName(cwd);
3285
- const newConfig = await runSetupWizard(ui, {
3286
- agent_name: current.agent_name ?? baseDefault,
3287
- use_relay: effectiveAutoStartRelay(current),
3288
- });
3289
- if (!newConfig) {
3290
- ctx.ui.notify("[un-bien] Setup cancelled.", "info");
3291
- return;
3292
- }
3293
- saveLocalConfig(cwd, newConfig);
3294
- ctx.ui.notify("[un-bien] Config updated. Run /unbien to apply now.", "info");
3295
- }
3296
- async function _cmdStart(ctx) {
3297
- if (_state !== "idle") {
3298
- ctx.ui.notify("[un-bien] Already started.", "warning");
3299
- return;
3300
- }
3301
- const lifecycleGeneration = ++_relayLifecycleGeneration;
3302
- const isCurrentCandidate = () => !_disposed &&
3303
- lifecycleGeneration === _relayLifecycleGeneration &&
3304
- _state === "idle" &&
3305
- _relay === null;
3306
- let edKp;
3307
- try {
3308
- edKp = await getOrCreateEd25519Keypair();
3309
- }
3310
- catch (err) {
3311
- // Identity lookup is part of the candidate lifecycle. A later stop/off or
3312
- // session replacement must silence its stale rejection before any UI or
3313
- // error propagation touches the superseded context.
3314
- if (!isCurrentCandidate())
3315
- return;
3316
- if (err instanceof KeyringUnavailableError) {
3317
- // The platform keyring (macOS Keychain / Windows Credential Manager) is
3318
- // locked/denied and there's no file identity to fall back to. We refuse
3319
- // to mint a new key (that's what silently broke pairing after idle), so
3320
- // abort cleanly with an actionable message instead of crashing or
3321
- // re-pairing. Unlocking the keychain and re-running fixes it.
3322
- ctx.ui.notify("[un-bien] Could not read this machine's identity: the system " +
3323
- "keychain is locked or access was denied. Unlock it (open the app / " +
3324
- "log in) and run /unbien again. Your pairing is NOT lost. " +
3325
- '(For a headless host, set "identity": { "storage": "file" } in un-bien.json.)', "error");
3326
- return;
3327
- }
3328
- if (err instanceof PairedIdentityMissingError) {
3329
- // Issues #95/#69: this process can't reach the keyring that holds the
3330
- // paired identity (classically a `systemd --user` daemon vs. the desktop
3331
- // session that paired). Minting a fresh key here would make SelfRevoke
3332
- // wipe peers.json seconds later and take the phone offline, so storage
3333
- // refuses. Surface the actionable fix instead of failing silently.
3334
- ctx.ui.notify("[un-bien] Could not read this machine's identity, but devices are " +
3335
- "already paired — refusing to generate a new one (that would revoke " +
3336
- "them). This process likely cannot reach the same keyring as the " +
3337
- "session that paired (e.g. a systemd --user daemon). Give the service " +
3338
- "keyring access, or copy the paired keypair to ~/.pi/un-bien/identity.json " +
3339
- "(0600) so both contexts read the same identity.", "error");
3340
- return;
3341
- }
3342
- throw err;
3343
- }
3344
- // Re-check immediately after the first await, before cache/config/model/UI
3345
- // mutation or Relay construction. `_disposed` alone is insufficient because
3346
- // same-module session_start intentionally clears it for the replacement.
3347
- if (!isCurrentCandidate())
3348
- return;
3349
- _cachedEd25519 = edKp;
3350
- const { url: relayUrl, source } = resolveRelayUrl();
3351
- if (relayUrl === null) {
3352
- ctx.ui.notify("[un-bien] No relay configured — staying on the local mesh only. Set one " +
3353
- "with `/unbien set-relay <url>` (or the UNBIEN_RELAY env var) to connect " +
3354
- "the phone app.", "warning");
3355
- return;
3356
- }
3357
- const myShort = Buffer.from(edKp.publicKey).toString("base64").slice(0, 8);
3358
- const cwd = "cwd" in ctx ? ctx.cwd : process.cwd();
3359
- // Same name we send in pair_ok — keeps room_meta.name and the per-pair
3360
- // session_name aligned so the app shows consistent labels.
3361
- const sessionName = _displayName(cwd);
3362
- // plan/41: derive the App↔Pi room from (cwd, name) so several agents in the
3363
- // SAME folder get distinct rooms (the app renders one tile per agent). The
3364
- // default/unnamed case preserves the legacy cwd-only id (no re-keying). Uses
3365
- // the SAME name as room_meta.name / pair_ok below — the invariant that the
3366
- // app pairs on the room the Pi actually announces.
3367
- const roomId = _deriveRoomId(cwd, sessionName);
3368
- // Seed the current model from the SDK's resolved selection so room_meta
3369
- // carries it on connect. `model_select` only fires on an explicit set/cycle
3370
- // (NOT on settings load), so a headless daemon that just runs its default
3371
- // model never emits it — without this its room_meta would omit the model and
3372
- // the app shows "unknown". `getModel()` returns the session's resolved model
3373
- // in every mode (interactive + RPC daemon); turn_start hydrates it later if
3374
- // the SDK resolves the model lazily.
3375
- if (!_currentModel) {
3376
- try {
3377
- const c = ctx;
3378
- // Prefer the live getModel() / ctx.model — populated for an interactive
3379
- // Pi. For a HEADLESS DAEMON both are undefined at connect: the SDK only
3380
- // resolves `this.model` lazily at the first turn, and `model_select`
3381
- // never fires for a default-model session. So fall back to the CONFIGURED
3382
- // default (defaultProvider/defaultModel in <cwd>/.pi/settings.json) — the
3383
- // model the daemon will actually use. Without this an idle daemon (never
3384
- // prompted → no turn) would never report its model and the app shows
3385
- // "unknown". turn_start still hydrates a later override.
3386
- const live = c.getModel?.() ?? c.model;
3387
- if (live) {
3388
- _currentModel = live.name ?? live.id ?? undefined;
3389
- }
3390
- else {
3391
- const sm = SettingsManager.create(cwd);
3392
- const provider = sm.getDefaultProvider();
3393
- const modelId = sm.getDefaultModel();
3394
- if (modelId) {
3395
- // SAFETY: ensureModelRegistry only reads modelRegistry/getModel off
3396
- // the ctx; c (and the _lastEventCtx/_lastCtx fallbacks) carry those
3397
- // when present, and it tolerates null.
3398
- const regCtx = (c ??
3399
- _lastEventCtx ??
3400
- _lastCtx);
3401
- const found = provider
3402
- ? ensureModelRegistry(regCtx).find(provider, modelId)
3403
- : undefined;
3404
- _currentModel = found?.name ?? modelId;
3405
- }
3406
- }
3407
- }
3408
- catch {
3409
- /* defensive — never block start on a model lookup */
3410
- }
3411
- }
3412
- // Plan/28 Wave D.1: seed thinking from the SDK's current level so the
3413
- // first room_meta hello already carries it. `pi.getThinkingLevel()` is
3414
- // safe at this point — extension factory has been bound by the SDK
3415
- // before any command handler fires. Future toggles go through the
3416
- // `thinking_level_select` event handler above.
3417
- try {
3418
- _currentThinking = _pi?.getThinkingLevel();
3419
- }
3420
- catch {
3421
- /* defensive — never block /unbien start on this */
3422
- }
3423
- const roomMeta = { name: sessionName, cwd };
3424
- const modelName = _currentModelName();
3425
- if (modelName)
3426
- roomMeta.model = modelName;
3427
- if (_currentThinking)
3428
- roomMeta.thinking = _currentThinking;
3429
- // The room's OWN pi sessionId on the announce — so the app keys per-session
3430
- // state by the pi id (wire identity), not the routing roomId. roomId stays
3431
- // relay-routing only.
3432
- const rootSid = _rootState().sessionManager?.getSessionId() ?? _rootSessionId ?? undefined;
3433
- if (rootSid)
3434
- roomMeta.sessionId = rootSid;
3435
- // Persist so _attemptReconnect can replay the same hello payload — without
3436
- // this, reconnect issues a bare hello and the relay creates a "default room"
3437
- // entry that surfaces in the app as a phantom legacy session.
3438
- _myRoomMeta = roomMeta;
3439
- ctx.ui.notify(`[un-bien] Connecting to relay ${relayUrl} (source: ${source}, room: ${roomId})…`, "info");
3440
- // Transport opens WebSocket; convert the canonical http(s):// stored
3441
- // form to ws(s):// at this boundary. The relayUrl variable keeps the
3442
- // http(s):// form for logging + mesh client construction below.
3443
- const relay = new RelayClient(toWebSocketUrl(relayUrl), edKp);
3444
- try {
3445
- await relay.connect({ roomId, roomMeta });
3446
- }
3447
- catch (err) {
3448
- // A rejected local candidate is never published and must always be closed,
3449
- // regardless of whether this lifecycle is still authoritative.
3450
- try {
3451
- relay.close();
3452
- }
3453
- catch {
3454
- /* best-effort rejected candidate cleanup */
3455
- }
3456
- // A stop, shutdown/replacement, relay-off, or newer start may supersede a
3457
- // candidate before its rejection arrives. Keep the outgoing context silent;
3458
- // only the authoritative attempt may report an error.
3459
- if (!isCurrentCandidate())
3460
- return;
3461
- if (err instanceof RoomAlreadyOpenError) {
3462
- ctx.ui.notify("[un-bien] Already running in this cwd. Stop the other terminal first.", "error");
3463
- return;
3464
- }
3465
- ctx.ui.notify(`[un-bien] relay connect failed: ${String(err)}`, "error");
3466
- return;
3467
- }
3468
- // The candidate is local until this publication point. Session shutdown,
3469
- // stop/relay-off, or a newer start may have invalidated it while connect()
3470
- // was pending; never let that stale continuation resurrect the Relay.
3471
- if (!isCurrentCandidate()) {
3472
- try {
3473
- relay.close();
3474
- }
3475
- catch {
3476
- /* best-effort stale candidate cleanup */
3477
- }
3478
- return;
3479
- }
3480
- _relay = relay;
3481
- _relayUrl = relayUrl;
3482
- _peerShort = myShort;
3483
- _myRoomId = roomId;
3484
- _state = "started";
3485
- // Set _sessionStartedAt ONLY on first /unbien start since process boot.
3486
- // Subsequent start cycles (after stop) preserve the original epoch so the
3487
- // app keeps treating it as the same session (and merges new events from
3488
- // the terminal turns that happened during the idle window). Pi process
3489
- // restart is the only thing that produces a fresh session_started_at.
3490
- if (_sessionStartedAt === null)
3491
- _sessionStartedAt = Date.now();
3492
- // _messageBuffer intentionally preserved across stop/start — it accumulates
3493
- // message_end events for the lifetime of the Pi process, including turns
3494
- // initiated from the terminal while the relay was disconnected.
3495
- relay.on("close", () => _onRelayClose(relay));
3496
- _stopAutoListener = _installAutoListener(relay);
3497
- _refreshFooter(ctx);
3498
- // SelfRevoke is the Pi path's single initial topology producer. Its first
3499
- // coalesced sweep always publishes verified membership or a safe fallback
3500
- // before the bridge may attach.
3501
- let createdProducer = false;
3502
- if (_selfRevoke === null) {
3503
- createdProducer = true;
3504
- const producerEpoch = ++_selfRevokeEpoch;
3505
- _selfRevokeTopologyReadyEpoch = -1;
3506
- _selfRevokeTopology = null;
3507
- let producer;
3508
- producer = new SelfRevoke({
3509
- client: new MeshClient(relayUrl),
3510
- storage: { snapshotOwnerPubkeys, conditionalRemovePeer },
3511
- myPubkey: edKp.publicKey,
3512
- onRevoke: (rawOwnerPubkey, canonicalOwnerPubkey) => {
3513
- if (_selfRevoke !== producer || producerEpoch !== _selfRevokeEpoch) {
3514
- return;
3515
- }
3516
- _revokeActiveOwnerRuntime(canonicalOwnerPubkey);
3517
- void rawOwnerPubkey; // exact storage removal already happened upstream
3518
- },
3519
- onAuthoritativeOwners: (canonicalOwnerPubkeys) => {
3520
- if (_selfRevoke !== producer || producerEpoch !== _selfRevokeEpoch) {
3521
- return;
3522
- }
3523
- const presentOwners = new Set(canonicalOwnerPubkeys);
3524
- let effectFailed = false;
3525
- for (const canonicalOwnerPubkey of [..._activePeers.keys()]) {
3526
- if (_selfRevoke !== producer || producerEpoch !== _selfRevokeEpoch) {
3527
- return;
3528
- }
3529
- if (presentOwners.has(canonicalOwnerPubkey))
3530
- continue;
3531
- try {
3532
- _revokeActiveOwnerRuntime(canonicalOwnerPubkey);
3533
- }
3534
- catch {
3535
- effectFailed = true;
3536
- }
3537
- }
3538
- if (effectFailed)
3539
- throw new Error("Owner runtime reconciliation failed");
3540
- },
3541
- onTopologyChanged: (snapshot) => {
3542
- if (_selfRevoke !== producer || producerEpoch !== _selfRevokeEpoch) {
3543
- return;
3544
- }
3545
- _selfRevokeTopology = snapshot;
3546
- _meshNode?.setTopology(snapshot);
3547
- _selfRevokeTopologyReadyEpoch = producerEpoch;
3548
- _attachBridgeIfReady();
3549
- },
3550
- log: { info: () => { }, warn: () => { }, error: () => { } },
3551
- });
3552
- _selfRevoke = producer;
3553
- producer.start();
3554
- await producer.checkOnce();
3555
- if (_disposed ||
3556
- _selfRevoke !== producer ||
3557
- producerEpoch !== _selfRevokeEpoch ||
3558
- _relay !== relay) {
3559
- return;
3560
- }
3561
- }
3562
- // Relay reconnect reuses the current producer's retained snapshot. Initial
3563
- // startup is callback-driven above, so it must not issue a second attach.
3564
- if (!createdProducer)
3565
- _attachBridgeIfReady();
3566
- _emitRelayState(); // → connected
3567
- ctx.ui.notify(`[un-bien] state: started (peer=${myShort}) — Connected to relay ${relayUrl}`, "info");
3568
- }
3569
- /**
3570
- * `/unbien pair` — always generates a fresh QR when the relay is up.
3571
- *
3572
- * Pre-W2D this rejected with "Already paired with X" once one owner was
3573
- * connected, forcing /unbien stop to pair a second device — the
3574
- * catch-22 the multi-channel refactor was designed to break. Now the new
3575
- * device is **added** to `_activePeers` after scanning, while existing
3576
- * owners keep their session.
3577
- */
3578
- async function _cmdPair(ctx, args = "") {
3579
- const cwd = "cwd" in ctx ? ctx.cwd : "";
3580
- // Auto-bootstrap when services are down. Before this, `/unbien pair`
3581
- // on a fresh terminal forced the user to call `/unbien` first — every
3582
- // session began with the same surprise warning + second command. Now we
3583
- // do the join + relay-start inline so the common "I just opened a
3584
- // terminal and want to pair my phone" flow is a single command.
3585
- //
3586
- // We don't run the first-time wizard here: pair is a focused operation
3587
- // and the wizard prompts are wrong UX in that flow. If there's no local
3588
- // config, the user truly needs to run `/unbien` first to configure.
3589
- if (_state === "idle") {
3590
- if (!localConfigExists(cwd)) {
3591
- ctx.ui.notify("[un-bien] First-time setup needed. Run /unbien to configure, then /unbien pair.", "warning");
3592
- return;
3593
- }
3594
- ctx.ui.notify("[un-bien] Starting mesh + relay before pairing…", "info");
3595
- if (!_meshNode)
3596
- await _cmdJoin(ctx);
3597
- if (_state === "idle")
3598
- await _cmdStart(ctx);
3599
- }
3600
- // Relay must be up — the QR carries a token the app exchanges through
3601
- // the relay. Without a live WS there's nothing for the scan to land on.
3602
- if (_state === "idle" || !_relay) {
3603
- ctx.ui.notify("[un-bien] Pair requires the relay to be connected. " +
3604
- "Run /unbien to start it (or fix your relay URL via /unbien set-relay).", "warning");
3605
- return;
3606
- }
3607
- const edKp = _cachedEd25519;
3608
- // Embed the user-configured name in the QR so the app shows it on the
3609
- // pairing screen before pair_ok lands (better UX than "remote" or a
3610
- // raw path snippet).
3611
- const sessionName = _displayName(cwd);
3612
- // Optional `--ttl <seconds>` — RPC clients (e.g. Cockpit) pass a caller-
3613
- // defined expiry. Defaults to TOKEN_TTL_MS, clamped to the safe window.
3614
- const ttlMatch = /--ttl\s+(\d+)/.exec(args);
3615
- const ttlMs = ttlMatch
3616
- ? clampPairTtlMs(Number(ttlMatch[1]) * 1000)
3617
- : TOKEN_TTL_MS;
3618
- const { token, expiresAt } = qrSession.issueToken(ttlMs);
3619
- // The QR room is the ISSUING session's room (session-id-derived `_myRoomId`).
3620
- // Pairing is room-scoped to this session (it owns the QR token), so only this
3621
- // session answers the pair_request — no fan-out race. Trust still lands on the
3622
- // MACHINE: pair_ok persists a PairedMachine keyed by epk, and the app then
3623
- // discovers all the machine's sessions via room_announced.
3624
- const roomId = _myRoomId ?? _deriveRoomId(cwd, sessionName);
3625
- const qrUri = buildQRUri(token, edKp.publicKey, sessionName, roomId);
3626
- // Render both the QR ASCII and the copy-paste URI inside the Pi TUI's
3627
- // chat panel via `pi.sendMessage` — the same channel the SDK uses for
3628
- // agent responses + tool results. `process.stderr.write` (the old QR
3629
- // path via `displayQR`) broke the TUI layout because it bypassed the
3630
- // chat widget and bled into the prompt area. qrcode-terminal v0.12
3631
- // small mode is pure Unicode (█ ▀ ▄ space, no ANSI escapes — see
3632
- // `lib/main.js:48-53`), so embedding the ASCII inside a sendMessage
3633
- // content string renders correctly without raw escape bytes.
3634
- if (_pi) {
3635
- const qrAscii = renderQRAscii(qrUri);
3636
- _pi.sendMessage({
3637
- customType: "un-bien:pair-code",
3638
- content: `📱 Scan to pair:\n\n${qrAscii}\n` +
3639
- `📋 Or copy this pairing code (camera-less devices):\n\n${qrUri}`,
3640
- // Structured payload for RPC clients (e.g. Cockpit): render their own QR
3641
- // from `uri` + show the expiry, without scraping the display string.
3642
- details: { uri: qrUri, token, expiresAt, roomId, name: sessionName },
3643
- display: true,
3644
- });
3645
- }
3646
- ctx.ui.notify(`[un-bien] QR ready — valid until ${new Date(expiresAt).toLocaleTimeString()}. ` +
3647
- `Scan with the app, or copy the pairing code printed above.`, "info");
3648
- // Returns immediately; the auto-listener transitions to 'paired' on pair_request.
3649
- }
3650
- /**
3651
- * `/unbien stop` — full teardown. Leaves the local UDS mesh AND closes
3652
- * the relay. Safe when one or both are already off. To resume, run
3653
- * `/unbien` again.
3654
- */
3655
- async function _cmdStop(ctx) {
3656
- // Invalidate queued root work and local async candidates even when none has
3657
- // published yet.
3658
- _rootLifecycleGeneration += 1;
3659
- _meshJoinGeneration += 1;
3660
- const meshUp = _meshNode !== null;
3661
- const relayUp = _state !== "idle";
3662
- if (!meshUp && !relayUp) {
3663
- _relayLifecycleGeneration += 1;
3664
- ctx.ui.notify("[un-bien] Already stopped — nothing to do.", "info");
3665
- return;
3666
- }
3667
- // Revoke Relay/SelfRevoke/bridge authority while the global node is still
3668
- // visible and before close() begins UDS leave.
3669
- if (relayUp) {
3670
- _goIdle();
3671
- }
3672
- else {
3673
- _relayLifecycleGeneration += 1;
3674
- _meshNode?.detachBridge();
3675
- }
3676
- const meshNode = _meshNode;
3677
- _meshNode = null;
3678
- _sessionName = null;
3679
- _sessionPeerCount = 0;
3680
- let meshClose = null;
3681
- try {
3682
- meshClose = meshNode?.close() ?? null;
3683
- }
3684
- catch {
3685
- /* best-effort */
3686
- }
3687
- try {
3688
- await meshClose;
3689
- }
3690
- catch {
3691
- /* best-effort */
3692
- }
3693
- ctx.ui.notify("[un-bien] Stopped (mesh + relay disconnected).", "info");
3694
- _refreshFooter(ctx);
3695
- }
3696
- async function _cmdList(ctx) {
3697
- const peers = await listPeers();
3698
- if (peers.length === 0) {
3699
- ctx.ui.notify("[un-bien] No paired devices.", "info");
3700
- return;
3701
- }
3702
- // Multi-channel (W2D): each peer is either `online` (channel attached
3703
- // right now) or `offline` (in peers.json but not connected). Replaces
3704
- // the singleton " (active)" marker that only ever marked one peer.
3705
- const lines = peers
3706
- .flatMap((record) => {
3707
- const inspected = _inspectPeerRecord(record);
3708
- if (!inspected)
3709
- return [];
3710
- const tag = inspected.runtimeKey !== null && _activePeers.has(inspected.runtimeKey)
3711
- ? " 🟢 online"
3712
- : " ⚪ offline";
3713
- return `• ${inspected.rawHandle.slice(0, 8)} — ${inspected.record.name}${tag}`;
3714
- })
3715
- .join("\n");
3716
- ctx.ui.notify(`[un-bien] Paired devices:\n${lines}`, "info");
3717
- }
3718
- async function _cmdRevoke(arg, ctx) {
3719
- const shortid = arg.trim();
3720
- if (!shortid) {
3721
- ctx.ui.notify("[un-bien] Usage: /unbien revoke <shortid>. Run /unbien list to see shortids.", "warning");
3722
- return;
3723
- }
3724
- // Revoke needs the relay so the revoked device's live channel is torn down
3725
- // — not just a silent peers.json edit. Auto-bootstrap the mesh + relay when
3726
- // down, mirroring `_cmdPair`.
3727
- const cwd = "cwd" in ctx ? ctx.cwd : "";
3728
- if (_state === "idle") {
3729
- if (!localConfigExists(cwd)) {
3730
- ctx.ui.notify("[un-bien] First-time setup needed. Run /unbien to configure, then /unbien revoke.", "warning");
3731
- return;
3732
- }
3733
- ctx.ui.notify("[un-bien] Starting mesh + relay before revoking…", "info");
3734
- if (!_meshNode)
3735
- await _cmdJoin(ctx);
3736
- if (_state === "idle")
3737
- await _cmdStart(ctx);
3738
- }
3739
- if (_state === "idle" || !_relay) {
3740
- ctx.ui.notify("[un-bien] Revoke requires the relay to be connected. " +
3741
- "Run /unbien to start it (or fix your relay URL via /unbien set-relay).", "warning");
3742
- return;
3743
- }
3744
- const matches = (await listPeers())
3745
- .map(_inspectPeerRecord)
3746
- .filter((peer) => peer !== null)
3747
- .filter((peer) => peer.rawHandle.startsWith(shortid));
3748
- if (matches.length === 0) {
3749
- ctx.ui.notify("[un-bien] No peer matching that shortid. Run /unbien devices to see shortids.", "warning");
3750
- return;
3751
- }
3752
- if (matches.length > 1) {
3753
- const collisions = matches
3754
- .map((peer) => peer.rawHandle.slice(0, 8))
3755
- .join(", ");
3756
- ctx.ui.notify(`[un-bien] Ambiguous shortid — ${matches.length} matches: ${collisions}. Use mais chars.`, "warning");
3757
- return;
3758
- }
3759
- const peer = matches[0];
3760
- await removePeer(peer.rawHandle);
3761
- _refreshPairingsCache();
3762
- // Storage removal uses the exact saved representation; the active channel
3763
- // is indexed by its canonical identity.
3764
- if (peer.runtimeKey !== null && _activePeers.has(peer.runtimeKey)) {
3765
- _detachPeerChannel(peer.runtimeKey);
3766
- _refreshFooter();
3767
- }
3768
- ctx.ui.notify(`[un-bien] Revoked: ${peer.record.name} (${peer.rawHandle.slice(0, 8)}…)`, "info");
3769
- }
3770
- async function _shortidCompletions(prefix, valuePrefix = "") {
3771
- const peers = (await listPeers())
3772
- .map(_inspectPeerRecord)
3773
- .filter((peer) => peer !== null);
3774
- return peers
3775
- .map((peer) => ({
3776
- shortid: peer.rawHandle.slice(0, 8),
3777
- name: peer.record.name,
3778
- }))
3779
- .filter((entry) => entry.shortid.startsWith(prefix))
3780
- .map((entry) => ({
3781
- value: `${valuePrefix}${entry.shortid}`,
3782
- label: `${entry.shortid} (${entry.name})`,
3783
- }));
3784
- }
3785
- function _cmdSetRelay(arg, ctx) {
3786
- const raw = arg.trim();
3787
- if (!raw) {
3788
- ctx.ui.notify("[un-bien] Usage: /unbien set-relay <http:// or https:// url>", "warning");
3789
- return;
3790
- }
3791
- if (isWebSocketScheme(raw)) {
3792
- ctx.ui.notify(`[un-bien] Use http:// or https://. The extension converts to WebSocket automatically.`, "error");
3793
- return;
3794
- }
3795
- if (!isValidRelayUrl(raw)) {
3796
- ctx.ui.notify(`[un-bien] Invalid URL: ${raw}. Must start with http:// or https://`, "error");
3797
- return;
3798
- }
3799
- saveConfig({ relay: raw });
3800
- ctx.ui.notify(`[un-bien] Relay set to ${raw}. Run /unbien relay stop then /unbien relay start to apply.`, "info");
3801
- }
3802
- /**
3803
- * `/unbien config` — print the effective relay URL and where it came from.
3804
- *
3805
- * Documented in the README ("Verify the active URL and its source") and in
3806
- * CLAUDE.md, but like the `relay` family (issue #119) it had no handler and
3807
- * fell through to the status panel, which shows the URL but not the source —
3808
- * so `env` vs `config` vs `default` was unverifiable without a restart.
3809
- */
3810
- function _cmdConfig(ctx) {
3811
- const { url, source } = resolveRelayUrl();
3812
- const origin = source === "env"
3813
- ? "UNBIEN_RELAY environment variable"
3814
- : source === "config"
3815
- ? "extensions/un-bien.json (set via /unbien set-relay)"
3816
- : "not set — run /unbien set-relay <url> or set UNBIEN_RELAY";
3817
- const live = _relayUrl && _relayUrl !== url
3818
- ? `\n ⚠ Live connection still on ${_relayUrl} — run /unbien relay stop then /unbien relay start to apply.`
3819
- : "";
3820
- ctx.ui.notify(`[un-bien]\n Relay URL: ${url ?? "(none)"}\n Source: ${source} — ${origin}${live}`, "info");
3821
- }
3822
- /**
3823
- * `/unbien identity` — report NON-SECRET identity state (active EPK, backend,
3824
- * resolved source). The private seed is NEVER shown: command output is
3825
- * LLM-visible, so extraction stays a manual `cat`/keychain op. Read-only
3826
- * (never mints).
3827
- */
3828
- async function _cmdIdentity(ctx) {
3829
- const info = await describeIdentity();
3830
- const backendLine = info.backend === "file" ? `file (${info.filePath})` : "keychain";
3831
- const epkLine = info.epk ?? "(none yet — minted on first use)";
3832
- const sourceLine = info.detail
3833
- ? `${info.source} — ${info.detail}`
3834
- : info.source;
3835
- ctx.ui.notify(`[un-bien] identity\n Backend: ${backendLine}\n EPK (public): ${epkLine}\n Source: ${sourceLine}`, info.source === "error" ? "error" : "info");
3836
- }
3837
- /**
3838
- * `/unbien relay [start|stop|status|url <url>]` — issue #119.
3839
- *
3840
- * The README has always documented this family (`relay url` to point at a
3841
- * self-hosted relay, `relay stop` + `relay start` to apply the change), but no
3842
- * handler existed: every `relay …` invocation fell through the `else` in the
3843
- * flat dispatcher and silently reprinted the status panel. Users following the
3844
- * README believed they had switched relays and stayed on the community relay —
3845
- * exactly the case where our own docs warn the operator sees routed plaintext.
3846
- *
3847
- * Verbs map onto the same primitives the RPC control channel already uses
3848
- * (`_handleControl`), so the slash command and the Cockpit button can't drift:
3849
- * relay-only up (`_cmdStart`) / relay-only down (`_goIdle`), never touching
3850
- * local-mesh membership — that stays `/unbien stop`'s job.
3851
- */
3852
- async function _cmdRelay(arg, ctx) {
3853
- const raw = arg.trim();
3854
- const [verb, ...rest] = raw.split(/\s+/);
3855
- const value = rest.join(" ").trim();
3856
- switch (verb) {
3857
- case "":
3858
- case "toggle":
3859
- await _handleControl("relay:toggle");
3860
- ctx.ui.notify(`[un-bien] Relay ${_relayStatus()}.`, "info");
3861
- _refreshFooter(ctx);
3862
- return;
3863
- case "start":
3864
- case "on":
3865
- if (_getState() === "idle")
3866
- await _cmdStart(ctx);
3867
- else
3868
- ctx.ui.notify(`[un-bien] Relay already ${_relayStatus()}.`, "info");
3869
- _emitRelayState(true);
3870
- return;
3871
- case "stop":
3872
- case "off":
3873
- if (_getState() === "idle") {
3874
- ctx.ui.notify("[un-bien] Relay already disconnected.", "info");
3875
- }
3876
- else {
3877
- _goIdle();
3878
- ctx.ui.notify("[un-bien] Relay disconnected (local mesh untouched).", "info");
3879
- }
3880
- _emitRelayState(true);
3881
- _refreshFooter(ctx);
3882
- return;
3883
- case "status":
3884
- _cmdStatus(ctx);
3885
- _emitRelayState(true);
3886
- return;
3887
- case "url":
3888
- // Same writer as `set-relay` — one code path, so validation and the
3889
- // "restart to apply" hint can never diverge between the two spellings.
3890
- _cmdSetRelay(value, ctx);
3891
- return;
3892
- default:
3893
- ctx.ui.notify("[un-bien] Usage: /unbien relay [start|stop|status|url <http(s) url>]", "warning");
3894
- return;
3895
- }
3896
- }
3897
- // ── Install/uninstall the launcher-daemon service ────────────────────────────
3898
- //
3899
- // Installs the un-bien launcher daemon as a user-level system service (systemd
3900
- // `--user` unit on Linux, launchd LaunchAgent on macOS, Task Scheduler on
3901
- // Windows). Once installed the launcher daemon starts at login + survives
3902
- // reboots. Uninstall is the inverse.
3903
- /**
3904
- * `linkCli` controls whether we symlink `un-bien` into `~/.local/bin/`. The
3905
- * slash-command path passes `true` (user is inside Pi's TUI — they installed
3906
- * via `pi install npm:un-bien` and need us to expose the CLI for them). The
3907
- * standalone-CLI path passes `false` because the user is already running our
3908
- * binary from PATH (they did `npm install -g un-bien`), so re-linking would
3909
- * point their `un-bien` at the Pi-extension copy and diverge on upgrades.
3910
- */
3911
- /** Returns true on success, false when install failed (so the standalone CLI
3912
- * can exit non-zero — e.g. the Cockpit / CI detect failure by exit code).
3913
- * We do NOT process.exit here: this also runs inside the Pi TUI, where exiting
3914
- * would kill the session. */
3915
- function _cmdInstall(ctx, opts = {}) {
3916
- const linkCli = opts.linkCli ?? false;
3917
- try {
3918
- const result = installService();
3919
- const sections = [
3920
- `[un-bien] Launcher daemon service installed (${result.platform}).`,
3921
- ` Unit: ${result.unitPath}`,
3922
- ` Steps:\n${result.log.map((l) => " " + l).join("\n")}`,
3923
- ];
3924
- if (linkCli) {
3925
- const link = linkCliBinaries();
3926
- sections.push(` CLI bins linked into ${link.binDir}:`, link.links.map((l) => ` ${l.name} → ${l.target}`).join("\n"), ` Steps:\n${link.log.map((l) => " " + l).join("\n")}`);
3927
- if (!link.onPath) {
3928
- if (process.platform === "win32") {
3929
- sections.push(` ⚠ ${link.binDir} was just added to your user PATH (it wasn't there yet).`, ` Open a NEW terminal and run \`unbien status\` to verify.`);
3930
- }
3931
- else {
3932
- sections.push(` ⚠ ${link.binDir} is not on $PATH yet. Add this line to ~/.zshrc / ~/.bashrc:`, ` export PATH="$HOME/.local/bin:$PATH"`, ` Then open a new terminal and run \`unbien status\` to verify.`);
3933
- }
3934
- }
3935
- }
3936
- ctx.ui.notify(sections.join("\n"), "info");
3937
- return true;
3938
- }
3939
- catch (err) {
3940
- ctx.ui.notify(`[un-bien] install failed: ${String(err)}`, "error");
3941
- return false;
3942
- }
3943
- }
3944
- function _cmdUninstall(ctx, opts = {}) {
3945
- const linkCli = opts.linkCli ?? false;
3946
- try {
3947
- const result = uninstallService();
3948
- const sections = [
3949
- `[un-bien] Launcher daemon service uninstalled (${result.platform}).`,
3950
- ` Unit: ${result.unitPath} (${result.removed ? "removed" : "not present"})`,
3951
- ` Steps:\n${result.log.map((l) => " " + l).join("\n")}`,
3952
- ];
3953
- if (linkCli) {
3954
- const unlink = unlinkCliBinaries();
3955
- sections.push(` CLI bins cleanup (${unlink.binDir}):`, unlink.removed
3956
- .map((r) => ` ${r.name} (${r.existed ? "removed" : "not present"})`)
3957
- .join("\n"));
3958
- }
3959
- ctx.ui.notify(sections.join("\n"), "info");
3960
- }
3961
- catch (err) {
3962
- ctx.ui.notify(`[un-bien] uninstall failed: ${String(err)}`, "error");
3963
- }
3964
- }
3965
- // ── Agent-network commands (plano 19) ─────────────────────────────────────────
3966
- function _resolveExtensionDir() {
3967
- // dist/index.js → dist; skills sit at <extensionRoot>/skills/. When we run
3968
- // from src/ via tsx (dev), index.ts is in src/ and skills/ is sibling. We
3969
- // detect by checking both locations.
3970
- const here = fileURLToPath(import.meta.url);
3971
- // dist/index.js or src/index.ts → parent = <dist or src>; sibling = ../skills
3972
- const parent = here.replace(/\/[^/]+$/, "");
3973
- const candidateA = join(parent, "..", "skills"); // dist → ../skills
3974
- const candidateB = join(parent, "skills"); // src → skills
3975
- if (existsSync(candidateA))
3976
- return parent.replace(/\/dist$/, "");
3977
- if (existsSync(candidateB))
3978
- return parent;
3979
- return parent;
3980
- }
3981
- function _deployAgentNetworkSkill() {
3982
- // Pi SDK spec (core/skills.js): every skill must live at
3983
- // <skillsRoot>/<skill-name>/SKILL.md
3984
- // The skill `name:` frontmatter must equal the parent directory name. We
3985
- // ship the source pre-arranged that way so deploy is a straight copy into
3986
- // ~/.pi/un-bien/skills/agent-network/SKILL.md.
3987
- const root = _resolveExtensionDir();
3988
- const src1 = join(root, "skills", "agent-network", "SKILL.md");
3989
- const src2 = join(root, "..", "skills", "agent-network", "SKILL.md");
3990
- const src = existsSync(src1) ? src1 : existsSync(src2) ? src2 : null;
3991
- if (!src)
3992
- return;
3993
- const dstDir = join(skillsDir(), "agent-network");
3994
- const dst = join(dstDir, "SKILL.md");
3995
- try {
3996
- mkdirSync(dstDir, { recursive: true });
3997
- copyFileSync(src, dst);
3998
- // Cleanup legacy deploy at ~/.pi/un-bien/skills/agent-network.md (flat
3999
- // layout, fails the Pi SDK's name-vs-parent-dir validation).
4000
- const legacy = join(skillsDir(), "agent-network.md");
4001
- if (existsSync(legacy)) {
4002
- try {
4003
- unlinkSync(legacy);
4004
- }
4005
- catch {
4006
- /* ignored */
4007
- }
4008
- }
4009
- }
4010
- catch {
4011
- /* best-effort */
4012
- }
4013
- }
4014
2292
  function _wakeAgent(content, label, steeringBehavior) {
4015
2293
  if (!_pi) {
4016
2294
  const detail = "agent session not bound yet";
@@ -4018,9 +2296,17 @@ function _wakeAgent(content, label, steeringBehavior) {
4018
2296
  return { ok: false, detail };
4019
2297
  }
4020
2298
  try {
4021
- const options = steeringBehavior
4022
- ? { deliverAs: steeringBehavior }
4023
- : undefined;
2299
+ // expandPromptTemplates: true — TUI PARITY for app-sent text. pi's
2300
+ // sendUserMessage defaults it to FALSE, so a "/"-prefixed message typed
2301
+ // in the PHONE composer reached the MODEL as literal text (observed:
2302
+ // "ignoring the two /unbien pair statements prior in the chat") instead
2303
+ // of dispatching as a command like the same text typed in the TUI. pi's
2304
+ // prompt() dispatches extension commands FIRST — even mid-stream — so
2305
+ // steering a running turn with a command now behaves identically too.
2306
+ const options = {
2307
+ ...(steeringBehavior ? { deliverAs: steeringBehavior } : {}),
2308
+ expandPromptTemplates: true,
2309
+ };
4024
2310
  _pi.sendUserMessage(content, options);
4025
2311
  return { ok: true };
4026
2312
  }
@@ -4108,181 +2394,6 @@ function _deliverMeshMessageToAgent(env) {
4108
2394
  _pendingMeshMessages.push(env);
4109
2395
  _scheduleMeshMessageDrain();
4110
2396
  }
4111
- /** Test-only entry point for verifying mesh-to-agent delivery semantics. */
4112
- export function _deliverMeshMessageToAgentForTest(env) {
4113
- _deliverMeshMessageToAgent(env);
4114
- }
4115
- /**
4116
- * Joins the fixed local UDS mesh ("local" session — see LOCAL_SESSION_NAME).
4117
- * Called by `_cmdRoot` on first run and on subsequent runs when the relay
4118
- * is up and the user hasn't explicitly stopped. The session name is no
4119
- * longer user-configurable: every Pi on the same machine joins the same
4120
- * broker.
4121
- */
4122
- async function _cmdJoin(ctx) {
4123
- const cwd = "cwd" in ctx ? ctx.cwd : process.cwd();
4124
- const local = loadLocalConfig(cwd);
4125
- const sessionName = LOCAL_SESSION_NAME;
4126
- // What the user configured for this agent…
4127
- const requestedName = local.agent_name || defaultAgentName(cwd);
4128
- // …and what we actually register: the name the cwd-lock reserved, which is
4129
- // `requestedName` or a `#N` variant when same-named agents share this folder.
4130
- // Falls back to requestedName when join runs without a prior `_cmdRoot` lock
4131
- // (e.g. legacy/test paths).
4132
- const agentName = _lockedName ?? requestedName;
4133
- if (_meshNode) {
4134
- ctx.ui.notify("[un-bien] Already on the local mesh.", "warning");
4135
- return;
4136
- }
4137
- const joinGeneration = ++_meshJoinGeneration;
4138
- ensureGlobalDirs();
4139
- mkdirSync(join(skillsDir(), "..", "sessions", sessionName), {
4140
- recursive: true,
4141
- });
4142
- const sock = sessionSockPath(sessionName);
4143
- const audit = sessionAuditPath(sessionName);
4144
- // Forward the cwd so the broker keys this peer by (cwd, name): a same-folder
4145
- // same-name reincarnation (switch_session re-eval, app restart) takes over the
4146
- // name instead of registering behind a mute `name#N` ghost. Canonicalize via
4147
- // realpath so symlinked cwds map to one identity (matches roomIdForCwd).
4148
- let canonCwd = cwd;
4149
- try {
4150
- canonCwd = realpathSync(cwd);
4151
- }
4152
- catch {
4153
- /* cwd missing — use raw path */
4154
- }
4155
- const peer = new MeshNode({
4156
- sockPath: sock,
4157
- name: agentName,
4158
- cwd: canonCwd,
4159
- auditPath: audit,
4160
- takeoverExisting: process.env["UNBIEN_DAEMON"] === "1",
4161
- });
4162
- peer.onMessage((env) => {
4163
- const body = env.body;
4164
- // Broker system events: re-query broker for authoritative count.
4165
- // Incremental ±1 drifts when peer_left is missed (leader leaves cleanly,
4166
- // failover, etc.) — querying list_peers makes the count self-healing.
4167
- if (body && (body.type === "peer_joined" || body.type === "peer_left")) {
4168
- _refreshSessionPeerCount(peer, ctx);
4169
- // Plan/25 Wave B: push fresh peer list to all siblings so their
4170
- // remotePeers cache stays current without polling.
4171
- void peer
4172
- .request("broker", { type: "list_peers" }, 2000)
4173
- .then((reply) => {
4174
- const body = reply.body;
4175
- // onLocalPeersChanged wants LOCAL-only addresses (list_peers returns
4176
- // the aggregated local + cross-PC roster). Prefer the structured
4177
- // roster (plan/38): a local peer has no `pc`. This is drive-letter
4178
- // safe — a Windows local address `C:\…@app` contains ':' but is NOT
4179
- // remote, so the old naive `!p.includes(":")` misclassified it.
4180
- let local = null;
4181
- const detailed = body?.peers_detailed;
4182
- if (Array.isArray(detailed)) {
4183
- local = detailed
4184
- .filter((p) => !p.pc && typeof p.address === "string")
4185
- .map((p) => p.address);
4186
- }
4187
- else if (Array.isArray(body?.peers)) {
4188
- // Fallback for a legacy broker without `peers_detailed`.
4189
- local = body.peers.filter((p) => !p.includes(":"));
4190
- }
4191
- // No-op when the bridge isn't up (follower / relay down).
4192
- if (local)
4193
- peer.onLocalPeersChanged(local);
4194
- })
4195
- .catch(() => {
4196
- /* bridge not bound yet, or list_peers failed */
4197
- });
4198
- return;
4199
- }
4200
- if (env.from === "broker")
4201
- return; // other broker control messages — ignore
4202
- // Real agent-to-agent message (SessionPeer already correlated replies via
4203
- // env.re before this point). Show it in the app's TOOL timeline and wake
4204
- // the agent as a CUSTOM message — never as the user's own message.
4205
- _deliverMeshMessageToAgent(env);
4206
- });
4207
- // After failover (leader died, we re-elected): the new broker's peers map
4208
- // starts fresh, but our cached `_sessionPeerCount` is stale. Re-seed it so
4209
- // surviving peers don't carry the pre-failover count forever.
4210
- //
4211
- // The cross-PC bridge re-attach on failover (drop the stale broker ref,
4212
- // re-wire against the fresh `localBroker()` if we were promoted to leader)
4213
- // is handled INSIDE MeshNode — no manual teardown/ensure needed here.
4214
- peer.onReconnect(() => {
4215
- _refreshSessionPeerCount(peer, ctx);
4216
- });
4217
- const isCurrentCandidate = () => !_disposed && joinGeneration === _meshJoinGeneration && _meshNode === null;
4218
- try {
4219
- const assigned = await peer.connect();
4220
- // The candidate stays local until connect resolves. Shutdown, stop, or a
4221
- // newer join invalidates its generation; close it instead of publishing a
4222
- // ghost peer or allowing _cmdRoot to continue into Relay startup.
4223
- if (!isCurrentCandidate()) {
4224
- try {
4225
- await peer.close();
4226
- }
4227
- catch {
4228
- /* best-effort */
4229
- }
4230
- return;
4231
- }
4232
- _meshNode = peer;
4233
- _sessionName = sessionName;
4234
- _sessionPeerCount = 1; // optimistic — overwritten by list_peers below
4235
- // Broker broadcasts `peer_joined` only to existing peers when a new one
4236
- // arrives — the newcomer doesn't get retroactive joined events. Ask the
4237
- // broker for the live peer list to seed the count correctly on join.
4238
- _refreshSessionPeerCount(peer, ctx);
4239
- // Tell RPC clients (e.g. Cockpit) the EFFECTIVE mesh name. The broker
4240
- // appends a `#N` suffix only on a same-(cwd,name) collision, so the name we
4241
- // requested and the one actually assigned can differ. Emit a pure-data event
4242
- // (display:false) carrying both + a `changed` flag so the client can rename
4243
- // the agent in its own UI to match what the mesh/relay will show. Fired on
4244
- // every join (incl. failover re-elect, which can re-assign the name), so the
4245
- // client always reflects the live name, not just the first one.
4246
- //
4247
- // plan/38 decision E: we deliberately DO NOT persist `assigned`. A `#N` is a
4248
- // RUNTIME collision resolution; freezing it into `agent_name` fossilizes an
4249
- // accident and causes cross-folder name ping-pong across restarts. The clean
4250
- // name (wizard / explicit `agent_name`) already lives in config or re-derives
4251
- // from `basename(cwd)`; the event above carries the live `#N` for the UI.
4252
- _pi?.sendMessage({
4253
- customType: "un-bien:name-assigned",
4254
- content: assigned === requestedName
4255
- ? `Mesh name: ${assigned}`
4256
- : `Mesh name reassigned: "${requestedName}" → "${assigned}" (collision)`,
4257
- details: {
4258
- requested: requestedName,
4259
- assigned,
4260
- changed: assigned !== requestedName,
4261
- },
4262
- display: false,
4263
- });
4264
- ctx.ui.notify(`[un-bien] Joined local mesh as "${assigned}" (${peer.currentRole()})`, "info");
4265
- _refreshFooter(ctx);
4266
- // Plan/25 Wave B/C: try to bring up cross-PC routing now that the
4267
- // local broker exists. No-op if the relay isn't up yet (will fire
4268
- // again from `_cmdStart`).
4269
- _attachBridgeIfReady();
4270
- }
4271
- catch (err) {
4272
- // A replacement/stop/newer join can invalidate this candidate before its
4273
- // failure arrives. Clean it up and never notify the outgoing session ctx.
4274
- if (!isCurrentCandidate()) {
4275
- try {
4276
- await peer.close();
4277
- }
4278
- catch {
4279
- /* best-effort */
4280
- }
4281
- return;
4282
- }
4283
- ctx.ui.notify(`[un-bien] join failed: ${String(err)}`, "error");
4284
- }
4285
- }
4286
2397
  // ── routeClientMessage ────────────────────────────────────────────────────────
4287
2398
  /**
4288
2399
  * Per-channel router. Replaces the W2D-pre `routeClientMessage` which
@@ -4296,7 +2407,7 @@ async function _cmdJoin(ctx) {
4296
2407
  * handles incoming app→pi requests.
4297
2408
  */
4298
2409
  function _abortCurrentTurn(fallbackCtx) {
4299
- const candidates = [_lastEventCtx, _lastCtx, fallbackCtx];
2410
+ const candidates = [_lastEventCtx, fallbackCtx];
4300
2411
  for (const candidate of candidates) {
4301
2412
  if (!candidate || candidate === _noopCtx)
4302
2413
  continue;
@@ -4380,24 +2491,10 @@ export function routeClientMessage(msg, ctx) {
4380
2491
  * also dump history to owner B's wire — duplicate traffic + the wrong
4381
2492
  * `in_reply_to`.
4382
2493
  */
4383
- /**
4384
- * Resets the Pi-side session view after a SUCCESSFUL `session_new`. The app's
4385
- * New Session clears its local store on `action_ok`, but that alone isn't
4386
- * durable: `_messageBuffer` (which answers `session_sync`) is append-only and
4387
- * `_sessionStartedAt` is stamped once, so a later reconnect/restart would
4388
- * replay the OLD history. We clear the buffer and restamp the clock so the
4389
- * envelope `session_sync` reconstructs from a clean slate. The app drops the
4390
- * stale conversation off the new-session `hello` (changed `sessionId`).
4391
- */
4392
- function _resetSessionForNew() {
4393
- // Restamp the session clock so the app detects the pi restart (session_sync_end
4394
- // carries it). The transcript resets naturally: the app re-fetches via
4395
- // get_entries against the fresh session and drops the old one on the new hello.
4396
- _sessionStartedAt = Date.now();
4397
- }
4398
- /** Resolve the base dir for tool-arg file lookups from the last command ctx. */
2494
+ /** Resolve the base dir for tool-arg file lookups from the current session. */
4399
2495
  function _resolveToolCwd() {
4400
- return _lastCtx && "cwd" in _lastCtx ? _lastCtx.cwd : process.cwd();
2496
+ const sm = _rootState().sessionManager;
2497
+ return sm && typeof sm.getCwd === "function" ? sm.getCwd() : process.cwd();
4401
2498
  }
4402
2499
  // ── Standalone CLI ────────────────────────────────────────────────────────────
4403
2500
  function _isDirectRun() {
@@ -4599,150 +2696,4 @@ if (_isDirectRun()) {
4599
2696
  ].join("\n"));
4600
2697
  }
4601
2698
  }
4602
- // ── `unbien claude` — launch Claude Code connected to the mesh ─────────────
4603
- /**
4604
- * Resolve the packaged agent-network skill path
4605
- * (`<pkgRoot>/skills/agent-network/SKILL.md`). Single source of truth shared
4606
- * by both runtimes: Pi discovers it via `resources_discover`, and the Claude
4607
- * launcher injects it as a system prompt (see `_cmdClaudeCli`). Returns null
4608
- * if the file is missing (e.g. running before `pnpm build`).
4609
- */
4610
- function _agentNetworkSkillPath() {
4611
- const here = fileURLToPath(import.meta.url); // dist/index.js (or src/index.ts via tsx)
4612
- const pkgRoot = dirname(dirname(here)); // package root (dist → ..; src → ..)
4613
- const skill = join(pkgRoot, "skills", "agent-network", "SKILL.md");
4614
- return existsSync(skill) ? skill : null;
4615
- }
4616
- async function _cmdClaudeCli(args) {
4617
- // Contract: `unbien claude [cwd] [claude-flags...]`. The optional cwd is
4618
- // ONLY the leading positional (first token, not a flag); everything after it
4619
- // is forwarded verbatim to the `claude` binary (e.g. `--resume`, `-c`,
4620
- // `-p "prompt"`). Restricting cwd to the leading token avoids mistaking a
4621
- // flag's value (e.g. the id in `--resume <id>`) for the cwd.
4622
- const hasCwdArg = args.length > 0 && !args[0].startsWith("-");
4623
- const targetCwd = hasCwdArg ? args[0] : process.cwd();
4624
- const passthroughArgs = hasCwdArg ? args.slice(1) : args;
4625
- // Wizard when no local config exists
4626
- if (!localConfigExists(targetCwd)) {
4627
- const suggested = defaultAgentName(targetCwd);
4628
- process.stdout.write(`\n[un-bien] No config found for ${targetCwd}\n`);
4629
- process.stdout.write("Let's set up this agent.\n\n");
4630
- const rl = createInterface({
4631
- input: process.stdin,
4632
- output: process.stdout,
4633
- });
4634
- const agentName = await new Promise((res) => rl.question(`Agent name [${suggested}]: `, (ans) => {
4635
- rl.close();
4636
- res(ans.trim() || suggested);
4637
- }));
4638
- saveLocalConfig(targetCwd, {
4639
- agent_name: agentName,
4640
- auto_start_relay: true,
4641
- });
4642
- process.stdout.write(`[un-bien] Config saved: agent="${agentName}"\n\n`);
4643
- }
4644
- // Resolve mesh server script path (dist/mcp/mesh_server.js)
4645
- const here = fileURLToPath(import.meta.url);
4646
- const distRoot = dirname(here);
4647
- const meshServerPath = resolve(distRoot, "mcp/mesh_server.js");
4648
- if (!existsSync(meshServerPath)) {
4649
- console.log(`[un-bien] mesh server not found at ${meshServerPath}. Run pnpm build first.`);
4650
- process.exit(1);
4651
- }
4652
- const absCwd = resolve(targetCwd);
4653
- const SERVER_NAME = "un-bien-mesh";
4654
- // The mesh MCP must be visible ONLY inside a `unbien claude` session — a
4655
- // plain `claude` in the same repo must NOT inherit it (otherwise every
4656
- // ordinary session silently joins the mesh as a stray agent).
4657
- //
4658
- // Older builds registered the server with `claude mcp add -s local`. That
4659
- // scope lives in `~/.claude.json` keyed by the **git repo root** and is
4660
- // inherited by EVERY claude session under that root — which is exactly the
4661
- // leak we're closing. So we no longer write any persistent scope; we load
4662
- // the server through an ephemeral `--mcp-config <tmpfile>` passed on the
4663
- // launch command line (see below). That config is session-only: it is never
4664
- // recorded in any scope `claude mcp list` enumerates, so a normal `claude`
4665
- // sees nothing.
4666
- //
4667
- // Migration: best-effort scrub of the stale `-s local` entry that prior
4668
- // versions left behind (and that is the source of the inherited-mesh bug).
4669
- // Idempotent — a no-op (non-zero, ignored) when the entry is already gone.
4670
- spawnSync("claude", ["mcp", "remove", SERVER_NAME, "-s", "local"], {
4671
- cwd: absCwd,
4672
- stdio: "ignore",
4673
- shell: false,
4674
- });
4675
- // Ephemeral MCP config consumed by `--mcp-config` below. We do NOT bake a
4676
- // `cwd` into it: the server resolves its folder from its own `process.cwd()`,
4677
- // which Claude sets to the directory the session was launched in (verified
4678
- // empirically — NOT the git root, NOT CLAUDE_PROJECT_DIR). We spawn claude
4679
- // with `cwd: absCwd`, the MCP child inherits it, so the server self-identifies
4680
- // as the right agent without leaking that path to any other session.
4681
- // Unique per pid so concurrent `unbien claude` launches don't collide.
4682
- const mcpConfigPath = join(tmpdir(), `un-bien-mesh-mcp-${process.pid}.json`);
4683
- writeFileSync(mcpConfigPath, JSON.stringify({
4684
- mcpServers: {
4685
- [SERVER_NAME]: { command: process.execPath, args: [meshServerPath] },
4686
- },
4687
- }));
4688
- // Inject the agent-network protocol as a system prompt instead of deploying a
4689
- // skill file into ~/.claude. Anyone running `unbien claude` is here to use
4690
- // the mesh, so load the protocol unconditionally — no lazy skill gating, no
4691
- // global skills-dir pollution, and the packaged file is the single source of
4692
- // truth shared with the Pi runtime. Skipped only if the file is missing.
4693
- const skillPath = _agentNetworkSkillPath();
4694
- // Launch flags:
4695
- // --mcp-config <tmpfile> — load the mesh server for
4696
- // THIS session only (never a persistent scope). We intentionally omit
4697
- // `--strict-mcp-config` so the user's own persistent MCP servers stay
4698
- // available alongside the mesh.
4699
- // --dangerously-load-development-channels TAG — enable claude/channel push
4700
- // for our local (non-allowlisted) server, so incoming mesh messages
4701
- // wake Claude instead of waiting for a get_messages poll. Entries must
4702
- // be tagged: `server:<name>` for a manually configured MCP server
4703
- // (`plugin:<name>@<marketplace>` is the plugin form). Shows a one-time
4704
- // confirmation dialog at startup. Works against the `--mcp-config`
4705
- // server in current Claude Code; if a build ever fails to match it, the
4706
- // per-turn `get_messages` poll (mandated by the mesh protocol) still
4707
- // delivers — we lose the wake, not the messages.
4708
- // --dangerously-skip-permissions — auto-approve tool calls
4709
- // --append-system-prompt-file=<skill> — load the mesh protocol
4710
- // `--append-system-prompt-file` uses the glued `--flag=value` form (a SINGLE
4711
- // argv token) on purpose: tools that restore a session by capturing and
4712
- // replaying the live process's argv (e.g. cmux) drop the TRAILING token,
4713
- // which here was the skill path — leaving a dangling `--append-system-prompt-file`
4714
- // → `claude` aborts with "argument missing" and the session never comes back.
4715
- // As one token, the worst case is the whole flag being dropped: claude still
4716
- // starts (just without the injected protocol), which is recoverable instead
4717
- // of fatal. (The other flags stay separate pairs — never last, so unaffected,
4718
- // and we don't risk a parser that may not accept `=`.)
4719
- // Any extra args the user passed (e.g. `--resume`, `-c`) are appended last so
4720
- // they reach the claude binary; ours come first as sensible defaults.
4721
- try {
4722
- spawnSync("claude", [
4723
- "--mcp-config",
4724
- mcpConfigPath,
4725
- "--dangerously-load-development-channels",
4726
- `server:${SERVER_NAME}`,
4727
- "--dangerously-skip-permissions",
4728
- ...(skillPath ? [`--append-system-prompt-file=${skillPath}`] : []),
4729
- ...passthroughArgs,
4730
- ], {
4731
- cwd: absCwd,
4732
- stdio: "inherit",
4733
- shell: false,
4734
- });
4735
- }
4736
- finally {
4737
- // Session over — drop the ephemeral config so it never lingers as a stray
4738
- // file. spawnSync blocks until claude exits, so claude has long since read
4739
- // it. Best-effort: ignore if already gone.
4740
- try {
4741
- unlinkSync(mcpConfigPath);
4742
- }
4743
- catch {
4744
- /* already removed */
4745
- }
4746
- }
4747
- }
4748
2699
  //# sourceMappingURL=index.js.map