@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.
- package/README.md +99 -130
- package/dist/actions/handlers.js.map +1 -1
- package/dist/actions/registry.js.map +1 -1
- package/dist/bin/launcher.js.map +1 -1
- package/dist/commands/deps.d.ts +156 -0
- package/dist/commands/deps.js +2 -0
- package/dist/commands/deps.js.map +1 -0
- package/dist/commands/fork_link.d.ts +4 -0
- package/dist/commands/fork_link.js +45 -0
- package/dist/commands/fork_link.js.map +1 -0
- package/dist/commands/housekeeping.d.ts +34 -0
- package/dist/commands/housekeeping.js +275 -0
- package/dist/commands/housekeeping.js.map +1 -0
- package/dist/commands/info.d.ts +43 -0
- package/dist/commands/info.js +127 -0
- package/dist/commands/info.js.map +1 -0
- package/dist/commands/lifecycle.d.ts +45 -0
- package/dist/commands/lifecycle.js +708 -0
- package/dist/commands/lifecycle.js.map +1 -0
- package/dist/commands/pairing.d.ts +26 -0
- package/dist/commands/pairing.js +167 -0
- package/dist/commands/pairing.js.map +1 -0
- package/dist/commands/register.d.ts +12 -0
- package/dist/commands/register.js +255 -0
- package/dist/commands/register.js.map +1 -0
- package/dist/commands/relay.d.ts +28 -0
- package/dist/commands/relay.js +81 -0
- package/dist/commands/relay.js.map +1 -0
- package/dist/commands/session_ops.d.ts +52 -0
- package/dist/commands/session_ops.js +75 -0
- package/dist/commands/session_ops.js.map +1 -0
- package/dist/config.d.ts +2 -1
- package/dist/config.js.map +1 -1
- package/dist/daemon/install.d.ts +4 -3
- package/dist/daemon/install.js +7 -1
- package/dist/daemon/install.js.map +1 -1
- package/dist/enrich_tool_args.js.map +1 -1
- package/dist/extension_ui_bridge.js.map +1 -1
- package/dist/image_codec.js.map +1 -1
- package/dist/index.d.ts +32 -101
- package/dist/index.js +818 -2867
- package/dist/index.js.map +1 -1
- package/dist/launch.d.ts +9 -1
- package/dist/launch.js +39 -4
- package/dist/launch.js.map +1 -1
- package/dist/launcher/launcher.js +21 -2
- package/dist/launcher/launcher.js.map +1 -1
- package/dist/mcp/mesh_result.js.map +1 -1
- package/dist/mcp/mesh_server.js.map +1 -1
- package/dist/mesh/canonical.js +1 -1
- package/dist/mesh/canonical.js.map +1 -1
- package/dist/mesh/client.js.map +1 -1
- package/dist/mesh/encoding.js.map +1 -1
- package/dist/mesh/self_revoke.js.map +1 -1
- package/dist/mesh/siblings.js.map +1 -1
- package/dist/mesh/verify.js +1 -1
- package/dist/mesh/verify.js.map +1 -1
- package/dist/pairing/crypto.js +0 -1
- package/dist/pairing/crypto.js.map +1 -1
- package/dist/pairing/peer_trust.js.map +1 -1
- package/dist/pairing/qr.js.map +1 -1
- package/dist/pairing/storage.js +4 -3
- package/dist/pairing/storage.js.map +1 -1
- package/dist/panel_bridge.js +1 -0
- package/dist/panel_bridge.js.map +1 -1
- package/dist/paths.d.ts +22 -8
- package/dist/paths.js +37 -10
- package/dist/paths.js.map +1 -1
- package/dist/protocol/codec.js.map +1 -1
- package/dist/rooms.js +1 -4
- package/dist/rooms.js.map +1 -1
- package/dist/session/bridge.js.map +1 -1
- package/dist/session/broker.js.map +1 -1
- package/dist/session/broker_remote.js.map +1 -1
- package/dist/session/capabilities.d.ts +11 -0
- package/dist/session/capabilities.js +35 -0
- package/dist/session/capabilities.js.map +1 -0
- package/dist/session/cwd_lock.js +3 -3
- package/dist/session/cwd_lock.js.map +1 -1
- package/dist/session/debug_log.js +13 -3
- package/dist/session/debug_log.js.map +1 -1
- package/dist/session/envelope.js.map +1 -1
- package/dist/session/global_config.d.ts +1 -1
- package/dist/session/global_config.js +1 -1
- package/dist/session/global_config.js.map +1 -1
- package/dist/session/ipc.d.ts +2 -2
- package/dist/session/ipc.js.map +1 -1
- package/dist/session/leader_election.js.map +1 -1
- package/dist/session/local_config.d.ts +37 -0
- package/dist/session/local_config.js +45 -1
- package/dist/session/local_config.js.map +1 -1
- package/dist/session/mesh_node.d.ts +1 -1
- package/dist/session/mesh_node.js +1 -1
- package/dist/session/mesh_node.js.map +1 -1
- package/dist/session/peer.js.map +1 -1
- package/dist/session/peer_inventory.js.map +1 -1
- package/dist/session/peer_limits.js.map +1 -1
- package/dist/session/received_images.d.ts +63 -0
- package/dist/session/received_images.js +273 -0
- package/dist/session/received_images.js.map +1 -0
- package/dist/session/relay_lifecycle.d.ts +219 -0
- package/dist/session/relay_lifecycle.js +764 -0
- package/dist/session/relay_lifecycle.js.map +1 -0
- package/dist/session/rpc_envelope.d.ts +10 -0
- package/dist/session/rpc_envelope.js +9 -0
- package/dist/session/rpc_envelope.js.map +1 -1
- package/dist/session/rpc_handlers.d.ts +53 -0
- package/dist/session/rpc_handlers.js +268 -0
- package/dist/session/rpc_handlers.js.map +1 -0
- package/dist/session/rpc_inbound.d.ts +41 -6
- package/dist/session/rpc_inbound.js +55 -0
- package/dist/session/rpc_inbound.js.map +1 -1
- package/dist/session/setup_wizard.js.map +1 -1
- package/dist/session/tools.js.map +1 -1
- package/dist/session/wizard.js.map +1 -1
- package/dist/state_migration.d.ts +45 -0
- package/dist/state_migration.js +104 -0
- package/dist/state_migration.js.map +1 -0
- package/dist/subagent_rooms.d.ts +19 -0
- package/dist/subagent_rooms.js +146 -14
- package/dist/subagent_rooms.js.map +1 -1
- package/dist/test_hooks.d.ts +180 -0
- package/dist/test_hooks.js +98 -0
- package/dist/test_hooks.js.map +1 -0
- package/dist/transport/peer_channel.d.ts +11 -0
- package/dist/transport/peer_channel.js +34 -11
- package/dist/transport/peer_channel.js.map +1 -1
- package/dist/transport/pi_forward_client.js.map +1 -1
- package/dist/transport/relay_client.d.ts +5 -2
- package/dist/transport/relay_client.js +6 -3
- package/dist/transport/relay_client.js.map +1 -1
- package/dist/ui/footer.js.map +1 -1
- package/docs/daemon.md +160 -170
- package/package.json +1 -1
- package/service-templates/launchd.plist.template +5 -4
- 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
|