@geohar/un-bien 0.9.0 → 0.16.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 (140) hide show
  1. package/README.md +100 -131
  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/client.d.ts +15 -0
  6. package/dist/client.js +15 -0
  7. package/dist/client.js.map +1 -0
  8. package/dist/commands/deps.d.ts +156 -0
  9. package/dist/commands/deps.js +2 -0
  10. package/dist/commands/deps.js.map +1 -0
  11. package/dist/commands/fork_link.d.ts +4 -0
  12. package/dist/commands/fork_link.js +45 -0
  13. package/dist/commands/fork_link.js.map +1 -0
  14. package/dist/commands/housekeeping.d.ts +34 -0
  15. package/dist/commands/housekeeping.js +275 -0
  16. package/dist/commands/housekeeping.js.map +1 -0
  17. package/dist/commands/info.d.ts +43 -0
  18. package/dist/commands/info.js +127 -0
  19. package/dist/commands/info.js.map +1 -0
  20. package/dist/commands/lifecycle.d.ts +45 -0
  21. package/dist/commands/lifecycle.js +708 -0
  22. package/dist/commands/lifecycle.js.map +1 -0
  23. package/dist/commands/pairing.d.ts +26 -0
  24. package/dist/commands/pairing.js +167 -0
  25. package/dist/commands/pairing.js.map +1 -0
  26. package/dist/commands/register.d.ts +12 -0
  27. package/dist/commands/register.js +255 -0
  28. package/dist/commands/register.js.map +1 -0
  29. package/dist/commands/relay.d.ts +28 -0
  30. package/dist/commands/relay.js +81 -0
  31. package/dist/commands/relay.js.map +1 -0
  32. package/dist/commands/session_ops.d.ts +52 -0
  33. package/dist/commands/session_ops.js +75 -0
  34. package/dist/commands/session_ops.js.map +1 -0
  35. package/dist/config.d.ts +11 -1
  36. package/dist/config.js.map +1 -1
  37. package/dist/daemon/install.d.ts +6 -5
  38. package/dist/daemon/install.js +15 -5
  39. package/dist/daemon/install.js.map +1 -1
  40. package/dist/enrich_tool_args.js.map +1 -1
  41. package/dist/extension_ui_bridge.js.map +1 -1
  42. package/dist/image_codec.js.map +1 -1
  43. package/dist/index.d.ts +32 -101
  44. package/dist/index.js +852 -2872
  45. package/dist/index.js.map +1 -1
  46. package/dist/launch.d.ts +14 -1
  47. package/dist/launch.js +55 -5
  48. package/dist/launch.js.map +1 -1
  49. package/dist/launcher/launcher.js +48 -6
  50. package/dist/launcher/launcher.js.map +1 -1
  51. package/dist/mcp/mesh_result.js.map +1 -1
  52. package/dist/mcp/mesh_server.js.map +1 -1
  53. package/dist/mesh/canonical.js +1 -1
  54. package/dist/mesh/canonical.js.map +1 -1
  55. package/dist/mesh/client.js.map +1 -1
  56. package/dist/mesh/encoding.js.map +1 -1
  57. package/dist/mesh/self_revoke.js.map +1 -1
  58. package/dist/mesh/siblings.js.map +1 -1
  59. package/dist/mesh/verify.js +1 -1
  60. package/dist/mesh/verify.js.map +1 -1
  61. package/dist/pairing/crypto.js +0 -1
  62. package/dist/pairing/crypto.js.map +1 -1
  63. package/dist/pairing/peer_trust.js.map +1 -1
  64. package/dist/pairing/qr.js.map +1 -1
  65. package/dist/pairing/storage.d.ts +7 -0
  66. package/dist/pairing/storage.js +22 -3
  67. package/dist/pairing/storage.js.map +1 -1
  68. package/dist/panel_bridge.js +1 -0
  69. package/dist/panel_bridge.js.map +1 -1
  70. package/dist/paths.d.ts +22 -8
  71. package/dist/paths.js +37 -10
  72. package/dist/paths.js.map +1 -1
  73. package/dist/protocol/codec.js.map +1 -1
  74. package/dist/rooms.js +1 -4
  75. package/dist/rooms.js.map +1 -1
  76. package/dist/session/bridge.js.map +1 -1
  77. package/dist/session/broker.js.map +1 -1
  78. package/dist/session/broker_remote.js.map +1 -1
  79. package/dist/session/capabilities.d.ts +11 -0
  80. package/dist/session/capabilities.js +35 -0
  81. package/dist/session/capabilities.js.map +1 -0
  82. package/dist/session/cwd_lock.js +3 -3
  83. package/dist/session/cwd_lock.js.map +1 -1
  84. package/dist/session/debug_log.js +13 -3
  85. package/dist/session/debug_log.js.map +1 -1
  86. package/dist/session/envelope.js.map +1 -1
  87. package/dist/session/global_config.d.ts +1 -1
  88. package/dist/session/global_config.js +1 -1
  89. package/dist/session/global_config.js.map +1 -1
  90. package/dist/session/ipc.d.ts +2 -2
  91. package/dist/session/ipc.js.map +1 -1
  92. package/dist/session/leader_election.js.map +1 -1
  93. package/dist/session/local_config.d.ts +37 -0
  94. package/dist/session/local_config.js +45 -1
  95. package/dist/session/local_config.js.map +1 -1
  96. package/dist/session/mesh_node.d.ts +1 -1
  97. package/dist/session/mesh_node.js +1 -1
  98. package/dist/session/mesh_node.js.map +1 -1
  99. package/dist/session/peer.js.map +1 -1
  100. package/dist/session/peer_inventory.js.map +1 -1
  101. package/dist/session/peer_limits.js.map +1 -1
  102. package/dist/session/received_images.d.ts +63 -0
  103. package/dist/session/received_images.js +273 -0
  104. package/dist/session/received_images.js.map +1 -0
  105. package/dist/session/relay_lifecycle.d.ts +223 -0
  106. package/dist/session/relay_lifecycle.js +802 -0
  107. package/dist/session/relay_lifecycle.js.map +1 -0
  108. package/dist/session/rpc_envelope.d.ts +10 -0
  109. package/dist/session/rpc_envelope.js +9 -0
  110. package/dist/session/rpc_envelope.js.map +1 -1
  111. package/dist/session/rpc_handlers.d.ts +53 -0
  112. package/dist/session/rpc_handlers.js +268 -0
  113. package/dist/session/rpc_handlers.js.map +1 -0
  114. package/dist/session/rpc_inbound.d.ts +41 -6
  115. package/dist/session/rpc_inbound.js +55 -0
  116. package/dist/session/rpc_inbound.js.map +1 -1
  117. package/dist/session/setup_wizard.js.map +1 -1
  118. package/dist/session/tools.js.map +1 -1
  119. package/dist/session/wizard.js.map +1 -1
  120. package/dist/state_migration.d.ts +45 -0
  121. package/dist/state_migration.js +104 -0
  122. package/dist/state_migration.js.map +1 -0
  123. package/dist/subagent_rooms.d.ts +19 -0
  124. package/dist/subagent_rooms.js +146 -14
  125. package/dist/subagent_rooms.js.map +1 -1
  126. package/dist/test_hooks.d.ts +180 -0
  127. package/dist/test_hooks.js +98 -0
  128. package/dist/test_hooks.js.map +1 -0
  129. package/dist/transport/peer_channel.d.ts +11 -0
  130. package/dist/transport/peer_channel.js +34 -11
  131. package/dist/transport/peer_channel.js.map +1 -1
  132. package/dist/transport/pi_forward_client.js.map +1 -1
  133. package/dist/transport/relay_client.d.ts +14 -3
  134. package/dist/transport/relay_client.js +6 -3
  135. package/dist/transport/relay_client.js.map +1 -1
  136. package/dist/ui/footer.js.map +1 -1
  137. package/docs/daemon.md +166 -176
  138. package/package.json +6 -2
  139. package/service-templates/launchd.plist.template +5 -4
  140. 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, pairingAllowList } 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";
65
- import { _expandTilde, _launchSession } from "./launch.js";
47
+ import { realpathSync } from "node:fs";
48
+ import { loadConfig, saveConfig, isValidRelayUrl, isWebSocketScheme, } from "./config.js";
49
+ import { _expandTilde, _launchSession, launchDirAllowed } 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
@@ -144,6 +137,12 @@ function _refreshPairingsCache() {
144
137
  void listPeers()
145
138
  .then((peers) => {
146
139
  _hasGlobalPairings = peers.length > 0;
140
+ // Keep the relay's ROOMS-gate allow-list in sync on every pairing change
141
+ // (pair / unpair / revoke). Design 01M1ZE43; no-ops when the relay is down.
142
+ _relay?.sendControl({
143
+ type: "pairing_set",
144
+ owners: pairingAllowList(peers),
145
+ });
147
146
  _refreshFooter();
148
147
  })
149
148
  .catch(() => {
@@ -211,193 +210,6 @@ function _publishWorking(working) {
211
210
  });
212
211
  }
213
212
  }
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
213
  /**
402
214
  * Issue #105 — pure-data events must not reach the model.
403
215
  *
@@ -430,57 +242,6 @@ function _filterInternalMessagesFromContext(messages) {
430
242
  !_isPureDataContextMessage(message))
431
243
  : [];
432
244
  }
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
245
  // ── Cross-PC mesh wiring (plan/25 Wave B/C) ───────────────────────────────────
485
246
  /**
486
247
  * Hand the live relay to MeshNode so it can bring up the cross-PC bridge
@@ -513,13 +274,14 @@ function _attachBridgeIfReady() {
513
274
  });
514
275
  }
515
276
  /**
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.
277
+ * Prefer an explicit ctx, else the always-fresh session_start ctx
278
+ * (`_lastEventCtx`, re-captured on every session_start so it is never a stale
279
+ * capture). Relay/async paths reach the ctx-only surfaces (ui/abort/compact)
280
+ * through this, since they fire outside any live pi event.
519
281
  * @see https://github.com/jacobaraujo7/remote_pi/issues/55
520
282
  */
