@geohar/un-bien 0.9.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/README.md +99 -130
  2. package/dist/actions/handlers.js.map +1 -1
  3. package/dist/actions/registry.js.map +1 -1
  4. package/dist/bin/launcher.js.map +1 -1
  5. package/dist/commands/deps.d.ts +156 -0
  6. package/dist/commands/deps.js +2 -0
  7. package/dist/commands/deps.js.map +1 -0
  8. package/dist/commands/fork_link.d.ts +4 -0
  9. package/dist/commands/fork_link.js +45 -0
  10. package/dist/commands/fork_link.js.map +1 -0
  11. package/dist/commands/housekeeping.d.ts +34 -0
  12. package/dist/commands/housekeeping.js +275 -0
  13. package/dist/commands/housekeeping.js.map +1 -0
  14. package/dist/commands/info.d.ts +43 -0
  15. package/dist/commands/info.js +127 -0
  16. package/dist/commands/info.js.map +1 -0
  17. package/dist/commands/lifecycle.d.ts +45 -0
  18. package/dist/commands/lifecycle.js +708 -0
  19. package/dist/commands/lifecycle.js.map +1 -0
  20. package/dist/commands/pairing.d.ts +26 -0
  21. package/dist/commands/pairing.js +167 -0
  22. package/dist/commands/pairing.js.map +1 -0
  23. package/dist/commands/register.d.ts +12 -0
  24. package/dist/commands/register.js +255 -0
  25. package/dist/commands/register.js.map +1 -0
  26. package/dist/commands/relay.d.ts +28 -0
  27. package/dist/commands/relay.js +81 -0
  28. package/dist/commands/relay.js.map +1 -0
  29. package/dist/commands/session_ops.d.ts +52 -0
  30. package/dist/commands/session_ops.js +75 -0
  31. package/dist/commands/session_ops.js.map +1 -0
  32. package/dist/config.d.ts +2 -1
  33. package/dist/config.js.map +1 -1
  34. package/dist/daemon/install.d.ts +4 -3
  35. package/dist/daemon/install.js +7 -1
  36. package/dist/daemon/install.js.map +1 -1
  37. package/dist/enrich_tool_args.js.map +1 -1
  38. package/dist/extension_ui_bridge.js.map +1 -1
  39. package/dist/image_codec.js.map +1 -1
  40. package/dist/index.d.ts +32 -101
  41. package/dist/index.js +818 -2867
  42. package/dist/index.js.map +1 -1
  43. package/dist/launch.d.ts +9 -1
  44. package/dist/launch.js +39 -4
  45. package/dist/launch.js.map +1 -1
  46. package/dist/launcher/launcher.js +21 -2
  47. package/dist/launcher/launcher.js.map +1 -1
  48. package/dist/mcp/mesh_result.js.map +1 -1
  49. package/dist/mcp/mesh_server.js.map +1 -1
  50. package/dist/mesh/canonical.js +1 -1
  51. package/dist/mesh/canonical.js.map +1 -1
  52. package/dist/mesh/client.js.map +1 -1
  53. package/dist/mesh/encoding.js.map +1 -1
  54. package/dist/mesh/self_revoke.js.map +1 -1
  55. package/dist/mesh/siblings.js.map +1 -1
  56. package/dist/mesh/verify.js +1 -1
  57. package/dist/mesh/verify.js.map +1 -1
  58. package/dist/pairing/crypto.js +0 -1
  59. package/dist/pairing/crypto.js.map +1 -1
  60. package/dist/pairing/peer_trust.js.map +1 -1
  61. package/dist/pairing/qr.js.map +1 -1
  62. package/dist/pairing/storage.js +4 -3
  63. package/dist/pairing/storage.js.map +1 -1
  64. package/dist/panel_bridge.js +1 -0
  65. package/dist/panel_bridge.js.map +1 -1
  66. package/dist/paths.d.ts +22 -8
  67. package/dist/paths.js +37 -10
  68. package/dist/paths.js.map +1 -1
  69. package/dist/protocol/codec.js.map +1 -1
  70. package/dist/rooms.js +1 -4
  71. package/dist/rooms.js.map +1 -1
  72. package/dist/session/bridge.js.map +1 -1
  73. package/dist/session/broker.js.map +1 -1
  74. package/dist/session/broker_remote.js.map +1 -1
  75. package/dist/session/capabilities.d.ts +11 -0
  76. package/dist/session/capabilities.js +35 -0
  77. package/dist/session/capabilities.js.map +1 -0
  78. package/dist/session/cwd_lock.js +3 -3
  79. package/dist/session/cwd_lock.js.map +1 -1
  80. package/dist/session/debug_log.js +13 -3
  81. package/dist/session/debug_log.js.map +1 -1
  82. package/dist/session/envelope.js.map +1 -1
  83. package/dist/session/global_config.d.ts +1 -1
  84. package/dist/session/global_config.js +1 -1
  85. package/dist/session/global_config.js.map +1 -1
  86. package/dist/session/ipc.d.ts +2 -2
  87. package/dist/session/ipc.js.map +1 -1
  88. package/dist/session/leader_election.js.map +1 -1
  89. package/dist/session/local_config.d.ts +37 -0
  90. package/dist/session/local_config.js +45 -1
  91. package/dist/session/local_config.js.map +1 -1
  92. package/dist/session/mesh_node.d.ts +1 -1
  93. package/dist/session/mesh_node.js +1 -1
  94. package/dist/session/mesh_node.js.map +1 -1
  95. package/dist/session/peer.js.map +1 -1
  96. package/dist/session/peer_inventory.js.map +1 -1
  97. package/dist/session/peer_limits.js.map +1 -1
  98. package/dist/session/received_images.d.ts +63 -0
  99. package/dist/session/received_images.js +273 -0
  100. package/dist/session/received_images.js.map +1 -0
  101. package/dist/session/relay_lifecycle.d.ts +219 -0
  102. package/dist/session/relay_lifecycle.js +764 -0
  103. package/dist/session/relay_lifecycle.js.map +1 -0
  104. package/dist/session/rpc_envelope.d.ts +10 -0
  105. package/dist/session/rpc_envelope.js +9 -0
  106. package/dist/session/rpc_envelope.js.map +1 -1
  107. package/dist/session/rpc_handlers.d.ts +53 -0
  108. package/dist/session/rpc_handlers.js +268 -0
  109. package/dist/session/rpc_handlers.js.map +1 -0
  110. package/dist/session/rpc_inbound.d.ts +41 -6
  111. package/dist/session/rpc_inbound.js +55 -0
  112. package/dist/session/rpc_inbound.js.map +1 -1
  113. package/dist/session/setup_wizard.js.map +1 -1
  114. package/dist/session/tools.js.map +1 -1
  115. package/dist/session/wizard.js.map +1 -1
  116. package/dist/state_migration.d.ts +45 -0
  117. package/dist/state_migration.js +104 -0
  118. package/dist/state_migration.js.map +1 -0
  119. package/dist/subagent_rooms.d.ts +19 -0
  120. package/dist/subagent_rooms.js +146 -14
  121. package/dist/subagent_rooms.js.map +1 -1
  122. package/dist/test_hooks.d.ts +180 -0
  123. package/dist/test_hooks.js +98 -0
  124. package/dist/test_hooks.js.map +1 -0
  125. package/dist/transport/peer_channel.d.ts +11 -0
  126. package/dist/transport/peer_channel.js +34 -11
  127. package/dist/transport/peer_channel.js.map +1 -1
  128. package/dist/transport/pi_forward_client.js.map +1 -1
  129. package/dist/transport/relay_client.d.ts +5 -2
  130. package/dist/transport/relay_client.js +6 -3
  131. package/dist/transport/relay_client.js.map +1 -1
  132. package/dist/ui/footer.js.map +1 -1
  133. package/docs/daemon.md +160 -170
  134. package/package.json +1 -1
  135. package/service-templates/launchd.plist.template +5 -4
  136. package/service-templates/systemd.service.template +3 -2
