@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
@@ -0,0 +1,802 @@
1
+ /**
2
+ * Relay lifecycle + owner management.
3
+ *
4
+ * Everything that owns the relay connection's lifetime and the attached-owner
5
+ * set: full/partial teardown (`_goIdle` / `_onRelayClose`), the reconnect
6
+ * backoff machine, the relay-state event + transparent control channel
7
+ * (Cockpit), per-owner channel attach/detach + broadcast fanout, the
8
+ * pair_request auto-listener, and the pair handshake payload
9
+ * (capabilities / harness identity).
10
+ *
11
+ * Seam: index.ts (composition root) owns every piece of shared mutable module
12
+ * state (`_state`, `_relay`, `_activePeers`, …) and the helpers that stayed
13
+ * there (`_refreshFooter`, `_routeClientMessageFrom`, …); they are threaded
14
+ * through `RelayLifecycleDeps`. This module MUST NOT import `../index.js`
15
+ * (circular import). The reconnect timers + attempt counter, the relay
16
+ * lifecycle generation, and the last-emitted relay status are RELAY-OWNED and
17
+ * live here in module scope; index reaches the generation + timer only through
18
+ * the exported accessors (session_shutdown bump, CommandDeps get/set, the
19
+ * test-hooks reconnect probe).
20
+ *
21
+ * NOT here: `_renameAgent` (rename touches mesh + config + relay; stays in
22
+ * index and is passed in as a dep), `_revokeActiveOwnerRuntime` /
23
+ * `_reportRevocationByFingerprint` (revoke reporting; index), `_getState`
24
+ * (public state snapshot; index).
25
+ */
26
+ import { join } from "node:path";
27
+ import { readFileSync } from "node:fs";
28
+ import { hostname } from "node:os";
29
+ import { fileURLToPath } from "node:url";
30
+ import { qrSession } from "../pairing/qr.js";
31
+ import { addPeer, listPeers, pairingAllowList } from "../pairing/storage.js";
32
+ import { _findKnownPeer } from "../pairing/peer_trust.js";
33
+ import { RelayClient } from "../transport/relay_client.js";
34
+ import { PlainPeerChannel } from "../transport/peer_channel.js";
35
+ import { toWebSocketUrl } from "../config.js";
36
+ import { helloEnvelope, isEnvelopeFrame, } from "./rpc_envelope.js";
37
+ import { envLog } from "./debug_log.js";
38
+ import { sessionCapabilities } from "./capabilities.js";
39
+ import { clearPendingReceivedImagePreviews } from "./received_images.js";
40
+ import { _cmdStart } from "../commands/lifecycle.js";
41
+ /** Sentinel prefix for a transparent control message an RPC client sends on the
42
+ * `prompt` channel (stdin). The `input` hook intercepts it, runs the action,
43
+ * and swallows it (`action:"handled"`) so it never becomes an LLM turn or a
44
+ * transcript entry. Starts with NUL so it can't collide with real user input
45
+ * and doesn't begin with "/" (which would route to the command parser). */
46
+ export const CTRL_PREFIX = "\x00un-bien-ctrl:";
47
+ /** Last `RelayConnectivity` emitted, for change-dedup. Starts "disconnected"
48
+ * (the process boots with the relay down). */
49
+ let _lastRelayStatus = null;
50
+ // ── Relay reconnect state ─────────────────────────────────────────────────────
51
+ // Backoffs in ms: 1s, 2s, 5s, 10s, 30s, then stays at 30s.
52
+ const RECONNECT_BACKOFFS_MS = [1_000, 2_000, 5_000, 10_000, 30_000];
53
+ let _reconnectTimer = null;
54
+ let _reconnectAttempt = 0;
55
+ // Every initial connect/reconnect candidate captures this generation. Stop,
56
+ // relay-off, and an unexpected close invalidate older async continuations.
57
+ let _relayLifecycleGeneration = 0;
58
+ /** Relay-lifecycle generation (module-local). index.ts bumps it on
59
+ * session_shutdown and fronts it through the CommandDeps get/set pair. */
60
+ export function _getRelayLifecycleGeneration() {
61
+ return _relayLifecycleGeneration;
62
+ }
63
+ /** @see _getRelayLifecycleGeneration */
64
+ export function _setRelayLifecycleGeneration(value) {
65
+ _relayLifecycleGeneration = value;
66
+ }
67
+ /** Pending reconnect timer (module-local) — the test-hooks reconnect probe. */
68
+ export function _getReconnectTimer() {
69
+ return _reconnectTimer;
70
+ }
71
+ // ── Multi-channel helpers ─────────────────────────────────────────────────────
72
+ /** Returns true when at least one owner is attached. Derived `paired` UX. */
73
+ export function _anyPeerActive(deps) {
74
+ return deps.activePeers.size > 0;
75
+ }
76
+ /** Broadcast for the extension_ui bridge. The bridge only ever emits
77
+ * `extension_ui_request`, sent ENVELOPE-ONLY as a `{rpc}` frame (the wire
78
+ * shape mirrors the SDK rpc contract 1:1). No stock fallback. */
79
+ export function _uiBroadcast(deps, msg) {
80
+ if (msg.type === "extension_ui_request")
81
+ _broadcastEnvelope(deps, { rpc: msg });
82
+ }
83
+ /** Broadcast for the panel bridge. The bridge only ever emits `panel_update`,
84
+ * forwarded ENVELOPE-ONLY as `{evt:{channel:"panel", data}}` (the {evt} plane);
85
+ * the app folds it into its panel store. No stock fallback. */
86
+ export function _panelBroadcast(deps, msg) {
87
+ if (msg.type === "panel_update")
88
+ _broadcastEnvelope(deps, { evt: { channel: "panel", data: msg } });
89
+ }
90
+ /** Fan an rpc-envelope frame out to every attached peer (base64 ct via each
91
+ * channel) — the single owner-fanout path for `{ rpc | evt }` messages. */
92
+ export function _broadcastEnvelope(deps, env) {
93
+ {
94
+ // Observability only (not a route gate): watch the {rpc|evt} wire during
95
+ // e2e bring-up. Frame type only — payloads can be large / carry images.
96
+ const kind = env.rpc
97
+ ? `rpc:${env.rpc.type ?? "?"}`
98
+ : `evt:${env.evt?.channel ?? "?"}`;
99
+ envLog(`envelope -> ${deps.activePeers.size} peer(s): ${kind}`);
100
+ }
101
+ for (const ch of deps.activePeers.values()) {
102
+ try {
103
+ ch.sendEnvelope(env);
104
+ }
105
+ catch {
106
+ /* best-effort per channel */
107
+ }
108
+ }
109
+ }
110
+ /**
111
+ * Teardown-path variant of `_broadcastEnvelope`: awaits each channel's ws
112
+ * send-completion (frame handed to the socket) — bounded by `timeoutMs` so a
113
+ * wedged socket can never hang a shutdown. The `session_shutdown` broadcast
114
+ * MUST use this: its handler tears the relay down immediately after, and a
115
+ * fire-and-forget send only beats `close()` by incidental FIFO luck.
116
+ */
117
+ export async function _broadcastEnvelopeFlushed(deps, env, timeoutMs = 1_000) {
118
+ {
119
+ const kind = env.rpc
120
+ ? `rpc:${env.rpc.type ?? "?"}`
121
+ : `evt:${env.evt?.channel ?? "?"}`;
122
+ envLog(`envelope -> ${deps.activePeers.size} peer(s), flushed: ${kind}`);
123
+ }
124
+ let timer;
125
+ try {
126
+ await Promise.race([
127
+ Promise.all([...deps.activePeers.values()].map((ch) => ch.sendEnvelopeFlushed(env))),
128
+ new Promise((resolve) => {
129
+ timer = setTimeout(resolve, timeoutMs);
130
+ }),
131
+ ]);
132
+ }
133
+ finally {
134
+ if (timer)
135
+ clearTimeout(timer);
136
+ }
137
+ }
138
+ /**
139
+ * Adds an owner's channel to `_activePeers`. Also updates the UX hint
140
+ * `_peerShort` (last-attached shortid) so the footer + status can pick
141
+ * a representative device when only one is connected.
142
+ */
143
+ function _attachPeerChannel(deps, appPeerId, channel) {
144
+ deps.activePeers.set(appPeerId, channel);
145
+ deps.peerShort = appPeerId.slice(0, 8);
146
+ }
147
+ /** Detaches a single owner's channel + removes it from the map. Used by
148
+ * `_onPeerDisconnect`, `_cmdRevoke`, and the SelfRevoke callback. */
149
+ export function _detachPeerChannel(deps, appPeerId) {
150
+ const ch = deps.activePeers.get(appPeerId);
151
+ if (!ch)
152
+ return;
153
+ try {
154
+ ch.detach();
155
+ }
156
+ catch {
157
+ /* best-effort */
158
+ }
159
+ deps.activePeers.delete(appPeerId);
160
+ if (deps.peerShort === appPeerId.slice(0, 8)) {
161
+ // Pick a different remaining peer for the UX hint, or clear when none.
162
+ const next = deps.activePeers.keys().next().value;
163
+ deps.peerShort = next ? next.slice(0, 8) : "";
164
+ }
165
+ }
166
+ // ── Transition helpers ────────────────────────────────────────────────────────
167
+ /**
168
+ * Full teardown: stop listener, detach channel, close relay → idle.
169
+ */
170
+ export function _goIdle(deps) {
171
+ deps.rootLifecycleGeneration += 1;
172
+ _relayLifecycleGeneration += 1;
173
+ // Cancel any pending reconnect attempt. Critical: /unbien stop must
174
+ // win the race against a scheduled reconnect.
175
+ if (_reconnectTimer !== null) {
176
+ clearTimeout(_reconnectTimer);
177
+ _reconnectTimer = null;
178
+ }
179
+ _reconnectAttempt = 0;
180
+ deps.stopAutoListener?.();
181
+ deps.stopAutoListener = null;
182
+ // Tear down every per-owner channel and clear the map.
183
+ for (const ch of deps.activePeers.values()) {
184
+ try {
185
+ ch.detach();
186
+ }
187
+ catch {
188
+ /* best-effort */
189
+ }
190
+ }
191
+ deps.activePeers.clear();
192
+ deps.peerShort = "";
193
+ deps.rootState().turnId = null;
194
+ clearPendingReceivedImagePreviews();
195
+ // Invalidate async producers and bridge ownership before closing the host
196
+ // Relay. A synchronous/delayed close callback must observe stale identity.
197
+ const producer = deps.selfRevoke;
198
+ deps.selfRevoke = null;
199
+ deps.selfRevokeEpoch += 1;
200
+ deps.selfRevokeTopologyReadyEpoch = -1;
201
+ deps.selfRevokeTopology = null;
202
+ producer?.stop();
203
+ deps.meshNode?.detachBridge();
204
+ const relay = deps.relay;
205
+ deps.relay = null;
206
+ deps.relayUrl = null;
207
+ relay?.close();
208
+ // Preserve _sessionStartedAt + _messageBuffer across stop/start cycles.
209
+ // The Pi agent session outlives the relay connection — `message_end` keeps
210
+ // firing for terminal turns even while idle, and the buffer must survive
211
+ // so those turns appear in the next session_sync. Only a Pi process
212
+ // restart resets these (init-time values).
213
+ deps.state = "idle";
214
+ deps.refreshFooter();
215
+ _emitRelayState(deps); // → disconnected
216
+ }
217
+ /**
218
+ * Called when the relay WS closes unexpectedly (network drop, relay restart,
219
+ * etc.). Does a **partial** teardown — keeps `_sessionStartedAt`, `_messageBuffer`,
220
+ * `_relayUrl`, `_cachedEd25519`, `_peerShort` so the session can resume on
221
+ * reconnect — and schedules an `_attemptReconnect`.
222
+ *
223
+ * Peer (app) reconnect after a successful relay reconnect is handled by the
224
+ * existing auto-listener via `peers.json` lookup, so we don't need to track
225
+ * the prior peer here; we just go back to `started` and wait.
226
+ */
227
+ export function _onRelayClose(deps, closedRelay) {
228
+ if (deps.relay !== closedRelay)
229
+ return; // delayed close from a replaced Relay
230
+ if (deps.state === "idle")
231
+ return; // already torn down (e.g. /unbien stop)
232
+ _relayLifecycleGeneration += 1;
233
+ deps.stopAutoListener?.();
234
+ deps.stopAutoListener = null;
235
+ // Detach every per-owner channel — relay is gone, none can route. The
236
+ // auto-listener re-attaches owners after `_attemptReconnect` succeeds
237
+ // (via the same known-peer + pair_request paths used on first connect).
238
+ for (const ch of deps.activePeers.values()) {
239
+ try {
240
+ ch.detach();
241
+ }
242
+ catch {
243
+ /* best-effort */
244
+ }
245
+ }
246
+ deps.activePeers.clear();
247
+ deps.peerShort = "";
248
+ deps.rootState().turnId = null;
249
+ deps.relay = null; // _relayUrl preserved for retry
250
+ // Cross-PC routing relies on _relay; bring it down. Will be re-instated
251
+ // by _attemptReconnect on success.
252
+ deps.meshNode?.detachBridge();
253
+ deps.state = "started";
254
+ deps.refreshFooter();
255
+ _emitRelayState(deps); // → reconnecting
256
+ const reconnectUrl = deps.relayUrl;
257
+ if (reconnectUrl) {
258
+ _scheduleReconnect(deps, _relayLifecycleGeneration, reconnectUrl);
259
+ }
260
+ }
261
+ function _isCurrentReconnect(deps, lifecycleGeneration, url) {
262
+ return (lifecycleGeneration === _relayLifecycleGeneration &&
263
+ deps.state === "started" &&
264
+ deps.relay === null &&
265
+ deps.relayUrl === url);
266
+ }
267
+ function _scheduleReconnect(deps, lifecycleGeneration, url) {
268
+ if (_reconnectTimer !== null)
269
+ return; // already scheduled
270
+ if (!deps.cachedEd25519)
271
+ return; // can't reconnect without the cached identity
272
+ if (!_isCurrentReconnect(deps, lifecycleGeneration, url))
273
+ return;
274
+ const idx = Math.min(_reconnectAttempt, RECONNECT_BACKOFFS_MS.length - 1);
275
+ const delay = RECONNECT_BACKOFFS_MS[idx];
276
+ _reconnectAttempt += 1;
277
+ // The timer belongs to the lifecycle that scheduled it. Re-check that exact
278
+ // generation + URL before constructing a candidate so a dequeued old timer
279
+ // cannot act on a newer stop/start lifecycle.
280
+ _reconnectTimer = setTimeout(() => {
281
+ _reconnectTimer = null;
282
+ if (!_isCurrentReconnect(deps, lifecycleGeneration, url))
283
+ return;
284
+ void _attemptReconnect(deps, lifecycleGeneration, url);
285
+ }, delay);
286
+ }
287
+ async function _attemptReconnect(deps, lifecycleGeneration, url) {
288
+ if (!deps.cachedEd25519)
289
+ return;
290
+ if (!_isCurrentReconnect(deps, lifecycleGeneration, url))
291
+ return;
292
+ const edKp = deps.cachedEd25519;
293
+ // _relayUrl is stored in canonical http(s):// form — convert at the
294
+ // WS boundary, same as _cmdStart.
295
+ const relay = new RelayClient(toWebSocketUrl(url), edKp);
296
+ try {
297
+ // Replay the same room identity from _cmdStart. Without this the relay
298
+ // would log this WS as a default-room peer and the app would see a
299
+ // phantom "legacy session" appear (regression of plano 17 + 18).
300
+ await relay.connect({
301
+ ...(deps.myRoomId ? { roomId: deps.myRoomId } : {}),
302
+ ...(deps.myRoomMeta ? { roomMeta: deps.myRoomMeta } : {}),
303
+ });
304
+ }
305
+ catch {
306
+ // A reconnect candidate stays local until publication; every rejected
307
+ // candidate is deterministically closed before stale-return or retry.
308
+ try {
309
+ relay.close();
310
+ }
311
+ catch {
312
+ /* best-effort rejected candidate cleanup */
313
+ }
314
+ if (!_isCurrentReconnect(deps, lifecycleGeneration, url))
315
+ return;
316
+ _scheduleReconnect(deps, lifecycleGeneration, url);
317
+ return;
318
+ }
319
+ if (!_isCurrentReconnect(deps, lifecycleGeneration, url)) {
320
+ try {
321
+ relay.close();
322
+ }
323
+ catch {
324
+ /* best-effort stale candidate cleanup */
325
+ }
326
+ return;
327
+ }
328
+ deps.relay = relay;
329
+ _reconnectAttempt = 0;
330
+ // Push our ROOMS-gate allow-list on every (re)connect so the relay rebuilds
331
+ // its soft-state pairing db (design 01M1ZE43). Best-effort; the relay fails
332
+ // open until a push lands.
333
+ _pushPairingAllowList(relay);
334
+ relay.on("close", () => _onRelayClose(deps, relay));
335
+ deps.stopAutoListener = _installAutoListener(deps, relay);
336
+ // Plan/25 Wave B/C: relay is back; bring cross-PC routing back online.
337
+ deps.attachBridgeIfReady();
338
+ // _state stays "started"; peer reconnect (if previously paired) flows
339
+ // through _installAutoListener → _findKnownPeer → _promoteToPaired
340
+ // automatically when the app sends any inner.
341
+ _emitRelayState(deps);
342
+ }
343
+ // ── Relay state event + transparent control channel (Cockpit toggle) ─────────
344
+ /** Push this machine's ROOMS-gate allow-list (paired Owner epks permitted to
345
+ * list our rooms) to the relay. Best-effort — a miss just delays the gate
346
+ * until the next push (the relay fails open meanwhile). Design 01M1ZE43. */
347
+ export function _pushPairingAllowList(relay) {
348
+ void listPeers()
349
+ .then((peers) => relay.sendControl({
350
+ type: "pairing_set",
351
+ owners: pairingAllowList(peers),
352
+ }))
353
+ .catch(() => {
354
+ /* best-effort */
355
+ });
356
+ }
357
+ /** Current relay connectivity, derived from `_state` + `_relay`. */
358
+ export function _relayStatus(deps) {
359
+ if (deps.getState() === "idle")
360
+ return "disconnected";
361
+ return deps.relay ? "connected" : "reconnecting";
362
+ }
363
+ /**
364
+ * Emit the `un-bien:relay-state` custom message so an RPC client (Cockpit)
365
+ * can render a relay on/off indicator. Pure data (`display:false`) — never
366
+ * shown in the transcript. De-duped on the connectivity value; pass
367
+ * `force=true` to answer an explicit `relay:status` query regardless.
368
+ */
369
+ export function _emitRelayState(deps, force = false) {
370
+ const status = _relayStatus(deps);
371
+ if (!force && status === _lastRelayStatus)
372
+ return;
373
+ _lastRelayStatus = status;
374
+ // This can run inside a WebSocket 'close' callback (via _onRelayClose). After a
375
+ // session replacement (newSession/fork/switchSession/reload) the module-level
376
+ // `_pi` is stale, and `assertActive` throws synchronously inside `sendMessage`.
377
+ // An uncaught throw from a WS event callback becomes a process-level
378
+ // uncaughtException and exits pi. Swallow it here: the next relay-state
379
+ // change re-emits, so connectivity is eventually consistent. See issue #55.
380
+ try {
381
+ deps.pi?.sendMessage({
382
+ customType: "un-bien:relay-state",
383
+ content: `Relay ${status}`,
384
+ details: {
385
+ status,
386
+ connected: status === "connected",
387
+ ...(deps.relayUrl ? { relayUrl: deps.relayUrl } : {}),
388
+ ...(deps.myRoomId ? { room: deps.myRoomId } : {}),
389
+ },
390
+ display: false,
391
+ });
392
+ }
393
+ catch {
394
+ // _pi stale (session replaced) or extension runtime not yet bound.
395
+ }
396
+ }
397
+ /** Minimal ctx for relay start/stop driven by a control message (no command
398
+ * ctx is available in the `input` hook). cwd matches the daemon's launch dir,
399
+ * so the derived relay room is identical to the one `_cmdStart` first used. */
400
+ export function _controlCtx() {
401
+ // SAFETY: _headlessUi() implements every ui method the relay start/stop path
402
+ // actually calls; the notify-forwarding shim is structurally narrower than the
403
+ // full ExtensionContext["ui"] but complete for this headless control path.
404
+ return {
405
+ ui: _headlessUi(),
406
+ cwd: process.cwd(),
407
+ };
408
+ }
409
+ /**
410
+ * `ui.notify` for headless contexts (daemon auto-init + control channel). There
411
+ * is no TUI, and the RPC client (Cockpit) already gets everything it needs via
412
+ * structured events (`un-bien:relay-state`, `un-bien:name-assigned`,
413
+ * room_meta) — so routine INFO chatter would just pollute the client's captured
414
+ * stderr. We drop info and forward only warnings/errors (kept for the
415
+ * supervisor's journal / genuine failures). The interactive Pi keeps its normal
416
+ * footer/notify path — this only affects headless ctxs.
417
+ */
418
+ export function _headlessUi() {
419
+ return {
420
+ notify: (msg, type) => {
421
+ if (type === "warning" || type === "error")
422
+ process.stderr.write(`${msg}\n`);
423
+ },
424
+ };
425
+ }
426
+ /**
427
+ * Handle a transparent control command from an RPC client (Cockpit), received
428
+ * as a `CTRL_PREFIX`-tagged input the `input` hook swallowed. Toggles the relay
429
+ * WITHOUT leaving the local mesh (relay-only: `_cmdStart` up / `_goIdle` down),
430
+ * then emits the fresh state. `relay:status` just re-emits (no change) so the
431
+ * client can sync its button after (re)attaching to the RPC stream.
432
+ */
433
+ export async function _handleControl(deps, cmd) {
434
+ // `rename:<new-name>` carries an argument, so it's matched before the
435
+ // fixed-verb switch. Renames the agent live (broker re-register + relay room
436
+ // swap) WITHOUT restarting the process or losing the SDK session.
437
+ if (cmd.startsWith("rename:")) {
438
+ await deps.renameAgent(cmd.slice("rename:".length).trim());
439
+ return;
440
+ }
441
+ switch (cmd) {
442
+ case "relay:on":
443
+ if (deps.getState() === "idle")
444
+ await _cmdStart(deps.commandDeps, _controlCtx());
445
+ _emitRelayState(deps, true);
446
+ return;
447
+ case "relay:off":
448
+ if (deps.getState() === "idle") {
449
+ deps.rootLifecycleGeneration += 1;
450
+ _relayLifecycleGeneration += 1;
451
+ }
452
+ else
453
+ _goIdle(deps);
454
+ _emitRelayState(deps, true);
455
+ return;
456
+ case "relay:toggle":
457
+ if (deps.getState() === "idle")
458
+ await _cmdStart(deps.commandDeps, _controlCtx());
459
+ else
460
+ _goIdle(deps);
461
+ _emitRelayState(deps, true);
462
+ return;
463
+ case "relay:status":
464
+ _emitRelayState(deps, true);
465
+ return;
466
+ default:
467
+ // Unknown control verb — ignore (forward-compat: a newer client may send
468
+ // verbs an older extension doesn't know).
469
+ return;
470
+ }
471
+ }
472
+ /**
473
+ * Per-owner disconnect callback. Fires when one specific owner's channel
474
+ * detaches (e.g. relay told us the peer is gone). Other owners' channels
475
+ * keep running — relay stays "started".
476
+ *
477
+ * Exported so tests can trigger the disconnect path for a specific peer.
478
+ *
479
+ * Backward-compat: a no-arg call (legacy tests / pre-W2D callers) falls
480
+ * back to detaching the most recently attached peer, mirroring the old
481
+ * singleton semantics.
482
+ */
483
+ export function _onPeerDisconnect(deps, appPeerId) {
484
+ if (deps.state === "idle")
485
+ return;
486
+ const target = appPeerId ?? [...deps.activePeers.keys()].pop();
487
+ if (!target)
488
+ return;
489
+ if (!deps.activePeers.has(target))
490
+ return;
491
+ _detachPeerChannel(deps, target);
492
+ if (_anyPeerActive(deps)) {
493
+ // Other owners still attached — keep _rootState().turnId so they continue
494
+ // seeing the in-flight agent stream.
495
+ deps.refreshFooter();
496
+ return;
497
+ }
498
+ // No owner left. Conservatively clear the turn so the next pair_request
499
+ // starts cleanly.
500
+ deps.rootState().turnId = null;
501
+ deps.refreshFooter();
502
+ deps.safeNotify("[un-bien] All app peers disconnected, listening for reconnect", "info");
503
+ // Auto-listener stays up — same listener catches the reconnect on any peer.
504
+ }
505
+ /**
506
+ * Attaches a new owner channel to the multi-owner set. Replaces the
507
+ * pre-W2D singleton `_promoteToPaired` which set `_state = "paired"` and
508
+ * a single `_peerChannel`. The relay state remains `started`; pairing
509
+ * status is derived from `_activePeers.size`.
510
+ *
511
+ * Idempotent for the same `appPeerId` (re-attaching tears down the prior
512
+ * channel and installs a fresh one — covers reconnect from the same
513
+ * device without leaking listeners).
514
+ */
515
+ function _attachOwner(deps, relay, appPeerId, peerName, firstInner) {
516
+ const peerShort = appPeerId.slice(0, 8);
517
+ // Drop any stale channel for this owner before re-attaching.
518
+ if (deps.activePeers.has(appPeerId))
519
+ _detachPeerChannel(deps, appPeerId);
520
+ // Async relay routing uses the always-fresh session_start ctx (`_lastEventCtx`
521
+ // via _liveCtx), re-captured every session_start so it never goes stale (#55).
522
+ const channel = new PlainPeerChannel(relay, appPeerId, deps.myRoomId ?? undefined, (msg) => deps.routeClientMessageFrom(channel, msg, deps.liveCtx() ?? deps.noopCtx), () => _onPeerDisconnect(deps, appPeerId), (env) => env.ub === undefined
523
+ ? deps.routeRpcCommandFrom(channel, env)
524
+ : deps.routeUnBienPlaneFrom(channel, env), () => deps.rootState().sessionManager?.getSessionId() ??
525
+ deps.rootSessionId ??
526
+ undefined);
527
+ _attachPeerChannel(deps, appPeerId, channel);
528
+ // Envelope-native capability handshake: advertise caps up front so the app can
529
+ // enable the {rpc|evt} route + suppress stock before any session content
530
+ // arrives. Additive to the stock session_history caps (parity transition).
531
+ const _sid = deps.rootState().sessionManager?.getSessionId();
532
+ channel.sendEnvelope(helloEnvelope(sessionCapabilities(), _sid));
533
+ envLog(`attach: peer=${appPeerId.slice(0, 8)} hello sent (caps + sessionId=${_sid ?? "?"}); active=${deps.activePeers.size}`);
534
+ // Reconstruction (transcript + panels + extension_ui) is request-driven: the
535
+ // app issues session_sync — on fresh open AND on relay reconnect — and the
536
+ // handler in _routeUnBienPlaneFrom replays all of it. Re-sync is idempotent
537
+ // (stable identify ids + ns/id panel merge), so nothing is replayed
538
+ // proactively here.
539
+ deps.refreshFooter();
540
+ deps.safeNotify(`[un-bien] Owner attached: peer=${peerShort}, name=${peerName} ` +
541
+ `(${deps.activePeers.size} active)`, "info");
542
+ if (firstInner) {
543
+ // The PlainPeerChannel listener fired on the same line that triggered
544
+ // attachment in some flows; we route explicitly here too to ensure the
545
+ // inner reaches the handler exactly once.
546
+ void firstInner;
547
+ }
548
+ return channel;
549
+ }
550
+ // ── Auto-listener ─────────────────────────────────────────────────────────────
551
+ //
552
+ // Installed while in 'started' state. Decodes the outer envelope as
553
+ // base64(JSON) and dispatches per sender peer_id:
554
+ // • Sender already in `_activePeers` → ignored here (the per-owner
555
+ // PlainPeerChannel listens on the same relay event and handles its own
556
+ // traffic via its `remotePeerId` filter)
557
+ // • `pair_request` from a new peer → validate token, persist peer, send
558
+ // pair_ok/pair_error, attach a new channel
559
+ // • Non-pair message from a known peer (peers.json) without an active
560
+ // channel yet → attach + route the inner (reconnect path)
561
+ // • Anything else (unknown peer + non-pair) → emit `error: unknown_peer`
562
+ export function _installAutoListener(deps, relay) {
563
+ const listenerGeneration = _relayLifecycleGeneration;
564
+ const hasListenerAuthority = () => !deps.disposed &&
565
+ deps.state === "started" &&
566
+ deps.relay === relay &&
567
+ _relayLifecycleGeneration === listenerGeneration;
568
+ const onMsg = async (line) => {
569
+ let outer;
570
+ try {
571
+ outer = JSON.parse(line);
572
+ }
573
+ catch {
574
+ return;
575
+ }
576
+ if (!outer.peer || !outer.ct)
577
+ return;
578
+ if (!hasListenerAuthority())
579
+ return;
580
+ // Decode inner envelope (base64 JSON) BEFORE the already-attached
581
+ // short-circuit, so a pair_request from an ATTACHED owner still reaches
582
+ // _handlePairRequest (an idempotent re-confirm, design 01M20G8SE) instead of
583
+ // being dropped. Inbound app->pi frames are low-volume, so the extra decode
584
+ // is cheap — streaming OUTPUT is outbound and never hits this path.
585
+ let inner;
586
+ try {
587
+ const plaintext = Buffer.from(outer.ct, "base64").toString("utf8");
588
+ const parsed = JSON.parse(plaintext);
589
+ if (!parsed ||
590
+ typeof parsed !== "object" ||
591
+ typeof parsed.type !== "string")
592
+ return;
593
+ inner = parsed;
594
+ }
595
+ catch {
596
+ return;
597
+ }
598
+ const appPeerId = outer.peer;
599
+ if (inner.type === "pair_request") {
600
+ await _handlePairRequest(deps, relay, appPeerId, inner, hasListenerAuthority);
601
+ return;
602
+ }
603
+ // Already-attached owners: their PlainPeerChannel handles non-pair routing.
604
+ if (deps.activePeers.has(appPeerId))
605
+ return;
606
+ // Reconnect path: known peer (peers.json) without an active channel
607
+ // sends a non-pair message → attach + route through the new channel.
608
+ // See pairing.md §Reconexão.
609
+ const known = await _findKnownPeer(appPeerId);
610
+ if (!hasListenerAuthority())
611
+ return;
612
+ if (known) {
613
+ const channel = _attachOwner(deps, relay, appPeerId, known.name);
614
+ // The channel listener didn't see the line that triggered the attach, so
615
+ // route it explicitly — MIRRORING the channel's own dispatch (peer_channel
616
+ // _onLine): a real-typed envelope ("rpc"/"evt"/"ub", legacy "env") or a
617
+ // bare rpc/evt/ub body goes to the envelope dispatcher, a stock
618
+ // ClientMessage to the stock switch. Everything is on the envelope proto
619
+ // now, so the first message is normally the ub session_sync (or the rpc
620
+ // get_entries) — routing that through the stock switch dropped it. Use
621
+ // _liveCtx (session_start-fresh), not #55.
622
+ const innerObj = inner;
623
+ if (isEnvelopeFrame(innerObj)) {
624
+ {
625
+ // SAFETY: isEnvelopeFrame confirmed rpc/evt/ub envelope keys are
626
+ // present, so this ClientMessage is byte-compatible with EnvelopeMessage.
627
+ const innerEnv = inner;
628
+ if (innerEnv.ub === undefined)
629
+ deps.routeRpcCommandFrom(channel, innerEnv);
630
+ else
631
+ deps.routeUnBienPlaneFrom(channel, innerEnv);
632
+ }
633
+ }
634
+ else {
635
+ deps.routeClientMessageFrom(channel, inner, deps.liveCtx() ?? deps.noopCtx);
636
+ }
637
+ return;
638
+ }
639
+ // Unknown peer with non-pair_request inner — signal so the app can react
640
+ // (peer was revoked / never paired). pair_request from unknown peer was
641
+ // already handled above as a legitimate path. We never log inner contents,
642
+ // only inner.type.
643
+ const errReply = {
644
+ type: "error",
645
+ code: "unknown_peer",
646
+ message: "Peer not paired — re-scan QR",
647
+ };
648
+ const errCt = Buffer.from(JSON.stringify(errReply)).toString("base64");
649
+ relay.send(JSON.stringify({ peer: appPeerId, ct: errCt }));
650
+ };
651
+ relay.on("message", onMsg);
652
+ return () => relay.off("message", onMsg);
653
+ }
654
+ /**
655
+ * Plan/27 Wave A: lazily resolve the pi-extension package version from
656
+ * disk so the `pair_ok.harness.version` field reflects what's actually
657
+ * shipped. The lookup is best-effort — a parse failure (or running this
658
+ * file out-of-tree) falls back to "0.0.0" which is still semver-valid
659
+ * and the app tolerates it. Cached at module load.
660
+ */
661
+ function _readExtensionVersion() {
662
+ try {
663
+ const here = fileURLToPath(import.meta.url);
664
+ // dist/session/relay_lifecycle.js → ../../.. = the extension package
665
+ // root. src/session/relay_lifecycle.ts under tsx → also three levels up.
666
+ const pkgPath = join(here, "..", "..", "..", "package.json");
667
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
668
+ return typeof pkg.version === "string" ? pkg.version : "0.0.0";
669
+ }
670
+ catch {
671
+ return "0.0.0";
672
+ }
673
+ }
674
+ const _HARNESS = {
675
+ name: "Pi coding agent",
676
+ version: _readExtensionVersion(),
677
+ };
678
+ const _HOSTNAME = hostname();
679
+ // un-bien capability handshake. PROTOCOL_VERSION bumps on a HARD (breaking)
680
+ // wire change; the app gates UI on capability PRESENCE, not this number.
681
+ const PROTOCOL_VERSION = 1;
682
+ // Features this extension supports, advertised on attach (session_history) + pair_ok.
683
+ // `remote_launch` is conditional (added only when local config opts in) — see
684
+ // Capability set moved to ./capabilities.ts (sessionCapabilities) so both the
685
+ // ub hello (here) and room_meta.caps (commands/lifecycle) advertise the SAME
686
+ // set from one choke point.
687
+ /** Send pair_ok to `inner`'s sender — the shared success + re-confirm payload
688
+ * (design 01M20G8SE). The caller has already attached the owner and (for a NEW
689
+ * peer) persisted it; this only emits the confirmation. */
690
+ function _emitPairOk(deps, sendInner, inner, roomId) {
691
+ sendInner({
692
+ type: "pair_ok",
693
+ in_reply_to: inner.id,
694
+ session_name: deps.displayName(deps.sessionCwd()),
695
+ session_started_at: deps.sessionStartedAt ?? Date.now(),
696
+ room_id: roomId,
697
+ harness: _HARNESS,
698
+ hostname: _HOSTNAME,
699
+ protocol_version: PROTOCOL_VERSION,
700
+ capabilities: sessionCapabilities(),
701
+ });
702
+ }
703
+ async function _handlePairRequest(deps, relay, appPeerId, inner, hasListenerAuthority) {
704
+ const sendInner = (msg) => {
705
+ const ct = Buffer.from(JSON.stringify(msg)).toString("base64");
706
+ relay.send(JSON.stringify({ peer: appPeerId, ct }));
707
+ };
708
+ const sendError = (code, message) => {
709
+ sendInner({ type: "pair_error", in_reply_to: inner.id, code, message });
710
+ };
711
+ // IDEMPOTENT RE-CONFIRM for an owner already in peers.json (design 01M20G8SE):
712
+ // the relay rewrites outer.peer to the challenge-verified sender, so a known
713
+ // epk here is an already-authenticated, already-trusted owner. Re-send pair_ok
714
+ // WITHOUT consuming a token (the token only bootstraps trust for a NEW epk), so
715
+ // a re-pair from a trusted device — re-scanned a fresh QR while still attached,
716
+ // or a stale-token re-scan — is ACKed instead of silently dropped.
717
+ const knownOwner = await _findKnownPeer(appPeerId);
718
+ if (knownOwner) {
719
+ if (!hasListenerAuthority())
720
+ return;
721
+ const roomId = deps.myRoomId;
722
+ if (!roomId) {
723
+ sendError("internal_error", "No session room yet — retry pairing once this session has started " +
724
+ "(design 01M1CAW0).");
725
+ return;
726
+ }
727
+ if (!deps.activePeers.has(appPeerId)) {
728
+ _attachOwner(deps, relay, appPeerId, knownOwner.name);
729
+ }
730
+ _emitPairOk(deps, sendInner, inner, roomId);
731
+ return;
732
+ }
733
+ const status = qrSession.consumeToken(inner.token);
734
+ if (status !== "ok") {
735
+ const code = status === "expired"
736
+ ? "token_expired"
737
+ : status === "consumed"
738
+ ? "token_consumed"
739
+ : "token_unknown";
740
+ const msg = code === "token_expired"
741
+ ? "Ephemeral token expired. Generate a new QR with /unbien pair."
742
+ : code === "token_consumed"
743
+ ? "Token already consumed by another pair_request."
744
+ : "Token was not issued by this Pi.";
745
+ sendError(code, msg);
746
+ return;
747
+ }
748
+ // design 01M1CAW0: pair_ok must carry the session-id-derived room the Pi
749
+ // actually announced. A relay connection only comes up once the session id
750
+ // exists (a pre-id start defers), so a null myRoomId here is a torn state —
751
+ // refuse the pair instead of falling back to the retired cwd-derived room
752
+ // (which the app would then address while the Pi announces another). The
753
+ // app surfaces pair_error and the user rescans once the session has started.
754
+ const roomId = deps.myRoomId;
755
+ if (!roomId) {
756
+ envLog("pair_request refused: no session room yet (design 01M1CAW0)");
757
+ sendError("internal_error", "No session room yet — retry pairing once this session has started " +
758
+ "(design 01M1CAW0).");
759
+ return;
760
+ }
761
+ // A delayed signed revoke must lose authority before the same-process
762
+ // re-pair enters storage; the replacement owns a fresh token snapshot.
763
+ const producer = deps.selfRevoke;
764
+ const producerEpoch = deps.selfRevokeEpoch;
765
+ producer?.invalidateStorageAuthority();
766
+ const pairedAt = new Date().toISOString();
767
+ try {
768
+ await addPeer({
769
+ name: inner.device_name,
770
+ remote_epk: appPeerId,
771
+ paired_at: pairedAt,
772
+ });
773
+ if (!hasListenerAuthority())
774
+ return;
775
+ deps.refreshPairingsCache();
776
+ if (producer &&
777
+ deps.selfRevoke === producer &&
778
+ deps.selfRevokeEpoch === producerEpoch) {
779
+ void producer.requestFreshCheck().catch(() => {
780
+ // The regular cadence retries; pairing itself already succeeded.
781
+ });
782
+ }
783
+ }
784
+ catch (err) {
785
+ if (!hasListenerAuthority())
786
+ return;
787
+ sendError("internal_error", `Failed to persist peer: ${String(err)}`);
788
+ return;
789
+ }
790
+ _attachOwner(deps, relay, appPeerId, inner.device_name);
791
+ _emitPairOk(deps, sendInner, inner, roomId);
792
+ // Notify local RPC clients (e.g. Cockpit) that pairing completed, so they can
793
+ // close the QR screen and show the new device. Pure data event (display:false)
794
+ // — still emitted to the RPC stdout via the session stream.
795
+ deps.pi?.sendMessage({
796
+ customType: "un-bien:paired",
797
+ content: `Paired with ${inner.device_name}`,
798
+ details: { name: inner.device_name, peerId: appPeerId, pairedAt },
799
+ display: false,
800
+ });
801
+ }
802
+ //# sourceMappingURL=relay_lifecycle.js.map