521
283
  function _liveCtx(preferred) {
522
- return preferred ?? _lastEventCtx ?? _lastCtx ?? null;
284
+ return preferred ?? _lastEventCtx ?? null;
523
285
  }
524
286
  /**
525
287
  * Read `ctx.ui` without letting a stale-ctx getter become an uncaughtException.
@@ -573,7 +335,7 @@ function _refreshFooter(ctx) {
573
335
  // `devicePaired` now reflects "any owner currently attached" — picks one
574
336
  // shortid representatively (multi-owner UX detail surfaces in the
575
337
  // `/unbien status` line, not the footer slot).
576
- devicePaired: _anyPeerActive() ? _peerShort : undefined,
338
+ devicePaired: _anyPeerActive(relayDeps) ? _peerShort : undefined,
577
339
  hasPairings: _hasGlobalPairings,
578
340
  agentName: _meshNode?.name(),
579
341
  };
@@ -596,155 +358,6 @@ let _pendingMeshMessages = [];
596
358
  // agent-run active/generation now live PER-SESSION in _stateFor(sid).agentRun;
597
359
  // mesh delivery targets the ROOT run, so the drain reads _rootState().agentRun.
598
360
  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
361
  const _sessions = new Map();
749
362
  // The session bound to the app room. null until the ROOT session_start fires;
750
363
  // while null, everything is treated as root (single-session / test harness).
@@ -820,29 +433,14 @@ let _cwdLock = null;
820
433
  // registers under this name; the broker confirms it (and may bump it again under
821
434
  // a live race). Null until the lock is acquired.
822
435
  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
436
  // Root startup has pre-candidate awaits (cwd lock, wizard) that relay/mesh
832
437
  // generations cannot safely represent: child startup intentionally advances
833
438
  // those generations. Stop/off/session replacement advance this separate epoch
834
439
  // so a queued root can never regain authority by creating a newer child.
835
440
  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
441
  function _isCurrentRootLifecycle(generation) {
840
442
  return !_disposed && generation === _rootLifecycleGeneration;
841
443
  }
842
- /** Test-only: exposes pending reconnect timer state. */
843
- export function _hasPendingReconnect() {
844
- return _reconnectTimer !== null;
845
- }
846
444
  /**
847
445
  * Public state-snapshot helper. Returns the derived UX state, not the raw
848
446
  * `_state` enum: the W2D refactor collapsed the internal machine to
@@ -855,19 +453,6 @@ export function _getState() {
855
453
  return "idle";
856
454
  return _activePeers.size > 0 ? "paired" : "started";
857
455
  }
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
456
  // ── Hidden e2e UI test harness (dev-only, undocumented) ───────────────────
872
457
  // Broadcasts CANNED frames to paired apps so the app UI can be exercised
873
458
  // end-to-end without a real agent turn. For plan/subagents/rich-ask it EMITS the
@@ -880,6 +465,7 @@ const _TEST_SVG_B64 = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg" viewB
880
465
  ' font-family="sans-serif">un-bien</text></svg>').toString("base64");
881
466
  function _emitTestBus(channel, data) {
882
467
  try {
468
+ ;
883
469
  _pi?.events?.emit(channel, data);
884
470
  }
885
471
  catch {
@@ -892,7 +478,7 @@ function _runTestScenario(scenario) {
892
478
  const id = `test-${Date.now()}`;
893
479
  switch (s) {
894
480
  case "ask-select":
895
- _broadcastEnvelope({
481
+ _broadcastEnvelope(relayDeps, {
896
482
  rpc: {
897
483
  type: "extension_ui_request",
898
484
  id,
@@ -903,7 +489,7 @@ function _runTestScenario(scenario) {
903
489
  });
904
490
  return "sent ask-select";
905
491
  case "ask-confirm":
906
- _broadcastEnvelope({
492
+ _broadcastEnvelope(relayDeps, {
907
493
  rpc: {
908
494
  type: "extension_ui_request",
909
495
  id,
@@ -914,7 +500,7 @@ function _runTestScenario(scenario) {
914
500
  });
915
501
  return "sent ask-confirm";
916
502
  case "ask-input":
917
- _broadcastEnvelope({
503
+ _broadcastEnvelope(relayDeps, {
918
504
  rpc: {
919
505
  type: "extension_ui_request",
920
506
  id,
@@ -925,7 +511,7 @@ function _runTestScenario(scenario) {
925
511
  });
926
512
  return "sent ask-input";
927
513
  case "ask-editor":
928
- _broadcastEnvelope({
514
+ _broadcastEnvelope(relayDeps, {
929
515
  rpc: {
930
516
  type: "extension_ui_request",
931
517
  id,
@@ -936,7 +522,7 @@ function _runTestScenario(scenario) {
936
522
  });
937
523
  return "sent ask-editor";
938
524
  case "ask-notify":
939
- _broadcastEnvelope({
525
+ _broadcastEnvelope(relayDeps, {
940
526
  rpc: {
941
527
  type: "extension_ui_request",
942
528
  id,
@@ -1020,8 +606,8 @@ function _runTestScenario(scenario) {
1020
606
  // `{content:[{type:"image",data,mimeType}]}`) and renders them below the
1021
607
  // card (WireImageView -> SVGImageView). Deliver the SVG that way.
1022
608
  const tc = `tc-svg-${Date.now()}`;
1023
- _broadcastEnvelope({ rpc: { type: "turn_start" } });
1024
- _broadcastEnvelope({
609
+ _broadcastEnvelope(relayDeps, { rpc: { type: "turn_start" } });
610
+ _broadcastEnvelope(relayDeps, {
1025
611
  rpc: {
1026
612
  type: "tool_execution_start",
1027
613
  toolCallId: tc,
@@ -1029,7 +615,7 @@ function _runTestScenario(scenario) {
1029
615
  args: { note: "test svg" },
1030
616
  },
1031
617
  });
1032
- _broadcastEnvelope({
618
+ _broadcastEnvelope(relayDeps, {
1033
619
  rpc: {
1034
620
  type: "tool_execution_end",
1035
621
  toolCallId: tc,
@@ -1046,13 +632,13 @@ function _runTestScenario(scenario) {
1046
632
  isError: false,
1047
633
  },
1048
634
  });
1049
- _broadcastEnvelope({ rpc: { type: "agent_settled" } });
635
+ _broadcastEnvelope(relayDeps, { rpc: { type: "agent_settled" } });
1050
636
  return "sent svg (envelope tool_execution_end + image)";
1051
637
  }
1052
638
  case "tool": {
1053
639
  const tc = `tc-${Date.now()}`;
1054
- _broadcastEnvelope({ rpc: { type: "turn_start" } });
1055
- _broadcastEnvelope({
640
+ _broadcastEnvelope(relayDeps, { rpc: { type: "turn_start" } });
641
+ _broadcastEnvelope(relayDeps, {
1056
642
  rpc: {
1057
643
  type: "tool_execution_start",
1058
644
  toolCallId: tc,
@@ -1060,7 +646,7 @@ function _runTestScenario(scenario) {
1060
646
  args: { command: "echo hello" },
1061
647
  },
1062
648
  });
1063
- _broadcastEnvelope({
649
+ _broadcastEnvelope(relayDeps, {
1064
650
  rpc: {
1065
651
  type: "tool_execution_end",
1066
652
  toolCallId: tc,
@@ -1068,7 +654,7 @@ function _runTestScenario(scenario) {
1068
654
  isError: false,
1069
655
  },
1070
656
  });
1071
- _broadcastEnvelope({ rpc: { type: "agent_settled" } });
657
+ _broadcastEnvelope(relayDeps, { rpc: { type: "agent_settled" } });
1072
658
  return "sent tool pair (envelope)";
1073
659
  }
1074
660
  case "diff": {
@@ -1086,8 +672,8 @@ function _runTestScenario(scenario) {
1086
672
  ],
1087
673
  },
1088
674
  ];
1089
- _broadcastEnvelope({ rpc: { type: "turn_start" } });
1090
- _broadcastEnvelope({
675
+ _broadcastEnvelope(relayDeps, { rpc: { type: "turn_start" } });
676
+ _broadcastEnvelope(relayDeps, {
1091
677
  rpc: {
1092
678
  type: "tool_execution_start",
1093
679
  toolCallId: tc,
@@ -1100,7 +686,7 @@ function _runTestScenario(scenario) {
1100
686
  },
1101
687
  aux: { hunks },
1102
688
  });
1103
- _broadcastEnvelope({
689
+ _broadcastEnvelope(relayDeps, {
1104
690
  rpc: {
1105
691
  type: "tool_execution_end",
1106
692
  toolCallId: tc,
@@ -1108,15 +694,15 @@ function _runTestScenario(scenario) {
1108
694
  isError: false,
1109
695
  },
1110
696
  });
1111
- _broadcastEnvelope({ rpc: { type: "agent_settled" } });
697
+ _broadcastEnvelope(relayDeps, { rpc: { type: "agent_settled" } });
1112
698
  return "sent diff (edit + aux hunks — shows the Diff⇄Content toggle)";
1113
699
  }
1114
700
  case "code-shell": {
1115
701
  // bash-family result → the app classifies it into a `code` block (lang
1116
702
  // shell), syntax-highlighted. OUTPUT is app-side now — no aux stamped.
1117
703
  const tc = `tc-sh-${Date.now()}`;
1118
- _broadcastEnvelope({ rpc: { type: "turn_start" } });
1119
- _broadcastEnvelope({
704
+ _broadcastEnvelope(relayDeps, { rpc: { type: "turn_start" } });
705
+ _broadcastEnvelope(relayDeps, {
1120
706
  rpc: {
1121
707
  type: "tool_execution_start",
1122
708
  toolCallId: tc,
@@ -1124,7 +710,7 @@ function _runTestScenario(scenario) {
1124
710
  args: { command: "ls -la" },
1125
711
  },
1126
712
  });
1127
- _broadcastEnvelope({
713
+ _broadcastEnvelope(relayDeps, {
1128
714
  rpc: {
1129
715
  type: "tool_execution_end",
1130
716
  toolCallId: tc,
@@ -1132,15 +718,15 @@ function _runTestScenario(scenario) {
1132
718
  isError: false,
1133
719
  },
1134
720
  });
1135
- _broadcastEnvelope({ rpc: { type: "agent_settled" } });
721
+ _broadcastEnvelope(relayDeps, { rpc: { type: "agent_settled" } });
1136
722
  return "sent code-shell (bash output → code block, lang shell)";
1137
723
  }
1138
724
  case "code-file": {
1139
725
  // read-family with a *.swift path → `code` block, lang inferred from the
1140
726
  // extension (swift) and highlighted.
1141
727
  const tc = `tc-rd-${Date.now()}`;
1142
- _broadcastEnvelope({ rpc: { type: "turn_start" } });
1143
- _broadcastEnvelope({
728
+ _broadcastEnvelope(relayDeps, { rpc: { type: "turn_start" } });
729
+ _broadcastEnvelope(relayDeps, {
1144
730
  rpc: {
1145
731
  type: "tool_execution_start",
1146
732
  toolCallId: tc,
@@ -1148,7 +734,7 @@ function _runTestScenario(scenario) {
1148
734
  args: { path: "/src/Greeter.swift" },
1149
735
  },
1150
736
  });
1151
- _broadcastEnvelope({
737
+ _broadcastEnvelope(relayDeps, {
1152
738
  rpc: {
1153
739
  type: "tool_execution_end",
1154
740
  toolCallId: tc,
@@ -1156,15 +742,15 @@ function _runTestScenario(scenario) {
1156
742
  isError: false,
1157
743
  },
1158
744
  });
1159
- _broadcastEnvelope({ rpc: { type: "agent_settled" } });
745
+ _broadcastEnvelope(relayDeps, { rpc: { type: "agent_settled" } });
1160
746
  return "sent code-file (read .swift → highlighted code block)";
1161
747
  }
1162
748
  case "diff-output": {
1163
749
  // A tool whose RESULT already embeds a unified diff → the app parses it
1164
750
  // into a `diff` block (re-reading persisted text; replay-safe).
1165
751
  const tc = `tc-do-${Date.now()}`;
1166
- _broadcastEnvelope({ rpc: { type: "turn_start" } });
1167
- _broadcastEnvelope({
752
+ _broadcastEnvelope(relayDeps, { rpc: { type: "turn_start" } });
753
+ _broadcastEnvelope(relayDeps, {
1168
754
  rpc: {
1169
755
  type: "tool_execution_start",
1170
756
  toolCallId: tc,
@@ -1172,7 +758,7 @@ function _runTestScenario(scenario) {
1172
758
  args: { command: "git diff" },
1173
759
  },
1174
760
  });
1175
- _broadcastEnvelope({
761
+ _broadcastEnvelope(relayDeps, {
1176
762
  rpc: {
1177
763
  type: "tool_execution_end",
1178
764
  toolCallId: tc,
@@ -1180,7 +766,7 @@ function _runTestScenario(scenario) {
1180
766
  isError: false,
1181
767
  },
1182
768
  });
1183
- _broadcastEnvelope({ rpc: { type: "agent_settled" } });
769
+ _broadcastEnvelope(relayDeps, { rpc: { type: "agent_settled" } });
1184
770
  return "sent diff-output (result embeds a unified diff → diff block)";
1185
771
  }
1186
772
  case "write": {
@@ -1188,8 +774,8 @@ function _runTestScenario(scenario) {
1188
774
  // card shows the Content view (new text as a code block, replay-safe).
1189
775
  const tc = `tc-wr-${Date.now()}`;
1190
776
  const content = "export function add(a: number, b: number): number {\n return a + b;\n}";
1191
- _broadcastEnvelope({ rpc: { type: "turn_start" } });
1192
- _broadcastEnvelope({
777
+ _broadcastEnvelope(relayDeps, { rpc: { type: "turn_start" } });
778
+ _broadcastEnvelope(relayDeps, {
1193
779
  rpc: {
1194
780
  type: "tool_execution_start",
1195
781
  toolCallId: tc,
@@ -1197,7 +783,7 @@ function _runTestScenario(scenario) {
1197
783
  args: { path: "/src/math.ts", content },
1198
784
  },
1199
785
  });
1200
- _broadcastEnvelope({
786
+ _broadcastEnvelope(relayDeps, {
1201
787
  rpc: {
1202
788
  type: "tool_execution_end",
1203
789
  toolCallId: tc,
@@ -1205,12 +791,12 @@ function _runTestScenario(scenario) {
1205
791
  isError: false,
1206
792
  },
1207
793
  });
1208
- _broadcastEnvelope({ rpc: { type: "agent_settled" } });
794
+ _broadcastEnvelope(relayDeps, { rpc: { type: "agent_settled" } });
1209
795
  return "sent write (args.content → content-as-code block)";
1210
796
  }
1211
797
  case "agent": {
1212
- _broadcastEnvelope({ rpc: { type: "turn_start" } });
1213
- _broadcastEnvelope({
798
+ _broadcastEnvelope(relayDeps, { rpc: { type: "turn_start" } });
799
+ _broadcastEnvelope(relayDeps, {
1214
800
  rpc: {
1215
801
  type: "message_update",
1216
802
  assistantMessageEvent: {
@@ -1219,7 +805,7 @@ function _runTestScenario(scenario) {
1219
805
  },
1220
806
  },
1221
807
  });
1222
- _broadcastEnvelope({
808
+ _broadcastEnvelope(relayDeps, {
1223
809
  rpc: {
1224
810
  type: "message_update",
1225
811
  assistantMessageEvent: {
@@ -1228,7 +814,7 @@ function _runTestScenario(scenario) {
1228
814
  },
1229
815
  },
1230
816
  });
1231
- _broadcastEnvelope({
817
+ _broadcastEnvelope(relayDeps, {
1232
818
  rpc: {
1233
819
  type: "message_end",
1234
820
  message: {
@@ -1237,11 +823,11 @@ function _runTestScenario(scenario) {
1237
823
  },
1238
824
  },
1239
825
  });
1240
- _broadcastEnvelope({ rpc: { type: "agent_settled" } });
826
+ _broadcastEnvelope(relayDeps, { rpc: { type: "agent_settled" } });
1241
827
  return "sent agent message (envelope)";
1242
828
  }
1243
829
  case "error":
1244
- _broadcastEnvelope({
830
+ _broadcastEnvelope(relayDeps, {
1245
831
  rpc: {
1246
832
  type: "message_end",
1247
833
  message: {
@@ -1251,7 +837,7 @@ function _runTestScenario(scenario) {
1251
837
  },
1252
838
  },
1253
839
  });
1254
- _broadcastEnvelope({ rpc: { type: "agent_settled" } });
840
+ _broadcastEnvelope(relayDeps, { rpc: { type: "agent_settled" } });
1255
841
  return "sent error (envelope message_end/error)";
1256
842
  case "all":
1257
843
  for (const sc of [
@@ -1296,150 +882,7 @@ function _routeRpcCommandFrom(sender, env) {
1296
882
  // session_sync (reconstruction) is un-bien's OWN protocol — dispatched on the
1297
883
  // un plane by _routeUnBienPlaneFrom, NOT here. Only byte-faithful pi rpc
1298
884
  // 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
- };
885
+ const handlers = createRpcHandlers(rpcDeps, sender);
1443
886
  void dispatchRpcCommand(frame, handlers)
1444
887
  .then((resp) => {
1445
888
  // Envelope-native ONLY: no stock fallback. An unhandled rpc type is
@@ -1481,16 +924,99 @@ function _routeUnBienPlaneFrom(sender, env) {
1481
924
  envLog(`session_sync(ub): panels=${panels.length} + ui (transcript is the app's get_entries rpc)`);
1482
925
  // Terminator/ack on the ub plane; carries the session clock so the app can
1483
926
  // detect a pi restart. `truncated`/`limit` are gone (a replay concern;
1484
- // get_entries is unbounded / since-delta).
927
+ // get_entries is unbounded / since-delta). `session_name` (pre-release
928
+ // 2026-09-18): the NAME PULL — heals a missed session_info_changed push
929
+ // (an iOS backgrounded socket misses live frames; the relay's stored meta
930
+ // is stale until our next hello, so reconnects can't carry it either).
931
+ // session_sync is issued on EVERY open + reconnect, so the name heals on
932
+ // every natural cycle. Old apps ignore the field; old extensions omit it.
933
+ //
934
+ // SOURCE = the room's DISPLAY name (myRoomMeta.name), NOT deps.sessionName:
935
+ // the latter is `_sessionName`, which `_cmdJoin` sets to the mesh session
936
+ // id LOCAL_SESSION_NAME ("local"). Sending that here clobbered the tile
937
+ // label with "local" on every reconnect (the flaky-name bug) — the
938
+ // non-live pull disagreed with the live session_info push. myRoomMeta.name
939
+ // is the same value pair_ok sends (deps.displayName) and tracks live
940
+ // renames (session_info_changed updates it), so pull == push now.
941
+ const displayName = deps.myRoomMeta?.name;
942
+ // Fork auto-nav: a fork-born session's FIRST sync echoes the app's
943
+ // originating fork request id (design: fork switch). take = read+unlink,
944
+ // so it fires once (the app navigates on first receipt; later reconnects
945
+ // must not re-jump). Keyed by the session id, so it survives the extension
946
+ // module re-eval that ctx.fork triggers.
947
+ const forkSm = _rootState().sessionManager;
948
+ const forkedFromReq = forkSm && typeof forkSm.getSessionDir === "function"
949
+ ? takeForkLink(forkSm.getSessionDir(), forkSm.getSessionId())
950
+ : undefined;
1485
951
  sender.sendEnvelope({
1486
952
  ub: {
1487
953
  type: "session_sync_end",
1488
954
  ...(typeof f.id === "string" ? { in_reply_to: f.id } : {}),
1489
955
  session_started_at: _sessionStartedAt ?? 0,
956
+ ...(displayName ? { session_name: displayName } : {}),
957
+ ...(forkedFromReq ? { forked_from_req: forkedFromReq } : {}),
1490
958
  },
1491
959
  });
1492
960
  return;
1493
961
  }
962
+ if (type === "terminate") {
963
+ // app->ext: kill THIS session. Root room only — a child room's terminate
964
+ // is handled by the child-room router in subagent_rooms (never reaches
965
+ // here; that room's conn dispatches its own ub frames). Gate on local
966
+ // config; the app confirms + shows red before sending.
967
+ if (!effectiveAllowRemoteTerminate(loadLocalConfig(process.cwd()))) {
968
+ envLog("terminate(ub): remote terminate disabled on this machine");
969
+ return;
970
+ }
971
+ envLog(`terminate(ub): graceful host shutdown requested${typeof frame.reason === "string" ? ` (${String(frame.reason)})` : ""}`);
972
+ _safeNotify("Terminated from the app", "warning");
973
+ try {
974
+ // ctx.shutdown() IS the /quit equivalent — pi's own source lists
975
+ // "extension shutdown()" alongside Ctrl+D / /quit (interactive-mode.js
976
+ // 3229: same graceful this.shutdown(): drain input, ui.stop() restores
977
+ // cooked mode + cursor, extension disposal, resume hint, exit). It sits
978
+ // DIRECTLY on the event ctx (ExtensionContext.shutdown — there is no
979
+ // ctx.actions sub-object; the first two attempts failed exactly there).
980
+ //
981
+ // MID-TURN DEFERRAL: the interactive handler fires immediately only when
982
+ // idle; otherwise it sets `shutdownRequested` and waits for
983
+ // `agent_settled` — which never arrives when nothing is running. So
984
+ // abort() FIRST: aborting a running turn forces the settle that
985
+ // re-checks the flag; on an idle session abort is a no-op and the
986
+ // idle fast-path fires directly.
987
+ //
988
+ // NO process.exit fallback, ever: a raw exit leaves the TTY in raw
989
+ // mode (keypresses echo escape codes until `stty sane`). No live ctx =>
990
+ // log loudly and stay up; the user quits on the machine.
991
+ const ctx = _liveCtx();
992
+ if (ctx?.shutdown) {
993
+ ctx.abort?.();
994
+ ctx.shutdown();
995
+ }
996
+ else {
997
+ envLog("terminate(ub): no live ctx — refusing raw exit (would leave the terminal in raw mode); staying up");
998
+ }
999
+ }
1000
+ catch (err) {
1001
+ envLog(`terminate(ub) error: ${err instanceof Error ? err.message : String(err)}`);
1002
+ }
1003
+ return;
1004
+ }
1005
+ if (type === "close_child_room") {
1006
+ // app->ext(PARENT): permanently close one of MY child rooms (done-subagent
1007
+ // removal — the room lingers by design otherwise). Tombstone + dispose in
1008
+ // subagent_rooms; the relay fires room_ended when the last conn drops.
1009
+ if (!effectiveAllowRemoteTerminate(loadLocalConfig(process.cwd()))) {
1010
+ envLog("close_child_room(ub): remote terminate disabled on this machine");
1011
+ return;
1012
+ }
1013
+ const roomId = frame.room_id;
1014
+ if (typeof roomId !== "string" || roomId.length === 0)
1015
+ return;
1016
+ const closed = _subagentRooms?.closeChildRoom(roomId) ?? false;
1017
+ envLog(`close_child_room(ub): room=${roomId.slice(0, 8)} closed=${closed}`);
1018
+ return;
1019
+ }
1494
1020
  if (type === "session_launch") {
1495
1021
  const f = frame;
1496
1022
  const cwd = _expandTilde(typeof f.cwd === "string" && f.cwd.length > 0 ? f.cwd : process.cwd());
@@ -1498,6 +1024,16 @@ function _routeUnBienPlaneFrom(sender, env) {
1498
1024
  envLog("session_launch(ub): remote launch disabled on this machine");
1499
1025
  return;
1500
1026
  }
1027
+ // Directory allow-list (design 01M211VW9), read FRESH each request.
1028
+ if (!launchDirAllowed(cwd, loadConfig().launch?.dirs)) {
1029
+ envLog("session_launch(ub): cwd not in launch.dirs allow-list");
1030
+ sender.send({
1031
+ type: "error",
1032
+ code: "permission_denied",
1033
+ message: "Remote launch not permitted for this directory",
1034
+ });
1035
+ return;
1036
+ }
1501
1037
  // Backend is a MACHINE config choice (pick-one via launch.backend), not
1502
1038
  // app-chosen; rpc is a fast-follow so only tmux|herdr resolve here.
1503
1039
  const backend = loadConfig().launch?.backend === "herdr" ? "herdr" : "tmux";
@@ -1506,67 +1042,42 @@ function _routeUnBienPlaneFrom(sender, env) {
1506
1042
  envLog(`session_launch(ub) error: ${launchError}`);
1507
1043
  return;
1508
1044
  }
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);
1045
+ if (type === "session_fork" || type === "session_navigate") {
1046
+ // app->ext: fork a NEW session / branch IN PLACE from a conversation entry.
1047
+ // ctx.fork / ctx.navigateTree live ONLY on the command ctx, so we can't act
1048
+ // here (this is the peer-channel dispatch, not a command). Self-dispatch the
1049
+ // registered `/unbien fork|branch` command via sendUserMessage — pi runs it
1050
+ // with a real ExtensionCommandContext. No stashed ctx, no startup bootstrap:
1051
+ // the command ctx lives exactly as long as the op that needs it. Not gated
1052
+ // beyond being paired — these are no more privileged than a prompt on this
1053
+ // same room (which the app can already send).
1054
+ const f = frame;
1055
+ const entryId = typeof f.entry_id === "string" ? f.entry_id : "";
1056
+ if (!entryId) {
1057
+ envLog(`${String(type)}(ub): missing entry_id — ignored`);
1058
+ return;
1538
1059
  }
1539
- catch {
1540
- /* best-effort per channel */
1060
+ if (type === "session_fork") {
1061
+ // Thread the request id (reqId) so the new session can echo it back as
1062
+ // forked_from_req on its first sync — the app's auto-nav correlation.
1063
+ // Order: `<entryId> <reqId> [pos]` (see _cmdFork's parse).
1064
+ const reqId = typeof f.id === "string" ? f.id : "";
1065
+ const posArg = f.position === "at" || f.position === "before" ? ` ${f.position}` : "";
1066
+ const reqArg = reqId ? ` ${reqId}` : "";
1067
+ envLog(`session_fork(ub): entry=${entryId.slice(0, 8)}${posArg}`);
1068
+ _pi?.sendUserMessage(`/unbien fork ${entryId}${reqArg}${posArg}`, {
1069
+ deliverAs: "followUp",
1070
+ expandPromptTemplates: true,
1071
+ });
1072
+ }
1073
+ else {
1074
+ envLog(`session_navigate(ub): entry=${entryId.slice(0, 8)}`);
1075
+ _pi?.sendUserMessage(`/unbien branch ${entryId}`, {
1076
+ deliverAs: "followUp",
1077
+ expandPromptTemplates: true,
1078
+ });
1541
1079
  }
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
1080
  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