@@ -0,0 +1,764 @@
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 } 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
+ relay.on("close", () => _onRelayClose(deps, relay));
331
+ deps.stopAutoListener = _installAutoListener(deps, relay);
332
+ // Plan/25 Wave B/C: relay is back; bring cross-PC routing back online.
333
+ deps.attachBridgeIfReady();
334
+ // _state stays "started"; peer reconnect (if previously paired) flows
335
+ // through _installAutoListener → _findKnownPeer → _promoteToPaired
336
+ // automatically when the app sends any inner.
337
+ _emitRelayState(deps);
338
+ }
339
+ // ── Relay state event + transparent control channel (Cockpit toggle) ─────────
340
+ /** Current relay connectivity, derived from `_state` + `_relay`. */
341
+ export function _relayStatus(deps) {
342
+ if (deps.getState() === "idle")
343
+ return "disconnected";
344
+ return deps.relay ? "connected" : "reconnecting";
345
+ }
346
+ /**
347
+ * Emit the `un-bien:relay-state` custom message so an RPC client (Cockpit)
348
+ * can render a relay on/off indicator. Pure data (`display:false`) — never
349
+ * shown in the transcript. De-duped on the connectivity value; pass
350
+ * `force=true` to answer an explicit `relay:status` query regardless.
351
+ */
352
+ export function _emitRelayState(deps, force = false) {
353
+ const status = _relayStatus(deps);
354
+ if (!force && status === _lastRelayStatus)
355
+ return;
356
+ _lastRelayStatus = status;
357
+ // This can run inside a WebSocket 'close' callback (via _onRelayClose). After a
358
+ // session replacement (newSession/fork/switchSession/reload) the module-level
359
+ // `_pi` is stale, and `assertActive` throws synchronously inside `sendMessage`.
360
+ // An uncaught throw from a WS event callback becomes a process-level
361
+ // uncaughtException and exits pi. Swallow it here: the next relay-state
362
+ // change re-emits, so connectivity is eventually consistent. See issue #55.
363
+ try {
364
+ deps.pi?.sendMessage({
365
+ customType: "un-bien:relay-state",
366
+ content: `Relay ${status}`,
367
+ details: {
368
+ status,
369
+ connected: status === "connected",
370
+ ...(deps.relayUrl ? { relayUrl: deps.relayUrl } : {}),
371
+ ...(deps.myRoomId ? { room: deps.myRoomId } : {}),
372
+ },
373
+ display: false,
374
+ });
375
+ }
376
+ catch {
377
+ // _pi stale (session replaced) or extension runtime not yet bound.
378
+ }
379
+ }
380
+ /** Minimal ctx for relay start/stop driven by a control message (no command
381
+ * ctx is available in the `input` hook). cwd matches the daemon's launch dir,
382
+ * so the derived relay room is identical to the one `_cmdStart` first used. */
383
+ export function _controlCtx() {
384
+ // SAFETY: _headlessUi() implements every ui method the relay start/stop path
385
+ // actually calls; the notify-forwarding shim is structurally narrower than the
386
+ // full ExtensionContext["ui"] but complete for this headless control path.
387
+ return {
388
+ ui: _headlessUi(),
389
+ cwd: process.cwd(),
390
+ };
391
+ }
392
+ /**
393
+ * `ui.notify` for headless contexts (daemon auto-init + control channel). There
394
+ * is no TUI, and the RPC client (Cockpit) already gets everything it needs via
395
+ * structured events (`un-bien:relay-state`, `un-bien:name-assigned`,
396
+ * room_meta) — so routine INFO chatter would just pollute the client's captured
397
+ * stderr. We drop info and forward only warnings/errors (kept for the
398
+ * supervisor's journal / genuine failures). The interactive Pi keeps its normal
399
+ * footer/notify path — this only affects headless ctxs.
400
+ */
401
+ export function _headlessUi() {
402
+ return {
403
+ notify: (msg, type) => {
404
+ if (type === "warning" || type === "error")
405
+ process.stderr.write(`${msg}\n`);
406
+ },
407
+ };
408
+ }
409
+ /**
410
+ * Handle a transparent control command from an RPC client (Cockpit), received
411
+ * as a `CTRL_PREFIX`-tagged input the `input` hook swallowed. Toggles the relay
412
+ * WITHOUT leaving the local mesh (relay-only: `_cmdStart` up / `_goIdle` down),
413
+ * then emits the fresh state. `relay:status` just re-emits (no change) so the
414
+ * client can sync its button after (re)attaching to the RPC stream.
415
+ */
416
+ export async function _handleControl(deps, cmd) {
417
+ // `rename:<new-name>` carries an argument, so it's matched before the
418
+ // fixed-verb switch. Renames the agent live (broker re-register + relay room
419
+ // swap) WITHOUT restarting the process or losing the SDK session.
420
+ if (cmd.startsWith("rename:")) {
421
+ await deps.renameAgent(cmd.slice("rename:".length).trim());
422
+ return;
423
+ }
424
+ switch (cmd) {
425
+ case "relay:on":
426
+ if (deps.getState() === "idle")
427
+ await _cmdStart(deps.commandDeps, _controlCtx());
428
+ _emitRelayState(deps, true);
429
+ return;
430
+ case "relay:off":
431
+ if (deps.getState() === "idle") {
432
+ deps.rootLifecycleGeneration += 1;
433
+ _relayLifecycleGeneration += 1;
434
+ }
435
+ else
436
+ _goIdle(deps);
437
+ _emitRelayState(deps, true);
438
+ return;
439
+ case "relay:toggle":
440
+ if (deps.getState() === "idle")
441
+ await _cmdStart(deps.commandDeps, _controlCtx());
442
+ else
443
+ _goIdle(deps);
444
+ _emitRelayState(deps, true);
445
+ return;
446
+ case "relay:status":
447
+ _emitRelayState(deps, true);
448
+ return;
449
+ default:
450
+ // Unknown control verb — ignore (forward-compat: a newer client may send
451
+ // verbs an older extension doesn't know).
452
+ return;
453
+ }
454
+ }
455
+ /**
456
+ * Per-owner disconnect callback. Fires when one specific owner's channel
457
+ * detaches (e.g. relay told us the peer is gone). Other owners' channels
458
+ * keep running — relay stays "started".
459
+ *
460
+ * Exported so tests can trigger the disconnect path for a specific peer.
461
+ *
462
+ * Backward-compat: a no-arg call (legacy tests / pre-W2D callers) falls
463
+ * back to detaching the most recently attached peer, mirroring the old
464
+ * singleton semantics.
465
+ */
466
+ export function _onPeerDisconnect(deps, appPeerId) {
467
+ if (deps.state === "idle")
468
+ return;
469
+ const target = appPeerId ?? [...deps.activePeers.keys()].pop();
470
+ if (!target)
471
+ return;
472
+ if (!deps.activePeers.has(target))
473
+ return;
474
+ _detachPeerChannel(deps, target);
475
+ if (_anyPeerActive(deps)) {
476
+ // Other owners still attached — keep _rootState().turnId so they continue
477
+ // seeing the in-flight agent stream.
478
+ deps.refreshFooter();
479
+ return;
480
+ }
481
+ // No owner left. Conservatively clear the turn so the next pair_request
482
+ // starts cleanly.
483
+ deps.rootState().turnId = null;
484
+ deps.refreshFooter();
485
+ deps.safeNotify("[un-bien] All app peers disconnected, listening for reconnect", "info");
486
+ // Auto-listener stays up — same listener catches the reconnect on any peer.
487
+ }
488
+ /**
489
+ * Attaches a new owner channel to the multi-owner set. Replaces the
490
+ * pre-W2D singleton `_promoteToPaired` which set `_state = "paired"` and
491
+ * a single `_peerChannel`. The relay state remains `started`; pairing
492
+ * status is derived from `_activePeers.size`.
493
+ *
494
+ * Idempotent for the same `appPeerId` (re-attaching tears down the prior
495
+ * channel and installs a fresh one — covers reconnect from the same
496
+ * device without leaking listeners).
497
+ */
498
+ function _attachOwner(deps, relay, appPeerId, peerName, firstInner) {
499
+ const peerShort = appPeerId.slice(0, 8);
500
+ // Drop any stale channel for this owner before re-attaching.
501
+ if (deps.activePeers.has(appPeerId))
502
+ _detachPeerChannel(deps, appPeerId);
503
+ // Async relay routing uses the always-fresh session_start ctx (`_lastEventCtx`
504
+ // via _liveCtx), re-captured every session_start so it never goes stale (#55).
505
+ 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
506
+ ? deps.routeRpcCommandFrom(channel, env)
507
+ : deps.routeUnBienPlaneFrom(channel, env), () => deps.rootState().sessionManager?.getSessionId() ??
508
+ deps.rootSessionId ??
509
+ undefined);
510
+ _attachPeerChannel(deps, appPeerId, channel);
511
+ // Envelope-native capability handshake: advertise caps up front so the app can
512
+ // enable the {rpc|evt} route + suppress stock before any session content
513
+ // arrives. Additive to the stock session_history caps (parity transition).
514
+ const _sid = deps.rootState().sessionManager?.getSessionId();
515
+ channel.sendEnvelope(helloEnvelope(sessionCapabilities(), _sid));
516
+ envLog(`attach: peer=${appPeerId.slice(0, 8)} hello sent (caps + sessionId=${_sid ?? "?"}); active=${deps.activePeers.size}`);
517
+ // Reconstruction (transcript + panels + extension_ui) is request-driven: the
518
+ // app issues session_sync — on fresh open AND on relay reconnect — and the
519
+ // handler in _routeUnBienPlaneFrom replays all of it. Re-sync is idempotent
520
+ // (stable identify ids + ns/id panel merge), so nothing is replayed
521
+ // proactively here.
522
+ deps.refreshFooter();
523
+ deps.safeNotify(`[un-bien] Owner attached: peer=${peerShort}, name=${peerName} ` +
524
+ `(${deps.activePeers.size} active)`, "info");
525
+ if (firstInner) {
526
+ // The PlainPeerChannel listener fired on the same line that triggered
527
+ // attachment in some flows; we route explicitly here too to ensure the
528
+ // inner reaches the handler exactly once.
529
+ void firstInner;
530
+ }
531
+ return channel;
532
+ }
533
+ // ── Auto-listener ─────────────────────────────────────────────────────────────
534
+ //
535
+ // Installed while in 'started' state. Decodes the outer envelope as
536
+ // base64(JSON) and dispatches per sender peer_id:
537
+ // • Sender already in `_activePeers` → ignored here (the per-owner
538
+ // PlainPeerChannel listens on the same relay event and handles its own
539
+ // traffic via its `remotePeerId` filter)
540
+ // • `pair_request` from a new peer → validate token, persist peer, send
541
+ // pair_ok/pair_error, attach a new channel
542
+ // • Non-pair message from a known peer (peers.json) without an active
543
+ // channel yet → attach + route the inner (reconnect path)
544
+ // • Anything else (unknown peer + non-pair) → emit `error: unknown_peer`
545
+ export function _installAutoListener(deps, relay) {
546
+ const listenerGeneration = _relayLifecycleGeneration;
547
+ const hasListenerAuthority = () => !deps.disposed &&
548
+ deps.state === "started" &&
549
+ deps.relay === relay &&
550
+ _relayLifecycleGeneration === listenerGeneration;
551
+ const onMsg = async (line) => {
552
+ let outer;
553
+ try {
554
+ outer = JSON.parse(line);
555
+ }
556
+ catch {
557
+ return;
558
+ }
559
+ if (!outer.peer || !outer.ct)
560
+ return;
561
+ if (!hasListenerAuthority())
562
+ return;
563
+ // Already-attached owners: their PlainPeerChannel handles routing.
564
+ if (deps.activePeers.has(outer.peer))
565
+ return;
566
+ // Decode inner envelope (base64 JSON)
567
+ let inner;
568
+ try {
569
+ const plaintext = Buffer.from(outer.ct, "base64").toString("utf8");
570
+ const parsed = JSON.parse(plaintext);
571
+ if (!parsed ||
572
+ typeof parsed !== "object" ||
573
+ typeof parsed.type !== "string")
574
+ return;
575
+ inner = parsed;
576
+ }
577
+ catch {
578
+ return;
579
+ }
580
+ const appPeerId = outer.peer;
581
+ if (inner.type === "pair_request") {
582
+ await _handlePairRequest(deps, relay, appPeerId, inner, hasListenerAuthority);
583
+ return;
584
+ }
585
+ // Reconnect path: known peer (peers.json) without an active channel
586
+ // sends a non-pair message → attach + route through the new channel.
587
+ // See pairing.md §Reconexão.
588
+ const known = await _findKnownPeer(appPeerId);
589
+ if (!hasListenerAuthority())
590
+ return;
591
+ if (known) {
592
+ const channel = _attachOwner(deps, relay, appPeerId, known.name);
593
+ // The channel listener didn't see the line that triggered the attach, so
594
+ // route it explicitly — MIRRORING the channel's own dispatch (peer_channel
595
+ // _onLine): a real-typed envelope ("rpc"/"evt"/"ub", legacy "env") or a
596
+ // bare rpc/evt/ub body goes to the envelope dispatcher, a stock
597
+ // ClientMessage to the stock switch. Everything is on the envelope proto
598
+ // now, so the first message is normally the ub session_sync (or the rpc
599
+ // get_entries) — routing that through the stock switch dropped it. Use
600
+ // _liveCtx (session_start-fresh), not #55.
601
+ const innerObj = inner;
602
+ if (isEnvelopeFrame(innerObj)) {
603
+ {
604
+ // SAFETY: isEnvelopeFrame confirmed rpc/evt/ub envelope keys are
605
+ // present, so this ClientMessage is byte-compatible with EnvelopeMessage.
606
+ const innerEnv = inner;
607
+ if (innerEnv.ub === undefined)
608
+ deps.routeRpcCommandFrom(channel, innerEnv);
609
+ else
610
+ deps.routeUnBienPlaneFrom(channel, innerEnv);
611
+ }
612
+ }
613
+ else {
614
+ deps.routeClientMessageFrom(channel, inner, deps.liveCtx() ?? deps.noopCtx);
615
+ }
616
+ return;
617
+ }
618
+ // Unknown peer with non-pair_request inner — signal so the app can react
619
+ // (peer was revoked / never paired). pair_request from unknown peer was
620
+ // already handled above as a legitimate path. We never log inner contents,
621
+ // only inner.type.
622
+ const errReply = {
623
+ type: "error",
624
+ code: "unknown_peer",
625
+ message: "Peer not paired — re-scan QR",
626
+ };
627
+ const errCt = Buffer.from(JSON.stringify(errReply)).toString("base64");
628
+ relay.send(JSON.stringify({ peer: appPeerId, ct: errCt }));
629
+ };
630
+ relay.on("message", onMsg);
631
+ return () => relay.off("message", onMsg);
632
+ }
633
+ /**
634
+ * Plan/27 Wave A: lazily resolve the pi-extension package version from
635
+ * disk so the `pair_ok.harness.version` field reflects what's actually
636
+ * shipped. The lookup is best-effort — a parse failure (or running this
637
+ * file out-of-tree) falls back to "0.0.0" which is still semver-valid
638
+ * and the app tolerates it. Cached at module load.
639
+ */
640
+ function _readExtensionVersion() {
641
+ try {
642
+ const here = fileURLToPath(import.meta.url);
643
+ // dist/session/relay_lifecycle.js → ../../.. = the extension package
644
+ // root. src/session/relay_lifecycle.ts under tsx → also three levels up.
645
+ const pkgPath = join(here, "..", "..", "..", "package.json");
646
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
647
+ return typeof pkg.version === "string" ? pkg.version : "0.0.0";
648
+ }
649
+ catch {
650
+ return "0.0.0";
651
+ }
652
+ }
653
+ const _HARNESS = {
654
+ name: "Pi coding agent",
655
+ version: _readExtensionVersion(),
656
+ };
657
+ const _HOSTNAME = hostname();
658
+ // un-bien capability handshake. PROTOCOL_VERSION bumps on a HARD (breaking)
659
+ // wire change; the app gates UI on capability PRESENCE, not this number.
660
+ const PROTOCOL_VERSION = 1;
661
+ // Features this extension supports, advertised on attach (session_history) + pair_ok.
662
+ // `remote_launch` is conditional (added only when local config opts in) — see
663
+ // Capability set moved to ./capabilities.ts (sessionCapabilities) so both the
664
+ // ub hello (here) and room_meta.caps (commands/lifecycle) advertise the SAME
665
+ // set from one choke point.
666
+ async function _handlePairRequest(deps, relay, appPeerId, inner, hasListenerAuthority) {
667
+ const sendInner = (msg) => {
668
+ const ct = Buffer.from(JSON.stringify(msg)).toString("base64");
669
+ relay.send(JSON.stringify({ peer: appPeerId, ct }));
670
+ };
671
+ const sendError = (code, message) => {
672
+ sendInner({ type: "pair_error", in_reply_to: inner.id, code, message });
673
+ };
674
+ const status = qrSession.consumeToken(inner.token);
675
+ if (status !== "ok") {
676
+ const code = status === "expired"
677
+ ? "token_expired"
678
+ : status === "consumed"
679
+ ? "token_consumed"
680
+ : "token_unknown";
681
+ const msg = code === "token_expired"
682
+ ? "Ephemeral token expired. Generate a new QR with /unbien pair."
683
+ : code === "token_consumed"
684
+ ? "Token already consumed by another pair_request."
685
+ : "Token was not issued by this Pi.";
686
+ sendError(code, msg);
687
+ return;
688
+ }
689
+ // design 01M1CAW0: pair_ok must carry the session-id-derived room the Pi
690
+ // actually announced. A relay connection only comes up once the session id
691
+ // exists (a pre-id start defers), so a null myRoomId here is a torn state —
692
+ // refuse the pair instead of falling back to the retired cwd-derived room
693
+ // (which the app would then address while the Pi announces another). The
694
+ // app surfaces pair_error and the user rescans once the session has started.
695
+ if (!deps.myRoomId) {
696
+ envLog("pair_request refused: no session room yet (design 01M1CAW0)");
697
+ sendError("internal_error", "No session room yet — retry pairing once this session has started " +
698
+ "(design 01M1CAW0).");
699
+ return;
700
+ }
701
+ // A delayed signed revoke must lose authority before the same-process
702
+ // re-pair enters storage; the replacement owns a fresh token snapshot.
703
+ const producer = deps.selfRevoke;
704
+ const producerEpoch = deps.selfRevokeEpoch;
705
+ producer?.invalidateStorageAuthority();
706
+ const pairedAt = new Date().toISOString();
707
+ try {
708
+ await addPeer({
709
+ name: inner.device_name,
710
+ remote_epk: appPeerId,
711
+ paired_at: pairedAt,
712
+ });
713
+ if (!hasListenerAuthority())
714
+ return;
715
+ deps.refreshPairingsCache();
716
+ if (producer &&
717
+ deps.selfRevoke === producer &&
718
+ deps.selfRevokeEpoch === producerEpoch) {
719
+ void producer.requestFreshCheck().catch(() => {
720
+ // The regular cadence retries; pairing itself already succeeded.
721
+ });
722
+ }
723
+ }
724
+ catch (err) {
725
+ if (!hasListenerAuthority())
726
+ return;
727
+ sendError("internal_error", `Failed to persist peer: ${String(err)}`);
728
+ return;
729
+ }
730
+ const cwd = deps.sessionCwd();
731
+ // Prefer the user-configured agent_name (with broker suffix when on the
732
+ // mesh) over the legacy parent/folder path — matches what the user sees
733
+ // in the terminal title and in /unbien status.
734
+ const sessionName = deps.displayName(cwd);
735
+ _attachOwner(deps, relay, appPeerId, inner.device_name);
736
+ sendInner({
737
+ type: "pair_ok",
738
+ in_reply_to: inner.id,
739
+ session_name: sessionName,
740
+ session_started_at: deps.sessionStartedAt ?? Date.now(),
741
+ // App uses this to address subsequent inner messages to the right room
742
+ // when this Pi runs alongside others with the same epk. Always the
743
+ // session-id-derived room the Pi announced (guarded above; design
744
+ // 01M1CAW0 — never a cwd-derived guess).
745
+ room_id: deps.myRoomId,
746
+ // Plan/27 Wave A — surface the host coding-agent identity + machine
747
+ // hostname so the app can render a meaningful device row (and tell
748
+ // two PCs apart even when nicknames collide).
749
+ harness: _HARNESS,
750
+ hostname: _HOSTNAME,
751
+ protocol_version: PROTOCOL_VERSION,
752
+ capabilities: sessionCapabilities(),
753
+ });
754
+ // Notify local RPC clients (e.g. Cockpit) that pairing completed, so they can
755
+ // close the QR screen and show the new device. Pure data event (display:false)
756
+ // — still emitted to the RPC stdout via the session stream.
757
+ deps.pi?.sendMessage({
758
+ customType: "un-bien:paired",
759
+ content: `Paired with ${inner.device_name}`,
760
+ details: { name: inner.device_name, peerId: appPeerId, pairedAt },
761
+ display: false,
762
+ });
763
+ }
764
+ //# sourceMappingURL=relay_lifecycle.js.map