1081
  }
1571
1082
  }
1572
1083
  // ── Display-name helpers ──────────────────────────────────────────────────────
@@ -1588,12 +1099,14 @@ function _detachPeerChannel(appPeerId) {
1588
1099
  * inline at three different sites (pair_ok, room_meta, QR URI); this
1589
1100
  * helper consolidates them and lifts the user's configured name above
1590
1101
  * the raw cwd path.
1102
+ *
1103
+ * A session-scoped name (pi `-n`, set by the remote launcher) slots in just
1104
+ * below the broker-assigned mesh name — see `resolveAgentName`.
1591
1105
  */
1592
1106
  function _displayName(cwd) {
1593
1107
  if (_meshNode)
1594
1108
  return _meshNode.name();
1595
- const local = loadLocalConfig(cwd);
1596
- return local.agent_name || defaultAgentName(cwd);
1109
+ return resolveAgentName(cwd, piSessionName(_pi));
1597
1110
  }
1598
1111
  function _reportRevocationByFingerprint(canonicalOwnerPubkey) {
1599
1112
  const fingerprint = _runtimeOwnerFingerprint(canonicalOwnerPubkey);
@@ -1609,299 +1122,10 @@ function _revokeActiveOwnerRuntime(canonicalOwnerPubkey) {
1609
1122
  if (!_activePeers.has(canonicalOwnerPubkey))
1610
1123
  return;
1611
1124
  _refreshPairingsCache();
1612
- _detachPeerChannel(canonicalOwnerPubkey);
1125
+ _detachPeerChannel(relayDeps, canonicalOwnerPubkey);
1613
1126
  _refreshFooter();
1614
1127
  _reportRevocationByFingerprint(canonicalOwnerPubkey);
1615
1128
  }
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
1129
  /**
1906
1130
  * Rename the agent LIVE (plan/38/41), without restarting the process or losing
1907
1131
  * the SDK session/conversation. Touches two layers:
@@ -1931,7 +1155,7 @@ async function _renameAgent(newName) {
1931
1155
  // clean; bring it back up after with the new name.
1932
1156
  const wasStarted = _getState() !== "idle";
1933
1157
  if (wasStarted)
1934
- _goIdle();
1158
+ _goIdle(relayDeps);
1935
1159
  let assigned = newName;
1936
1160
  try {
1937
1161
  assigned = await _meshNode.rename(newName); // broker soft rejoin
@@ -1940,7 +1164,7 @@ async function _renameAgent(newName) {
1940
1164
  ctx.ui.notify(`[un-bien] rename failed: ${String(err)}`, "error");
1941
1165
  }
1942
1166
  if (wasStarted && !_disposed)
1943
- await _cmdStart(ctx); // relay back up → roomIdFor(cwd, assigned)
1167
+ await _cmdStart(deps, ctx); // relay back up → roomIdFor(cwd, assigned)
1944
1168
  _pi?.sendMessage({
1945
1169
  customType: "un-bien:name-assigned",
1946
1170
  content: assigned === newName
@@ -1950,338 +1174,48 @@ async function _renameAgent(newName) {
1950
1174
  display: false,
1951
1175
  });
1952
1176
  }
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;
1177
+ // ── Extension factory (default export) ───────────────────────────────────────
1178
+ // One-shot flag for the turn-ctx surface probe (see the turn_start handler).
1179
+ let _turnCtxProbed = false;
1180
+ /** TARGETED, PROXY-SAFE ctx probe: the ctx objects carry guarded getters
1181
+ * (throwing on invalid/stale access — see _ctxUi), so full reflection
1182
+ * under-reports or throws. Access each interesting member individually,
1183
+ * catching per property. Reflection-only: never invokes anything. */
1184
+ function _probeCtx(label, ctx) {
1185
+ const names = [
1186
+ "fork",
1187
+ "navigateTree",
1188
+ "newSession",
1189
+ "switchSession",
1190
+ "compact",
1191
+ "shutdown",
1192
+ "sendMessage",
1193
+ "prompt",
1194
+ "clearQueue",
1195
+ ];
1196
+ const parts = [];
1197
+ // SAFETY: per-property try/catch — a throwing getter (guarded proxy)
1198
+ // reports "throws" instead of failing the whole probe.
1199
+ const target = ctx;
1200
+ for (const name of names) {
1201
+ try {
1202
+ const v = target[name];
1203
+ parts.push(`${name}=${typeof v === "function" ? "fn" : String(v)}`);
1204
+ }
1205
+ catch {
1206
+ parts.push(`${name}=throws`);
1207
+ }
1978
1208
  }
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.
1209
+ envLog(`${label} targeted: ${parts.join(" ")}`);
1985
1210
  }
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;
2051
- try {
2052
- outer = JSON.parse(line);
2053
- }
2054
- catch {
2055
- return;
2056
- }
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
- }
2181
- return caps;
2182
- }
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
1211
  // Freshest base ExtensionContext, re-captured on EVERY `session_start`
2279
1212
  // (startup/new/fork/reload/resume). The session_start ctx is always bound to
2280
1213
  // the CURRENT session, so compact + cancel (base-ctx methods) routed through
2281
1214
  // here never hit a stale ctx — regardless of who triggered the replacement
2282
1215
  // (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`.
1216
+ // base-ctx methods (no newSession — that's command-ctx only); command-only ops
1217
+ // (fork/new/branch) reach a command ctx by self-dispatching a slash command
1218
+ // instead of retaining one.
2285
1219
  let _lastEventCtx = null;
2286
1220
  const _noopCtx = { ui: { notify: () => undefined }, abort: () => undefined };
2287
1221
  // A single Pi process can load this extension TWICE in the SAME session:
@@ -2341,6 +1275,466 @@ function _releaseRootSession(pi) {
2341
1275
  if (g[_ROOT_SESSION_OWNER_KEY] === pi)
2342
1276
  delete g[_ROOT_SESSION_OWNER_KEY];
2343
1277
  }
1278
+ // ── Received-image pipeline seam ──────────────────────────────────────────
1279
+ //
1280
+ // The image-preview domain lives in ./session/received_images.ts and reaches
1281
+ // this module's state + helpers ONLY through this object (that module never
1282
+ // imports ../index.js — no circular imports). Same pattern as CommandDeps.
1283
+ const imageDeps = {
1284
+ get pi() {
1285
+ return _pi;
1286
+ },
1287
+ rootState: _rootState,
1288
+ get myRoomMeta() {
1289
+ return _myRoomMeta;
1290
+ },
1291
+ wakeAgent: _wakeAgent,
1292
+ };
1293
+ // ── Rpc-inbound handler seam ──────────────────────────────────────────────
1294
+ //
1295
+ // The rpc-command handler implementations live in ./session/rpc_handlers.ts
1296
+ // (a factory this module's `_routeRpcCommandFrom` — which owns channel/sender
1297
+ // routing — calls per dispatch). Same pattern as CommandDeps: that module
1298
+ // never imports ../index.js — no circular imports.
1299
+ const rpcDeps = {
1300
+ get pi() {
1301
+ return _pi;
1302
+ },
1303
+ rootState: _rootState,
1304
+ get lastEventCtx() {
1305
+ return _lastEventCtx;
1306
+ },
1307
+ imageDeps,
1308
+ wakeAgent: _wakeAgent,
1309
+ abortCurrentTurn: () => _abortCurrentTurn(),
1310
+ };
1311
+ // ── Relay lifecycle seam ──────────────────────────────────────────────────
1312
+ //
1313
+ // The relay lifecycle + owner-management code lives in
1314
+ // ./session/relay_lifecycle.ts and reaches this module's state and helpers
1315
+ // ONLY through this object (that module never imports ../index.js — no
1316
+ // circular imports). Same pattern as CommandDeps. The two wrappers below
1317
+ // preserve the exact `_handleControl` / `_onPeerDisconnect` export signatures
1318
+ // the integration tests import.
1319
+ const relayDeps = {
1320
+ get state() {
1321
+ return _state;
1322
+ },
1323
+ set state(v) {
1324
+ _state = v;
1325
+ },
1326
+ get relay() {
1327
+ return _relay;
1328
+ },
1329
+ set relay(v) {
1330
+ _relay = v;
1331
+ },
1332
+ get relayUrl() {
1333
+ return _relayUrl;
1334
+ },
1335
+ set relayUrl(v) {
1336
+ _relayUrl = v;
1337
+ },
1338
+ get peerShort() {
1339
+ return _peerShort;
1340
+ },
1341
+ set peerShort(v) {
1342
+ _peerShort = v;
1343
+ },
1344
+ get stopAutoListener() {
1345
+ return _stopAutoListener;
1346
+ },
1347
+ set stopAutoListener(v) {
1348
+ _stopAutoListener = v;
1349
+ },
1350
+ get selfRevoke() {
1351
+ return _selfRevoke;
1352
+ },
1353
+ set selfRevoke(v) {
1354
+ _selfRevoke = v;
1355
+ },
1356
+ get selfRevokeEpoch() {
1357
+ return _selfRevokeEpoch;
1358
+ },
1359
+ set selfRevokeEpoch(v) {
1360
+ _selfRevokeEpoch = v;
1361
+ },
1362
+ get selfRevokeTopologyReadyEpoch() {
1363
+ return _selfRevokeTopologyReadyEpoch;
1364
+ },
1365
+ set selfRevokeTopologyReadyEpoch(v) {
1366
+ _selfRevokeTopologyReadyEpoch = v;
1367
+ },
1368
+ get selfRevokeTopology() {
1369
+ return _selfRevokeTopology;
1370
+ },
1371
+ set selfRevokeTopology(v) {
1372
+ _selfRevokeTopology = v;
1373
+ },
1374
+ get rootLifecycleGeneration() {
1375
+ return _rootLifecycleGeneration;
1376
+ },
1377
+ set rootLifecycleGeneration(v) {
1378
+ _rootLifecycleGeneration = v;
1379
+ },
1380
+ get meshNode() {
1381
+ return _meshNode;
1382
+ },
1383
+ get cachedEd25519() {
1384
+ return _cachedEd25519;
1385
+ },
1386
+ get myRoomId() {
1387
+ return _myRoomId;
1388
+ },
1389
+ get myRoomMeta() {
1390
+ return _myRoomMeta;
1391
+ },
1392
+ get sessionStartedAt() {
1393
+ return _sessionStartedAt;
1394
+ },
1395
+ sessionCwd: () => {
1396
+ const sm = _rootState().sessionManager;
1397
+ return sm && typeof sm.getCwd === "function" ? sm.getCwd() : process.cwd();
1398
+ },
1399
+ get disposed() {
1400
+ return _disposed;
1401
+ },
1402
+ get pi() {
1403
+ return _pi;
1404
+ },
1405
+ get rootSessionId() {
1406
+ return _rootSessionId;
1407
+ },
1408
+ get commandDeps() {
1409
+ return deps;
1410
+ },
1411
+ activePeers: _activePeers,
1412
+ rootState: _rootState,
1413
+ getState: _getState,
1414
+ refreshFooter: _refreshFooter,
1415
+ safeNotify: _safeNotify,
1416
+ refreshPairingsCache: _refreshPairingsCache,
1417
+ displayName: _displayName,
1418
+ attachBridgeIfReady: _attachBridgeIfReady,
1419
+ routeClientMessageFrom: _routeClientMessageFrom,
1420
+ routeRpcCommandFrom: _routeRpcCommandFrom,
1421
+ routeUnBienPlaneFrom: _routeUnBienPlaneFrom,
1422
+ liveCtx: _liveCtx,
1423
+ noopCtx: _noopCtx,
1424
+ renameAgent: _renameAgent,
1425
+ };
1426
+ export async function _handleControl(cmd) {
1427
+ await _handleControlImpl(relayDeps, cmd);
1428
+ }
1429
+ export function _onPeerDisconnect(appPeerId) {
1430
+ _onPeerDisconnectImpl(relayDeps, appPeerId);
1431
+ }
1432
+ // ── Command seam ──────────────────────────────────────────────────────────────
1433
+ //
1434
+ // The /unbien handlers live in ./commands/ and reach this module's state
1435
+ // and helpers ONLY through this object (command modules never import
1436
+ // ../index.js — no circular imports). Mutable module state is exposed as
1437
+ // accessor closures so handlers and this module always see the same
1438
+ // variables.
1439
+ const deps = {
1440
+ get state() {
1441
+ return _state;
1442
+ },
1443
+ set state(v) {
1444
+ _state = v;
1445
+ },
1446
+ get relay() {
1447
+ return _relay;
1448
+ },
1449
+ set relay(v) {
1450
+ _relay = v;
1451
+ },
1452
+ get relayUrl() {
1453
+ return _relayUrl;
1454
+ },
1455
+ set relayUrl(v) {
1456
+ _relayUrl = v;
1457
+ },
1458
+ get meshNode() {
1459
+ return _meshNode;
1460
+ },
1461
+ set meshNode(v) {
1462
+ _meshNode = v;
1463
+ },
1464
+ get sessionName() {
1465
+ return _sessionName;
1466
+ },
1467
+ set sessionName(v) {
1468
+ _sessionName = v;
1469
+ },
1470
+ get sessionPeerCount() {
1471
+ return _sessionPeerCount;
1472
+ },
1473
+ set sessionPeerCount(v) {
1474
+ _sessionPeerCount = v;
1475
+ },
1476
+ get cachedEd25519() {
1477
+ return _cachedEd25519;
1478
+ },
1479
+ set cachedEd25519(v) {
1480
+ _cachedEd25519 = v;
1481
+ },
1482
+ get currentModel() {
1483
+ return _currentModel;
1484
+ },
1485
+ set currentModel(v) {
1486
+ _currentModel = v;
1487
+ },
1488
+ get currentThinking() {
1489
+ return _currentThinking;
1490
+ },
1491
+ set currentThinking(v) {
1492
+ _currentThinking = v;
1493
+ },
1494
+ get myRoomId() {
1495
+ return _myRoomId;
1496
+ },
1497
+ set myRoomId(v) {
1498
+ _myRoomId = v;
1499
+ },
1500
+ get myRoomMeta() {
1501
+ return _myRoomMeta;
1502
+ },
1503
+ set myRoomMeta(v) {
1504
+ _myRoomMeta = v;
1505
+ },
1506
+ get peerShort() {
1507
+ return _peerShort;
1508
+ },
1509
+ set peerShort(v) {
1510
+ _peerShort = v;
1511
+ },
1512
+ get sessionStartedAt() {
1513
+ return _sessionStartedAt;
1514
+ },
1515
+ set sessionStartedAt(v) {
1516
+ _sessionStartedAt = v;
1517
+ },
1518
+ get cwdLock() {
1519
+ return _cwdLock;
1520
+ },
1521
+ set cwdLock(v) {
1522
+ _cwdLock = v;
1523
+ },
1524
+ get lockedName() {
1525
+ return _lockedName;
1526
+ },
1527
+ set lockedName(v) {
1528
+ _lockedName = v;
1529
+ },
1530
+ get relayLifecycleGeneration() {
1531
+ return _getRelayLifecycleGeneration();
1532
+ },
1533
+ set relayLifecycleGeneration(v) {
1534
+ _setRelayLifecycleGeneration(v);
1535
+ },
1536
+ get relayStartDeferred() {
1537
+ return _relayStartDeferred;
1538
+ },
1539
+ set relayStartDeferred(v) {
1540
+ _relayStartDeferred = v;
1541
+ },
1542
+ get rootLifecycleGeneration() {
1543
+ return _rootLifecycleGeneration;
1544
+ },
1545
+ set rootLifecycleGeneration(v) {
1546
+ _rootLifecycleGeneration = v;
1547
+ },
1548
+ get meshJoinGeneration() {
1549
+ return _meshJoinGeneration;
1550
+ },
1551
+ set meshJoinGeneration(v) {
1552
+ _meshJoinGeneration = v;
1553
+ },
1554
+ get selfRevoke() {
1555
+ return _selfRevoke;
1556
+ },
1557
+ set selfRevoke(v) {
1558
+ _selfRevoke = v;
1559
+ },
1560
+ get selfRevokeEpoch() {
1561
+ return _selfRevokeEpoch;
1562
+ },
1563
+ set selfRevokeEpoch(v) {
1564
+ _selfRevokeEpoch = v;
1565
+ },
1566
+ get selfRevokeTopologyReadyEpoch() {
1567
+ return _selfRevokeTopologyReadyEpoch;
1568
+ },
1569
+ set selfRevokeTopologyReadyEpoch(v) {
1570
+ _selfRevokeTopologyReadyEpoch = v;
1571
+ },
1572
+ get selfRevokeTopology() {
1573
+ return _selfRevokeTopology;
1574
+ },
1575
+ set selfRevokeTopology(v) {
1576
+ _selfRevokeTopology = v;
1577
+ },
1578
+ get stopAutoListener() {
1579
+ return _stopAutoListener;
1580
+ },
1581
+ set stopAutoListener(v) {
1582
+ _stopAutoListener = v;
1583
+ },
1584
+ get hasGlobalPairings() {
1585
+ return _hasGlobalPairings;
1586
+ },
1587
+ get disposed() {
1588
+ return _disposed;
1589
+ },
1590
+ get lastEventCtx() {
1591
+ return _lastEventCtx;
1592
+ },
1593
+ get pi() {
1594
+ return _pi;
1595
+ },
1596
+ get rootSessionId() {
1597
+ return _rootSessionId;
1598
+ },
1599
+ activePeers: _activePeers,
1600
+ isCurrentRootLifecycle: _isCurrentRootLifecycle,
1601
+ displayName: _displayName,
1602
+ deriveRoomId: _deriveRoomId,
1603
+ currentModelName: _currentModelName,
1604
+ rootState: _rootState,
1605
+ goIdle: () => _goIdle(relayDeps),
1606
+ onRelayClose: (closedRelay) => _onRelayClose(relayDeps, closedRelay),
1607
+ installAutoListener: (relay) => _installAutoListener(relayDeps, relay),
1608
+ refreshFooter: _refreshFooter,
1609
+ revokeActiveOwnerRuntime: _revokeActiveOwnerRuntime,
1610
+ attachBridgeIfReady: _attachBridgeIfReady,
1611
+ emitRelayState: (force) => _emitRelayState(relayDeps, force),
1612
+ refreshSessionPeerCount: _refreshSessionPeerCount,
1613
+ deliverMeshMessageToAgent: _deliverMeshMessageToAgent,
1614
+ refreshPairingsCache: _refreshPairingsCache,
1615
+ detachPeerChannel: (appPeerId) => _detachPeerChannel(relayDeps, appPeerId),
1616
+ handleControl: _handleControl,
1617
+ relayStatus: () => _relayStatus(relayDeps),
1618
+ getState: _getState,
1619
+ runTestScenario: _runTestScenario,
1620
+ safeNotify: _safeNotify,
1621
+ renameAgent: _renameAgent,
1622
+ };
1623
+ // ── Test-only hooks seam ──────────────────────────────────────────────────────
1624
+ //
1625
+ // The _xForTest surface lives in ./test_hooks.ts (a factory over this module's
1626
+ // state + helpers — that module never imports ../index.js). The one-line
1627
+ // re-exports below preserve the exact export names every existing test
1628
+ // (extension.test.ts + the colocated command tests) imports.
1629
+ const _testHooks = createTestHooks({
1630
+ commandDeps: deps,
1631
+ get disposed() {
1632
+ return _disposed;
1633
+ },
1634
+ set disposed(v) {
1635
+ _disposed = v;
1636
+ },
1637
+ set autoInited(v) {
1638
+ _autoInited = v;
1639
+ },
1640
+ get panelBridge() {
1641
+ return _panelBridge;
1642
+ },
1643
+ set panelBridge(v) {
1644
+ _panelBridge = v;
1645
+ },
1646
+ get rpcEnvelope() {
1647
+ return _rpcEnvelope;
1648
+ },
1649
+ set rpcEnvelope(v) {
1650
+ _rpcEnvelope = v;
1651
+ },
1652
+ get subagentRooms() {
1653
+ return _subagentRooms;
1654
+ },
1655
+ set subagentRooms(v) {
1656
+ _subagentRooms = v;
1657
+ },
1658
+ get extensionUiBridge() {
1659
+ return _extensionUiBridge;
1660
+ },
1661
+ set extensionUiBridge(v) {
1662
+ _extensionUiBridge = v;
1663
+ },
1664
+ set rootSessionId(v) {
1665
+ _rootSessionId = v;
1666
+ },
1667
+ get cwdLock() {
1668
+ return _cwdLock;
1669
+ },
1670
+ set cwdLock(v) {
1671
+ _cwdLock = v;
1672
+ },
1673
+ get lockedName() {
1674
+ return _lockedName;
1675
+ },
1676
+ set lockedName(v) {
1677
+ _lockedName = v;
1678
+ },
1679
+ set sessionStartedAt(v) {
1680
+ _sessionStartedAt = v;
1681
+ },
1682
+ set currentModel(v) {
1683
+ _currentModel = v;
1684
+ },
1685
+ set pi(v) {
1686
+ _pi = v;
1687
+ },
1688
+ set relayStartDeferred(v) {
1689
+ _relayStartDeferred = v;
1690
+ },
1691
+ rootSessionOwnerKey: _ROOT_SESSION_OWNER_KEY,
1692
+ sessions: _sessions,
1693
+ get meshNode() {
1694
+ return _meshNode;
1695
+ },
1696
+ get selfRevoke() {
1697
+ return _selfRevoke;
1698
+ },
1699
+ get cachedEd25519() {
1700
+ return _cachedEd25519;
1701
+ },
1702
+ get reconnectTimer() {
1703
+ return _getReconnectTimer();
1704
+ },
1705
+ activePeers: _activePeers,
1706
+ rootState: _rootState,
1707
+ seedRootSession(sid) {
1708
+ _rootSessionId = sid;
1709
+ _stateFor(sid).sessionManager = {
1710
+ getSessionId: () => sid,
1711
+ };
1712
+ },
1713
+ deliverMeshMessageToAgent: _deliverMeshMessageToAgent,
1714
+ });
1715
+ export const _connectForTest = _testHooks.connectForTest;
1716
+ export const _stopForTest = _testHooks.stopForTest;
1717
+ export const _getDisposedForTest = _testHooks.getDisposedForTest;
1718
+ export const _setDisposedForTest = _testHooks.setDisposedForTest;
1719
+ export const _resetAutoInitedForTest = _testHooks.resetAutoInitedForTest;
1720
+ export const _resetBridgeOwnersForTest = _testHooks.resetBridgeOwnersForTest;
1721
+ export const _resetSessionsForTest = _testHooks.resetSessionsForTest;
1722
+ export const _seedRootSessionForTest = _testHooks.seedRootSessionForTest;
1723
+ export const _setAutoInitedForTest = _testHooks.setAutoInitedForTest;
1724
+ export const _hasMeshNodeForTest = _testHooks.hasMeshNodeForTest;
1725
+ export const _checkSelfRevokeForTest = _testHooks.checkSelfRevokeForTest;
1726
+ export const _getLockedNameForTest = _testHooks.getLockedNameForTest;
1727
+ export const _resetCwdLockForTest = _testHooks.resetCwdLockForTest;
1728
+ export const _startRelayForTest = _testHooks.startRelayForTest;
1729
+ export const _getCachedPublicKeyForTest = _testHooks.getCachedPublicKeyForTest;
1730
+ export const _setSessionStartedAtForTest = _testHooks.setSessionStartedAtForTest;
1731
+ export const _setCurrentModelForTest = _testHooks.setCurrentModelForTest;
1732
+ export const _getCurrentTurnIdForTest = _testHooks.getCurrentTurnIdForTest;
1733
+ export const _setPiForTest = _testHooks.setPiForTest;
1734
+ export const _hasPendingReconnect = _testHooks.hasPendingReconnect;
1735
+ export const _getActivePeerCountForTest = _testHooks.getActivePeerCountForTest;
1736
+ export const _hasActivePeerForTest = _testHooks.hasActivePeerForTest;
1737
+ export const _deliverMeshMessageToAgentForTest = _testHooks.deliverMeshMessageToAgentForTest;
2344
1738
  const extension = (pi) => {
2345
1739
  const applied = _appliedRegistry();
2346
1740
  if (applied.has(pi))
@@ -2365,13 +1759,13 @@ const extension = (pi) => {
2365
1759
  if (_claimRootSession(pi)) {
2366
1760
  _pi = pi;
2367
1761
  _extensionUiBridge?.dispose();
2368
- _extensionUiBridge = createExtensionUiBridge(pi, _uiBroadcast);
1762
+ _extensionUiBridge = createExtensionUiBridge(pi, (msg) => _uiBroadcast(relayDeps, msg));
2369
1763
  _panelBridge?.dispose();
2370
- _panelBridge = createPanelBridge(pi, _panelBroadcast, {
1764
+ _panelBridge = createPanelBridge(pi, (msg) => _panelBroadcast(relayDeps, msg), {
2371
1765
  suppressAgents: subagentRoomsEnabled(),
2372
1766
  });
2373
1767
  _rpcEnvelope?.dispose();
2374
- _rpcEnvelope = createRpcEnvelope(pi, _broadcastEnvelope, {
1768
+ _rpcEnvelope = createRpcEnvelope(pi, (env) => _broadcastEnvelope(relayDeps, env), {
2375
1769
  enrichArgs: (tool, args) => {
2376
1770
  const e = _enrichToolArgs(tool, args, _resolveToolCwd());
2377
1771
  return Array.isArray(e.hunks) ? { hunks: e.hunks } : null;
@@ -2381,11 +1775,11 @@ const extension = (pi) => {
2381
1775
  _subagentRooms = initSubagentRooms(pi, {
2382
1776
  getParentRoomId: () => _myRoomId,
2383
1777
  getParentSessionId: () => _rootSessionId,
2384
- broadcastPanel: _panelBroadcast,
1778
+ broadcastPanel: (msg) => _panelBroadcast(relayDeps, msg),
2385
1779
  });
2386
1780
  }
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.
1781
+ // Plano 19: ensure the state root's `sessions/` + `skills/` dirs exist and
1782
+ // deploy the agent-network skill on first load. resources_discover lets Pi find it.
2389
1783
  try {
2390
1784
  ensureGlobalDirs();
2391
1785
  _deployAgentNetworkSkill();
@@ -2427,7 +1821,7 @@ const extension = (pi) => {
2427
1821
  void _handleControl(event.text.slice(CTRL_PREFIX.length).trim());
2428
1822
  return { action: "handled" };
2429
1823
  }
2430
- if (!_anyPeerActive())
1824
+ if (!_anyPeerActive(relayDeps))
2431
1825
  return;
2432
1826
  if (event.source === "extension")
2433
1827
  return;
@@ -2508,7 +1902,7 @@ const extension = (pi) => {
2508
1902
  // only clear the turn id used for queue/turn correlation.
2509
1903
  if (st.turnId)
2510
1904
  st.turnId = null;
2511
- _flushPendingReceivedImagePreviews();
1905
+ _flushPendingReceivedImagePreviews(imageDeps);
2512
1906
  settleRun();
2513
1907
  });
2514
1908
  // plan/34: the broker no longer gates delivery on busy state, so we no
@@ -2516,6 +1910,17 @@ const extension = (pi) => {
2516
1910
  // room_meta over the relay (plan/32) below — that's independent of the
2517
1911
  // broker and drives the app's working indicator.
2518
1912
  pi.on("turn_start", (_event, ctx) => {
1913
+ // One-shot TURN CTX TARGETED PROBE (see the session_start probe note):
1914
+ // per-property, proxy-safe. Turn ctxs fire while the agent RUNS — if
1915
+ // they carry the session-control powers, the always-warm capture
1916
+ // replaces the stash entirely.
1917
+ if (!_turnCtxProbed) {
1918
+ _turnCtxProbed = true;
1919
+ if (ctx)
1920
+ _probeCtx("turn ctx", ctx);
1921
+ else
1922
+ envLog("turn ctx targeted: ctx is undefined at turn_start");
1923
+ }
2519
1924
  const sid = _sidOf(ctx);
2520
1925
  const st = _stateFor(sid);
2521
1926
  // Each session records its OWN sessionManager (no cross-session clobber).
@@ -2594,6 +1999,61 @@ const extension = (pi) => {
2594
1999
  if (!_isNonRootSid(_sidOf(ctx)))
2595
2000
  _publishWorking(false);
2596
2001
  });
2002
+ // RENAME FORWARD (pre-release 2026-09-18): the session display name
2003
+ // changed — via /name in the TUI, RPC, or the app's own set_session_name
2004
+ // command (pi's native verb; the reply IS the standard rpc response). Two
2005
+ // effects: (1) forward to the app — over the relay-OPAQUE envelope plane (any
2006
+ // relay, but only reaches ATTACHED peers) AND, for relay >=0.6.0, a
2007
+ // room_meta_update the relay fans to ROOM SUBSCRIBERS (Home, pre-attach);
2008
+ // (2) refresh the local announce state so the NEXT hello re-announces the
2009
+ // fresh name — the relay's stored meta self-heals at the extension's next
2010
+ // connect.
2011
+ // Root sessions only (v1): a subagent child's rename gets its own forward
2012
+ // when child renames matter.
2013
+ pi.on("session_info_changed", (event, ctx) => {
2014
+ const name = event?.name;
2015
+ if (typeof name !== "string" || name.length === 0)
2016
+ return;
2017
+ if (_isNonRootSid(_sidOf(ctx)))
2018
+ return;
2019
+ _sessionName = name;
2020
+ if (_myRoomMeta)
2021
+ _myRoomMeta = { ..._myRoomMeta, name };
2022
+ _broadcastEnvelope(relayDeps, {
2023
+ evt: { channel: "session_info", data: { name } },
2024
+ });
2025
+ // ALSO fan via room_meta_update so the relay pushes the rename to ROOM
2026
+ // SUBSCRIBERS (Home apps subscribed but NOT attached) — the evt above only
2027
+ // reaches attached peers, so pre-attach the Home tile went stale until open
2028
+ // or reconnect. Relay >=0.6.0 merges + fans `name`; older relays drop the
2029
+ // unknown field (harmless — attached peers still get the evt). Design 01M1SPN7.
2030
+ if (_relay && _myRoomId) {
2031
+ _relay.sendControl({
2032
+ type: "room_meta_update",
2033
+ room_id: _myRoomId,
2034
+ meta: { name },
2035
+ });
2036
+ }
2037
+ });
2038
+ // LEAF BEACON on tree navigation (branch). pi fires session_tree AFTER a leaf
2039
+ // move for EVERY source — TUI /tree, edit-resubmit, and our own /unbien branch
2040
+ // (event.fromExtension) — carrying the new leaf directly. Push it so the app
2041
+ // re-derives its active path (evt session_info leafId → SessionState.derivePath
2042
+ // walks parentId from the new leaf over its already-cached tree; no prefetch).
2043
+ // THE single source for all leaf moves: without it a TUI /tree only reflected
2044
+ // on the next get_entries refetch (stale path until then). Root room only — a
2045
+ // subagent's tree move must not re-path the app's root session.
2046
+ pi.on("session_tree", (event, ctx) => {
2047
+ if (_isNonRootSid(_sidOf(ctx)))
2048
+ return;
2049
+ const leafId = event
2050
+ ?.newLeafId;
2051
+ if (typeof leafId === "string" && leafId.length > 0) {
2052
+ _broadcastEnvelope(relayDeps, {
2053
+ evt: { channel: "session_info", data: { leafId } },
2054
+ });
2055
+ }
2056
+ });
2597
2057
  // Re-capture the freshest base ctx on every session replacement so compact
2598
2058
  // never operates on a stale captured ctx — this is the fix for the
2599
2059
  // "stale after session replacement" crash when the app taps Compact after a
@@ -2610,6 +2070,13 @@ const extension = (pi) => {
2610
2070
  // subagent steals the base ctx and root-scoped notifies silently drop.
2611
2071
  if (!_isNonRootSid(sid))
2612
2072
  _lastEventCtx = ctx;
2073
+ // EVENT-CTX TARGETED PROBE (run 2026-09-18, round 3): the ctx objects
2074
+ // are guarded (throwing getters — proxies), so full reflection
2075
+ // under-reports or throws. Per-property access, each caught, is the
2076
+ // proxy-safe probe.
2077
+ if (!_isNonRootSid(sid) && ctx) {
2078
+ _probeCtx("event ctx", ctx);
2079
+ }
2613
2080
  if (ctx?.sessionManager)
2614
2081
  _stateFor(sid).sessionManager = ctx.sessionManager;
2615
2082
  // session_shutdown disposes per-session pi-ask subscriptions. A host that
@@ -2624,13 +2091,13 @@ const extension = (pi) => {
2624
2091
  if (ctx?.sessionManager)
2625
2092
  _rootSessionId = sid;
2626
2093
  if (!_extensionUiBridge)
2627
- _extensionUiBridge = createExtensionUiBridge(pi, _uiBroadcast);
2094
+ _extensionUiBridge = createExtensionUiBridge(pi, (msg) => _uiBroadcast(relayDeps, msg));
2628
2095
  if (!_panelBridge)
2629
- _panelBridge = createPanelBridge(pi, _panelBroadcast, {
2096
+ _panelBridge = createPanelBridge(pi, (msg) => _panelBroadcast(relayDeps, msg), {
2630
2097
  suppressAgents: subagentRoomsEnabled(),
2631
2098
  });
2632
2099
  if (!_rpcEnvelope)
2633
- _rpcEnvelope = createRpcEnvelope(pi, _broadcastEnvelope, {
2100
+ _rpcEnvelope = createRpcEnvelope(pi, (env) => _broadcastEnvelope(relayDeps, env), {
2634
2101
  enrichArgs: (tool, args) => {
2635
2102
  const e = _enrichToolArgs(tool, args, _resolveToolCwd());
2636
2103
  return Array.isArray(e.hunks) ? { hunks: e.hunks } : null;
@@ -2640,7 +2107,7 @@ const extension = (pi) => {
2640
2107
  _subagentRooms = initSubagentRooms(pi, {
2641
2108
  getParentRoomId: () => _myRoomId,
2642
2109
  getParentSessionId: () => _rootSessionId,
2643
- broadcastPanel: _panelBroadcast,
2110
+ broadcastPanel: (msg) => _panelBroadcast(relayDeps, msg),
2644
2111
  });
2645
2112
  }
2646
2113
  else if (_isNonRootSid(sid)) {
@@ -2648,6 +2115,17 @@ const extension = (pi) => {
2648
2115
  // `pi` here is the CHILD's ExtensionAPI (per-activation).
2649
2116
  _subagentRooms?.onChildSession(pi, ctx);
2650
2117
  }
2118
+ // design 01M1CAW0 (announce waits for the session id): a relay start that
2119
+ // was deferred because no session id existed re-runs NOW — the root
2120
+ // session's sessionManager was captured above, so _deriveRoomId has an id
2121
+ // to announce. Root session_starts only (a child never owned the relay);
2122
+ // the flag stays armed when this session_start still carries no id.
2123
+ if (deps.relayStartDeferred && !_isNonRootSid(sid)) {
2124
+ if (_rootState().sessionManager?.getSessionId()) {
2125
+ deps.relayStartDeferred = false;
2126
+ void _cmdStart(deps, ctx);
2127
+ }
2128
+ }
2651
2129
  // Rearm a reused-but-disposed instance. The session_shutdown teardown (below)
2652
2130
  // sets _disposed=true assuming the host re-evaluates THIS module fresh for the
2653
2131
  // replacement session, yielding a new instance with _disposed=false. Some hosts
@@ -2661,7 +2139,7 @@ const extension = (pi) => {
2661
2139
  const restartAuthority = {
2662
2140
  rootLifecycleGeneration: _rootLifecycleGeneration,
2663
2141
  };
2664
- void _cmdRoot(ctx, restartAuthority);
2142
+ void _cmdRoot(deps, ctx, restartAuthority);
2665
2143
  }
2666
2144
  // Auto-start un-bien on a fresh boot when the cwd's local config has
2667
2145
  // auto_start_relay enabled (default true). Covers BOTH interactive
@@ -2702,7 +2180,7 @@ const extension = (pi) => {
2702
2180
  const initCtx = isDaemon
2703
2181
  ? { ui: _headlessUi(), cwd: process.cwd() }
2704
2182
  : ctx;
2705
- void _cmdRoot(initCtx);
2183
+ void _cmdRoot(deps, initCtx);
2706
2184
  }
2707
2185
  }
2708
2186
  });
@@ -2750,7 +2228,7 @@ const extension = (pi) => {
2750
2228
  // clears `_disposed` before its promises settle.
2751
2229
  _disposed = true;
2752
2230
  _rootLifecycleGeneration += 1;
2753
- _relayLifecycleGeneration += 1;
2231
+ _setRelayLifecycleGeneration(_getRelayLifecycleGeneration() + 1);
2754
2232
  _meshJoinGeneration += 1;
2755
2233
  // The bridge owns live pi.events subscriptions + flow TTLs. Dispose before
2756
2234
  // the outgoing session is replaced so stale listeners cannot leak or
@@ -2765,8 +2243,18 @@ const extension = (pi) => {
2765
2243
  // app can mark the session ended. Emit EXPLICITLY here, BEFORE the
2766
2244
  // producer dispose below: `_rpcEnvelope` gates on its `disposed` flag,
2767
2245
  // so once disposed it can no longer build/broadcast this frame.
2768
- if (_anyPeerActive()) {
2769
- _broadcastEnvelope({ rpc: { type: "session_shutdown" } });
2246
+ if (_anyPeerActive(relayDeps)) {
2247
+ // FLUSH-BEFORE-TEARDOWN: await the frame's ws send-completion
2248
+ // (bounded — a wedged socket can't hang the shutdown) BEFORE _goIdle
2249
+ // closes the relay below. Fire-and-forget only beat close() by
2250
+ // incidental FIFO luck; the switch flow (/resume: old instance
2251
+ // broadcasts shutdown, fresh instance takes the new room) NEEDS the
2252
+ // app to reliably mark the old session ended, or it lingers live
2253
+ // forever (the app has no disconnect-implies-ended backstop, by
2254
+ // design).
2255
+ await _broadcastEnvelopeFlushed(relayDeps, {
2256
+ rpc: { type: "session_shutdown" },
2257
+ });
2770
2258
  }
2771
2259
  _extensionUiBridge?.dispose();
2772
2260
  _extensionUiBridge = null;
@@ -2778,11 +2266,9 @@ const extension = (pi) => {
2778
2266
  _subagentRooms = null;
2779
2267
  _releaseRootSession(pi);
2780
2268
  }
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;
2269
+ // Drop the captured event ctx immediately. On module-reuse hosts the same
2270
+ // instance survives session replacement; the next session_start re-binds
2271
+ // `_lastEventCtx` for the new session.
2786
2272
  _lastEventCtx = null;
2787
2273
  // No bye reason: the process keeps running and the fresh instance re-joins
2788
2274
  // the SAME relay room, so an explicit offline→online flap would be wrong.
@@ -2792,7 +2278,7 @@ const extension = (pi) => {
2792
2278
  _meshNode?.detachBridge();
2793
2279
  }
2794
2280
  else {
2795
- _goIdle();
2281
+ _goIdle(relayDeps);
2796
2282
  }
2797
2283
  const meshNode = _meshNode;
2798
2284
  _meshNode = null;
@@ -2822,232 +2308,7 @@ const extension = (pi) => {
2822
2308
  /* best-effort */
2823
2309
  }
2824
2310
  });
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
- });
2311
+ registerUnbienCommands(pi, deps);
3051
2312
  // Auto-init now runs from the session_start handler (above), AFTER the
3052
2313
  // SDK calls bindCore(). The original setTimeout(0) here fired before bindCore
3053
2314
  // replaced the throwing action-method stubs, so the first pi.sendMessage in
@@ -3057,960 +2318,6 @@ const extension = (pi) => {
3057
2318
  // interactive pi sessions are on remote automatically — no /unbien needed.
3058
2319
  };
3059
2320
  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
2321
  function _wakeAgent(content, label, steeringBehavior) {
4015
2322
  if (!_pi) {
4016
2323
  const detail = "agent session not bound yet";
@@ -4018,9 +2325,17 @@ function _wakeAgent(content, label, steeringBehavior) {
4018
2325
  return { ok: false, detail };
4019
2326
  }
4020
2327
  try {
4021
- const options = steeringBehavior
4022
- ? { deliverAs: steeringBehavior }
4023
- : undefined;
2328
+ // expandPromptTemplates: true — TUI PARITY for app-sent text. pi's
2329
+ // sendUserMessage defaults it to FALSE, so a "/"-prefixed message typed
2330
+ // in the PHONE composer reached the MODEL as literal text (observed:
2331
+ // "ignoring the two /unbien pair statements prior in the chat") instead
2332
+ // of dispatching as a command like the same text typed in the TUI. pi's
2333
+ // prompt() dispatches extension commands FIRST — even mid-stream — so
2334
+ // steering a running turn with a command now behaves identically too.
2335
+ const options = {
2336
+ ...(steeringBehavior ? { deliverAs: steeringBehavior } : {}),
2337
+ expandPromptTemplates: true,
2338
+ };
4024
2339
  _pi.sendUserMessage(content, options);
4025
2340
  return { ok: true };
4026
2341
  }
@@ -4108,181 +2423,6 @@ function _deliverMeshMessageToAgent(env) {
4108
2423
  _pendingMeshMessages.push(env);
4109
2424
  _scheduleMeshMessageDrain();
4110
2425
  }
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
2426
  // ── routeClientMessage ────────────────────────────────────────────────────────
4287
2427
  /**
4288
2428
  * Per-channel router. Replaces the W2D-pre `routeClientMessage` which
@@ -4296,7 +2436,7 @@ async function _cmdJoin(ctx) {
4296
2436
  * handles incoming app→pi requests.
4297
2437
  */
4298
2438
  function _abortCurrentTurn(fallbackCtx) {
4299
- const candidates = [_lastEventCtx, _lastCtx, fallbackCtx];
2439
+ const candidates = [_lastEventCtx, fallbackCtx];
4300
2440
  for (const candidate of candidates) {
4301
2441
  if (!candidate || candidate === _noopCtx)
4302
2442
  continue;
@@ -4380,24 +2520,10 @@ export function routeClientMessage(msg, ctx) {
4380
2520
  * also dump history to owner B's wire — duplicate traffic + the wrong
4381
2521
  * `in_reply_to`.
4382
2522
  */
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. */
2523
+ /** Resolve the base dir for tool-arg file lookups from the current session. */
4399
2524
  function _resolveToolCwd() {
4400
- return _lastCtx && "cwd" in _lastCtx ? _lastCtx.cwd : process.cwd();
2525
+ const sm = _rootState().sessionManager;
2526
+ return sm && typeof sm.getCwd === "function" ? sm.getCwd() : process.cwd();
4401
2527
  }
4402
2528
  // ── Standalone CLI ────────────────────────────────────────────────────────────
4403
2529
  function _isDirectRun() {
@@ -4599,150 +2725,4 @@ if (_isDirectRun()) {
4599
2725
  ].join("\n"));
4600
2726
  }
4601
2727
  }
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
2728
  //# sourceMappingURL=index.js.map