@geohar/unbien-durable 0.0.0-stage → 0.1.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 (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +52 -2
  3. package/dist/agent_config.d.ts +56 -0
  4. package/dist/agent_config.js +92 -0
  5. package/dist/agent_config.js.map +1 -0
  6. package/dist/bridge.d.ts +175 -0
  7. package/dist/bridge.js +921 -0
  8. package/dist/bridge.js.map +1 -0
  9. package/dist/event_source.d.ts +53 -0
  10. package/dist/event_source.js +124 -0
  11. package/dist/event_source.js.map +1 -0
  12. package/dist/extension.d.ts +34 -0
  13. package/dist/extension.js +46 -0
  14. package/dist/extension.js.map +1 -0
  15. package/dist/host.d.ts +2 -0
  16. package/dist/host.js +261 -0
  17. package/dist/host.js.map +1 -0
  18. package/dist/index.d.ts +17 -0
  19. package/dist/index.js +18 -0
  20. package/dist/index.js.map +1 -0
  21. package/dist/launch_wait_quit.d.ts +12 -0
  22. package/dist/launch_wait_quit.js +136 -0
  23. package/dist/launch_wait_quit.js.map +1 -0
  24. package/dist/mesh_delivery.d.ts +28 -0
  25. package/dist/mesh_delivery.js +31 -0
  26. package/dist/mesh_delivery.js.map +1 -0
  27. package/dist/mirror.d.ts +56 -0
  28. package/dist/mirror.js +136 -0
  29. package/dist/mirror.js.map +1 -0
  30. package/dist/ollama_cloud.d.ts +2 -0
  31. package/dist/ollama_cloud.js +98 -0
  32. package/dist/ollama_cloud.js.map +1 -0
  33. package/dist/pi_launch.d.ts +50 -0
  34. package/dist/pi_launch.js +84 -0
  35. package/dist/pi_launch.js.map +1 -0
  36. package/dist/port.d.ts +94 -0
  37. package/dist/port.js +121 -0
  38. package/dist/port.js.map +1 -0
  39. package/dist/registrar.d.ts +7 -0
  40. package/dist/registrar.js +31 -0
  41. package/dist/registrar.js.map +1 -0
  42. package/dist/thinking.d.ts +24 -0
  43. package/dist/thinking.js +36 -0
  44. package/dist/thinking.js.map +1 -0
  45. package/dist/view_translator.d.ts +83 -0
  46. package/dist/view_translator.js +317 -0
  47. package/dist/view_translator.js.map +1 -0
  48. package/package.json +63 -3
package/dist/bridge.js ADDED
@@ -0,0 +1,921 @@
1
+ /**
2
+ * The durable host's relay<->conversation bridge: the durable analog of the
3
+ * pi extension's relay wiring, with the session-replacement machinery
4
+ * DELETED by design — durable conversations have stable ids, so there are
5
+ * no generations, no disposal re-arm, no root-claim weakset, no stale-ctx
6
+ * juggling. A "session replacement" is just `retarget(conversation)`: the
7
+ * old room gets a flushed session_shutdown broadcast, the relay reconnects
8
+ * under the new conversation's room id, and a fresh port/mirror/watch
9
+ * attach. Nothing else carries state across.
10
+ *
11
+ * Surface (the app sees the same traffic as a pi session):
12
+ * - the room announces on connect, keyed roomIdForSession(conversation.id)
13
+ * — stable across host restarts, exactly like pi's session-id rooms;
14
+ * - the fail-closed signed pairing allow-list is pushed on every connect;
15
+ * - pair_request / known-peer reconnect / unknown-peer refusal mirror the
16
+ * pi extension's auto-listener;
17
+ * - live frames come from ConversationViewSource → createRpcEnvelope —
18
+ * the SAME producer the pi binding uses, over the SAME event shapes;
19
+ * - a run that ends without an answer is recorded as a display app entry
20
+ * (customType un-bien:durable-run-failure), so it shows live and on
21
+ * replay;
22
+ * - inbound rpc commands dispatch through the shared rpc_inbound/
23
+ * rpc_handlers pair (createRpcHandlers over the durable port); the
24
+ * model-registry/slash verbs answer errors (pi-only members are null);
25
+ * - the un plane answers session_sync / terminate.
26
+ */
27
+ import { hostname } from "node:os";
28
+ import { defineEntry as defineEntryFn } from "@earendil-works/pi-durable";
29
+ import { TOKEN_TTL_MS, PlainPeerChannel, RelayClient, _findKnownPeer, addPeer, buildQRUri, buildSignedAllowList, createRpcEnvelope, createRpcHandlers, dispatchRpcCommand, effectiveAllowRemoteTerminate, getOrCreateEd25519Keypair, helloEnvelope, isEnvelopeFrame, listPeers, loadLocalConfig, nextAllowListVersion, pairingAllowList, qrSession, renderQRAscii, roomIdForSession, sessionCapabilities, toWebSocketUrl, } from "@geohar/un-bien/durable";
30
+ import { DurableSessionControl, DurableSessionPort } from "./port.js";
31
+ import { DurableTranscriptMirror, UN_BIEN_APP_KIND } from "./mirror.js";
32
+ import { ConversationViewSource } from "./event_source.js";
33
+ import { DEFAULT_THINKING_LEVEL, ensureThinkingLevel } from "./thinking.js";
34
+ import { DurableAgentConfig } from "./agent_config.js";
35
+ /** App-defined entry kind carrying un-bien custom entries (data-only
36
+ * sidecars: display text + details payload). */
37
+ const UnBienAppEntry = defineEntryFn(UN_BIEN_APP_KIND);
38
+ /** customType of the hidden record of a session rename. */
39
+ const SESSION_NAME_CUSTOM_TYPE = "un-bien:session-name";
40
+ /** The newest session name recorded in the conversation, if any. */
41
+ function recordedSessionName(mirror) {
42
+ // SAFETY: the mirror's wire entries are mirror.ts toWire() output; an
43
+ // un-bien.app record is a custom_message with string customType/content.
44
+ const entries = mirror.page().entries;
45
+ for (let i = entries.length - 1; i >= 0; i--) {
46
+ const entry = entries[i];
47
+ if (entry.type === "custom_message" && entry.customType === SESSION_NAME_CUSTOM_TYPE)
48
+ return entry.content;
49
+ }
50
+ return undefined;
51
+ }
52
+ /** customType of the transcript notice for a run that ended unanswered. */
53
+ const RUN_FAILURE_CUSTOM_TYPE = "un-bien:durable-run-failure";
54
+ /** The notice text per RunFailureReason. */
55
+ const RUN_FAILURE_TEXT = {
56
+ model_error: "The model request failed",
57
+ no_model: "No model is available",
58
+ aborted: "Stopped",
59
+ reset: "The context was reset before an answer",
60
+ faulted: "The run failed",
61
+ orphaned: "The run could not continue",
62
+ interrupted: "The host stopped while the model was answering",
63
+ restart_limit: "The run was cut off too many times",
64
+ nothing_to_answer: "Nothing to answer",
65
+ turn_limit: "The turn limit was reached",
66
+ };
67
+ /** Reconnect backoffs — the pi extension's ladder, minus the generations. */
68
+ const RECONNECT_BACKOFFS_MS = [1_000, 2_000, 5_000, 10_000, 30_000];
69
+ export class DurableBridge {
70
+ opts;
71
+ relay = null;
72
+ activePeers = new Map();
73
+ keypair = null;
74
+ conversation;
75
+ mirror = new DurableTranscriptMirror();
76
+ busy = false;
77
+ port;
78
+ control;
79
+ events = null;
80
+ rpcEnvelope = null;
81
+ myRoomId = null;
82
+ sessionStartedAt = Date.now();
83
+ reconnectTimer = null;
84
+ reconnectAttempt = 0;
85
+ stopped = false;
86
+ pairingRotationActive = false;
87
+ pairingRotationTimer = null;
88
+ /** The last minted rotating invite (null when paired/not rotating) —
89
+ * surfaced via currentPairingInvite() for host-side web pages. */
90
+ lastPairingInvite = null;
91
+ /** The served session's display name: the newest rename recorded in the
92
+ * conversation, else the host's displayName. */
93
+ sessionName;
94
+ agentConfig = null;
95
+ constructor(opts) {
96
+ this.opts = opts;
97
+ this.sessionName = opts.displayName;
98
+ }
99
+ /** The currently served conversation (stable id across restarts). */
100
+ get currentConversation() {
101
+ return this.conversation;
102
+ }
103
+ /** Live busy flag (run in flight) — the port's `streaming`. */
104
+ get isBusy() {
105
+ return this.busy;
106
+ }
107
+ /** Attach to a conversation: port + control + watch + event fanout. */
108
+ async attach(conversation) {
109
+ this.conversation = conversation;
110
+ if (this.opts.models !== undefined) {
111
+ try {
112
+ const set = await ensureThinkingLevel(conversation, this.opts.models, this.opts.thinkingLevel ?? DEFAULT_THINKING_LEVEL, this.opts.context);
113
+ if (set !== undefined)
114
+ this.opts.log(`thinking level: ${set}`);
115
+ }
116
+ catch (err) {
117
+ this.opts.log(`thinking level not set: ${err instanceof Error ? err.message : String(err)}`, "warning");
118
+ }
119
+ }
120
+ this.mirror = new DurableTranscriptMirror();
121
+ this.port = this.buildPort(conversation);
122
+ this.control = this.buildControl();
123
+ await this.events?.stop();
124
+ // The conversation view is the event + transcript source of truth: its
125
+ // first value (and the history before its window) seeds the mirror,
126
+ // the get_entries surface.
127
+ this.events = new ConversationViewSource({
128
+ conversation,
129
+ context: this.opts.context,
130
+ mirror: this.mirror,
131
+ onBusyChange: (busy) => {
132
+ this.busy = busy;
133
+ },
134
+ onRunFailure: (failure) => this.recordRunFailure(failure),
135
+ onEnd: (end) => {
136
+ this.opts.log(`conversation watch ended: ${end.kind}`, "warning");
137
+ },
138
+ });
139
+ await this.events.open();
140
+ this.sessionName = recordedSessionName(this.mirror) ?? this.opts.displayName;
141
+ this.agentConfig =
142
+ this.opts.models !== undefined
143
+ ? new DurableAgentConfig({
144
+ conversation: () => this.conversation,
145
+ harness: this.opts.harness,
146
+ models: this.opts.models,
147
+ context: this.opts.context,
148
+ inbox: () => this.events?.inbox,
149
+ rename: (name) => this.renameSession(name),
150
+ })
151
+ : null;
152
+ this.events.start();
153
+ this.rpcEnvelope?.dispose();
154
+ this.rpcEnvelope = createRpcEnvelope(this.events, (env) => this.broadcast(env));
155
+ // The conversation's session clock: a fresh attach (first start or a
156
+ // retarget to a NEW conversation) restamps; a restart re-attaching the
157
+ // SAME stable conversation keeps its id but the clock is per-process.
158
+ this.sessionStartedAt = Date.now();
159
+ }
160
+ /** Connect the relay + announce this conversation's room. */
161
+ async start() {
162
+ this.stopped = false;
163
+ this.keypair = this.keypair ?? (await getOrCreateEd25519Keypair());
164
+ await this.connectRelay();
165
+ }
166
+ /** Full teardown (host shutdown / retarget). */
167
+ async stop() {
168
+ this.stopped = true;
169
+ this.stopPairingRotation();
170
+ qrSession.clear();
171
+ if (this.reconnectTimer !== null) {
172
+ clearTimeout(this.reconnectTimer);
173
+ this.reconnectTimer = null;
174
+ }
175
+ this.rpcEnvelope?.dispose();
176
+ this.rpcEnvelope = null;
177
+ await this.events?.stop();
178
+ this.events = null;
179
+ for (const channel of this.activePeers.values()) {
180
+ try {
181
+ channel.detach();
182
+ }
183
+ catch {
184
+ /* best-effort */
185
+ }
186
+ }
187
+ this.activePeers.clear();
188
+ this.relay?.close();
189
+ this.relay = null;
190
+ this.myRoomId = null;
191
+ }
192
+ /**
193
+ * Session replacement WITHOUT replacement machinery: flush a
194
+ * session_shutdown to the old room, stop everything, attach to the new
195
+ * conversation, reconnect under its room. The app drops the old
196
+ * conversation on the new hello (changed sessionId), exactly as it does
197
+ * for pi.
198
+ */
199
+ async retarget(conversation) {
200
+ if (this.activePeers.size > 0) {
201
+ try {
202
+ await this.broadcastFlushed({ rpc: { type: "session_shutdown" } });
203
+ }
204
+ catch {
205
+ /* best-effort — the room dies with the disconnect anyway */
206
+ }
207
+ }
208
+ const relay = this.relay;
209
+ this.relay = null;
210
+ relay?.close();
211
+ for (const channel of this.activePeers.values()) {
212
+ try {
213
+ channel.detach();
214
+ }
215
+ catch {
216
+ /* best-effort */
217
+ }
218
+ }
219
+ this.activePeers.clear();
220
+ await this.attach(conversation);
221
+ if (!this.stopped)
222
+ await this.connectRelay();
223
+ }
224
+ // ── internals ────────────────────────────────────────────────────────────
225
+ buildPort(conversation) {
226
+ return new DurableSessionPort({
227
+ conversation,
228
+ context: this.opts.context,
229
+ mirror: this.mirror,
230
+ cwd: this.opts.cwd,
231
+ busy: () => this.busy,
232
+ appendAppEntry: (entry) => {
233
+ // Custom entries are committed best-effort; a failed commit logs,
234
+ // never throws into the caller (the port contract).
235
+ void this.conversation
236
+ .commit((tx) => tx.appendEntry(UnBienAppEntry, this.conversation.id, {
237
+ data: {
238
+ customType: entry.customType,
239
+ content: entry.content,
240
+ ...(entry.display !== undefined
241
+ ? { display: entry.display }
242
+ : {}),
243
+ ...(entry.details !== undefined
244
+ ? // SAFETY: the port types details `unknown` to stay
245
+ // harness-neutral; mesh/relay details payloads are JSON
246
+ // trees by wire contract.
247
+ { details: entry.details }
248
+ : {}),
249
+ },
250
+ }), this.opts.context)
251
+ .catch((err) => {
252
+ const detail = err instanceof Error ? err.message : String(err);
253
+ this.opts.log(`custom entry commit failed: ${detail}`, "warning");
254
+ });
255
+ },
256
+ log: (message, level) => this.opts.log(message, level),
257
+ subscribe: (handler) => this.events?.onAny(handler) ?? (() => undefined),
258
+ });
259
+ }
260
+ buildControl() {
261
+ return new DurableSessionControl({
262
+ createConversation: (context) => this.opts.harness.createConversation({ ownership: { kind: "ownerless" } }, context),
263
+ retarget: (conversation) => this.retarget(conversation),
264
+ current: () => this.conversation,
265
+ context: this.opts.context,
266
+ shutdownHost: this.opts.shutdownHost,
267
+ });
268
+ }
269
+ async connectRelay() {
270
+ if (!this.keypair)
271
+ return;
272
+ const roomId = roomIdForSession(String(this.conversation.id));
273
+ const relay = (this.opts.createRelay ?? defaultCreateRelay)(toWebSocketUrl(this.opts.relayUrl), this.keypair);
274
+ try {
275
+ await relay.connect({
276
+ roomId,
277
+ roomMeta: {
278
+ name: this.sessionName,
279
+ cwd: this.opts.cwd,
280
+ sessionId: String(this.conversation.id),
281
+ caps: sessionCapabilities(),
282
+ },
283
+ });
284
+ }
285
+ catch (err) {
286
+ relay.close();
287
+ this.scheduleReconnect();
288
+ const detail = err instanceof Error ? err.message : String(err);
289
+ this.opts.log(`relay connect failed: ${detail}`, "error");
290
+ return;
291
+ }
292
+ this.reconnectAttempt = 0;
293
+ this.relay = relay;
294
+ this.myRoomId = roomId;
295
+ relay.on("close", () => {
296
+ if (this.relay !== relay || this.stopped)
297
+ return;
298
+ this.relay = null;
299
+ this.scheduleReconnect();
300
+ });
301
+ this.pushPairingAllowList(relay);
302
+ this.installAutoListener(relay);
303
+ this.armPairingRotation();
304
+ this.opts.log(`[un-bien-durable] relay connected — room ${roomId} (conversation ${String(this.conversation.id)})`, "info");
305
+ }
306
+ scheduleReconnect() {
307
+ if (this.stopped)
308
+ return;
309
+ if (this.reconnectTimer !== null)
310
+ return;
311
+ const delay = RECONNECT_BACKOFFS_MS[Math.min(this.reconnectAttempt, RECONNECT_BACKOFFS_MS.length - 1)];
312
+ this.reconnectAttempt += 1;
313
+ this.reconnectTimer = setTimeout(() => {
314
+ this.reconnectTimer = null;
315
+ // Fire-and-forget (a timer callback can't await): connectRelay's own
316
+ // try covers relay.connect(), this catch covers anything else thrown
317
+ // on the path (the relay factory, room-id derivation) — escaping as an
318
+ // unhandled rejection would take the host down.
319
+ void this.connectRelay().catch((err) => {
320
+ const detail = err instanceof Error ? err.message : String(err);
321
+ this.opts.log(`relay reconnect failed: ${detail}`, "error");
322
+ });
323
+ }, delay);
324
+ }
325
+ /** Fail-closed signed allow-list push (the relay refuses rooms whose
326
+ * owners are not listed). Same single-signer path as the pi extension. */
327
+ pushPairingAllowList(relay) {
328
+ const kp = this.keypair;
329
+ if (!kp)
330
+ return;
331
+ void Promise.all([listPeers(), nextAllowListVersion()])
332
+ .then(([peers, version]) => {
333
+ const owners = pairingAllowList(peers);
334
+ const { blob, sig } = buildSignedAllowList(kp, owners, version);
335
+ relay.sendControl({ type: "pairing_set", blob, sig });
336
+ })
337
+ .catch((err) => {
338
+ const detail = err instanceof Error ? err.message : String(err);
339
+ this.opts.log(`pairing_set push failed: ${detail}`, "warning");
340
+ });
341
+ }
342
+ /**
343
+ * The auto-listener: pair_request from new devices, reconnect attach for
344
+ * known peers, unknown-peer refusal. The durable port of the pi
345
+ * extension's listener — no listener-authority generations (a stopped
346
+ * bridge is `this.relay !== relay`, checked directly).
347
+ */
348
+ installAutoListener(relay) {
349
+ const onMsg = async (line) => {
350
+ let outer;
351
+ try {
352
+ outer = JSON.parse(line);
353
+ }
354
+ catch {
355
+ return;
356
+ }
357
+ if (!outer.peer || !outer.ct)
358
+ return;
359
+ if (this.relay !== relay)
360
+ return;
361
+ const appPeerId = outer.peer;
362
+ let inner;
363
+ try {
364
+ const plaintext = Buffer.from(outer.ct, "base64").toString("utf8");
365
+ const parsed = JSON.parse(plaintext);
366
+ if (!parsed ||
367
+ typeof parsed !== "object" ||
368
+ typeof parsed.type !== "string")
369
+ return;
370
+ inner = parsed;
371
+ }
372
+ catch {
373
+ return;
374
+ }
375
+ if (inner.type === "pair_request") {
376
+ await this.handlePairRequest(relay, appPeerId, inner);
377
+ return;
378
+ }
379
+ if (this.activePeers.has(appPeerId))
380
+ return;
381
+ const known = await _findKnownPeer(appPeerId);
382
+ if (this.relay !== relay)
383
+ return;
384
+ if (known) {
385
+ const channel = this.attachOwner(relay, appPeerId, known.name);
386
+ // The channel listener missed the line that triggered the attach —
387
+ // route it explicitly, mirroring the channel's own dispatch.
388
+ const innerObj = inner;
389
+ if (isEnvelopeFrame(innerObj)) {
390
+ // SAFETY: isEnvelopeFrame confirmed rpc/evt/ub envelope keys are
391
+ // present, so this ClientMessage is byte-compatible with
392
+ // EnvelopeMessage.
393
+ const innerEnv = inner;
394
+ if (innerEnv.ub === undefined)
395
+ this.routeRpc(channel, innerEnv);
396
+ else
397
+ void this.routeUnBienPlane(channel, innerEnv);
398
+ }
399
+ else {
400
+ this.routeStock(channel, inner);
401
+ }
402
+ return;
403
+ }
404
+ this.sendInner(relay, appPeerId, {
405
+ type: "error",
406
+ code: "unknown_peer",
407
+ message: "Peer not paired — re-scan QR",
408
+ });
409
+ };
410
+ relay.on("message", onMsg);
411
+ }
412
+ async handlePairRequest(relay, appPeerId, inner) {
413
+ const sendError = (code, message) => this.sendInner(relay, appPeerId, {
414
+ type: "pair_error",
415
+ in_reply_to: inner.id,
416
+ code,
417
+ message,
418
+ });
419
+ const known = await _findKnownPeer(appPeerId);
420
+ if (known) {
421
+ // Idempotent re-confirm for an already-trusted owner (design
422
+ // 01M20G8SE): pair_ok without consuming a token.
423
+ const roomId = this.myRoomId;
424
+ if (!roomId) {
425
+ sendError("internal_error", "No session room yet — retry pairing once this host has started.");
426
+ return;
427
+ }
428
+ if (!this.activePeers.has(appPeerId))
429
+ this.attachOwner(relay, appPeerId, known.name);
430
+ this.emitPairOk(relay, appPeerId, inner, roomId);
431
+ return;
432
+ }
433
+ const status = qrSession.consumeToken(inner.token);
434
+ if (status !== "ok") {
435
+ const code = status === "expired"
436
+ ? "token_expired"
437
+ : status === "consumed"
438
+ ? "token_consumed"
439
+ : "token_unknown";
440
+ sendError(code, code === "token_expired"
441
+ ? "Ephemeral token expired. Generate a new QR from a paired session."
442
+ : code === "token_consumed"
443
+ ? "Token already consumed by another pair_request."
444
+ : "Token was not issued by this host.");
445
+ return;
446
+ }
447
+ const roomId = this.myRoomId;
448
+ if (!roomId) {
449
+ sendError("internal_error", "No session room yet — retry pairing once this host has started.");
450
+ return;
451
+ }
452
+ try {
453
+ await addPeer({
454
+ name: inner.device_name,
455
+ remote_epk: appPeerId,
456
+ paired_at: new Date().toISOString(),
457
+ });
458
+ }
459
+ catch (err) {
460
+ const detail = err instanceof Error ? err.message : String(err);
461
+ sendError("internal_error", `Failed to persist pairing: ${detail}`);
462
+ return;
463
+ }
464
+ if (this.relay !== relay)
465
+ return;
466
+ this.stopPairingRotation();
467
+ this.opts.log(`[un-bien-durable] paired with ${inner.device_name} — pairing QR rotation stopped`, "info");
468
+ this.pushPairingAllowList(relay);
469
+ if (!this.activePeers.has(appPeerId))
470
+ this.attachOwner(relay, appPeerId, inner.device_name);
471
+ this.emitPairOk(relay, appPeerId, inner, roomId);
472
+ }
473
+ // ── Pairing QR rotation ─────────────────────────────────────────────────────
474
+ /**
475
+ * The startup pairing invite: while the machine has no paired peers, the
476
+ * bridge ITSELF mints the rotating QR on the same qrSession the
477
+ * pair_request handler consumes from — one process issues AND validates
478
+ * the token, so a scan can never hit "token was not issued by this host"
479
+ * (the failure mode of minting in a separate CLI process). Armed on every
480
+ * successful relay connect (startup + reconnects); the first tick reads
481
+ * peers.json and retires silently when a peer already exists, so a
482
+ * revoked-then-restarted host re-arms on its next start().
483
+ */
484
+ armPairingRotation() {
485
+ if (this.pairingRotationActive || this.stopped)
486
+ return;
487
+ // Suppressed by the host (--no-pairing-qr / UNBIEN_NO_PAIRING_QR): the
488
+ // auto-minted invite mints with no user gesture, which headless hosts
489
+ // don't want — pairing happens via the un-bien CLI instead.
490
+ if (this.opts.pairingQr === false)
491
+ return;
492
+ this.pairingRotationActive = true;
493
+ this.tickPairingRotation();
494
+ }
495
+ /** Fire-and-forget one rotation step: a rejection (listPeers on a wedged
496
+ * broker socket, …) logs + retires the rotation instead of escaping as
497
+ * an unhandled rejection — the rotation re-arms on the next successful
498
+ * relay connect. */
499
+ tickPairingRotation() {
500
+ void this.rotatePairingQR().catch((err) => {
501
+ const detail = err instanceof Error ? err.message : String(err);
502
+ this.opts.log(`pairing QR rotation failed: ${detail}`, "warning");
503
+ this.stopPairingRotation();
504
+ });
505
+ }
506
+ /** One rotation step: retire when peers exist, otherwise mint + log a
507
+ * fresh invite and schedule the next tick. */
508
+ async rotatePairingQR() {
509
+ if (this.stopped || !this.pairingRotationActive)
510
+ return;
511
+ // A peer landing in peers.json by any path (this bridge's handshake, a
512
+ // CLI pair-token --wait, a copied file) retires the invite.
513
+ if ((await listPeers()).length > 0) {
514
+ this.stopPairingRotation();
515
+ return;
516
+ }
517
+ const kp = this.keypair;
518
+ if (!kp) {
519
+ this.stopPairingRotation();
520
+ return;
521
+ }
522
+ // The QR's rm/n must match what emitPairOk answers with: this
523
+ // conversation's room and the configured display name. Re-derived per
524
+ // tick so a retarget mid-rotation re-keys the invite to the new room.
525
+ const roomId = roomIdForSession(String(this.conversation.id));
526
+ const { token, expiresAt } = qrSession.issueToken();
527
+ const uri = buildQRUri(token, kp.publicKey, this.opts.displayName, roomId);
528
+ // Surfaced to host-side web pages (GET /pair) via currentPairingInvite().
529
+ this.lastPairingInvite = uri;
530
+ this.opts.log(`[un-bien-durable] machine unpaired — scan this QR with the un-bien app:\n\n` +
531
+ `${renderQRAscii(uri)}\n` +
532
+ `Or paste this URI into the app's pair flow:\n\n${uri}\n\n` +
533
+ `The invite renews at ${new Date(expiresAt).toLocaleTimeString()} — rotation continues until a device pairs.`, "info");
534
+ this.pairingRotationTimer = setTimeout(() => {
535
+ this.pairingRotationTimer = null;
536
+ this.tickPairingRotation();
537
+ }, this.opts.pairingRotationMs ?? TOKEN_TTL_MS);
538
+ }
539
+ /** Cancel the rotation (a peer paired / teardown). No qrSession.clear()
540
+ * here: the outstanding token either was just consumed by the pairing
541
+ * handshake or dies with the process — consumption is same-process by
542
+ * design. */
543
+ stopPairingRotation() {
544
+ this.pairingRotationActive = false;
545
+ this.lastPairingInvite = null;
546
+ if (this.pairingRotationTimer !== null) {
547
+ clearTimeout(this.pairingRotationTimer);
548
+ this.pairingRotationTimer = null;
549
+ }
550
+ }
551
+ /** The CURRENT pairing invite as a deep-link URI — what a /pair web page
552
+ * renders as a tappable link. null when not rotating (paired already, or
553
+ * rotation stopped). Updated on every rotation tick. */
554
+ currentPairingInvite() {
555
+ return this.pairingRotationActive ? (this.lastPairingInvite ?? null) : null;
556
+ }
557
+ /** Ask attached app peers to authorize: ONE live toast
558
+ * (extension_ui_request notify) is the visible channel. An authorize
559
+ * pending expires in minutes, so nothing replays it; a late peer gets a
560
+ * fresh mint when the model calls the tool again. The request is also
561
+ * recorded as a hidden app entry (customType "un-bien:auth-required",
562
+ * display:false), kept for the record and out of model context.
563
+ *
564
+ * `message` is the elicit text and names the real upstream server;
565
+ * `server` is the caller's label (the combiner bridge, say), kept in
566
+ * details. `url` is appended unless the message already carries it, and
567
+ * may be empty for a prompt with nothing to open. */
568
+ async sendAuthorizeCard(server, url, message) {
569
+ const content = url !== "" && !message.includes(url) ? `${message}\n\n${url}` : message;
570
+ await this.conversation
571
+ .commit((tx) => tx.appendEntry(UnBienAppEntry, this.conversation.id, {
572
+ data: {
573
+ customType: "un-bien:auth-required",
574
+ content,
575
+ display: false,
576
+ details: { server, url, message, kind: "authorize" },
577
+ },
578
+ }), this.opts.context)
579
+ .catch((err) => {
580
+ this.opts.log(`authorize card commit failed: ${err instanceof Error ? err.message : String(err)}`, "warning");
581
+ });
582
+ await this.broadcastFlushed({
583
+ rpc: {
584
+ type: "extension_ui_request",
585
+ id: `auth-${Date.now()}`,
586
+ method: "notify",
587
+ message: content,
588
+ notify_type: "warning",
589
+ },
590
+ });
591
+ }
592
+ /** Rename the served session: record it in the conversation (a hidden app
593
+ * entry, read back at attach), then confirm it live as pi does — the
594
+ * session_info evt to attached apps and room_meta_update so the relay
595
+ * pushes the name to room subscribers. */
596
+ async renameSession(name) {
597
+ await this.conversation.commit((tx) => tx.appendEntry(UnBienAppEntry, this.conversation.id, {
598
+ data: { customType: SESSION_NAME_CUSTOM_TYPE, content: name, display: false },
599
+ }), this.opts.context);
600
+ this.sessionName = name;
601
+ this.broadcast({ evt: { channel: "session_info", data: { name } } });
602
+ if (this.relay && this.myRoomId) {
603
+ this.relay.sendControl({
604
+ type: "room_meta_update",
605
+ room_id: this.myRoomId,
606
+ meta: { name },
607
+ });
608
+ }
609
+ }
610
+ /** Record a run that ended without an answer as a display app entry, so
611
+ * the app shows why (live, and on replay). `nothing_to_answer` (a
612
+ * continue with nothing pending) is not a failure anyone needs to see. */
613
+ recordRunFailure(failure) {
614
+ if (failure.reason === "nothing_to_answer")
615
+ return;
616
+ const why = RUN_FAILURE_TEXT[failure.reason] ?? failure.reason;
617
+ const content = failure.detail !== undefined ? `${why}: ${failure.detail}` : why;
618
+ void this.conversation
619
+ .commit((tx) => tx.appendEntry(UnBienAppEntry, this.conversation.id, {
620
+ data: {
621
+ customType: RUN_FAILURE_CUSTOM_TYPE,
622
+ content,
623
+ display: true,
624
+ details: {
625
+ reason: failure.reason,
626
+ ...(failure.detail !== undefined
627
+ ? { detail: failure.detail }
628
+ : {}),
629
+ },
630
+ },
631
+ }), this.opts.context)
632
+ .catch((err) => {
633
+ this.opts.log(`run failure entry commit failed: ${err instanceof Error ? err.message : String(err)}`, "warning");
634
+ });
635
+ }
636
+ /** Mint a FRESH pairing invite on demand — for host-side pairing pages
637
+ * (/pair) so a SECOND device can be added after the rotation retired, or
638
+ * an operator can open pairing mode without a restart. One process mints
639
+ * + consumes (this bridge), so the token race is structurally excluded.
640
+ * Multi-device safe: adding a peer appends to peers.json; the page's
641
+ * caller gates availability (enable/disable toggle). */
642
+ forcePairingInvite() {
643
+ const kp = this.keypair;
644
+ if (!kp || this.stopped)
645
+ return null;
646
+ const roomId = roomIdForSession(String(this.conversation.id));
647
+ const { token } = qrSession.issueToken();
648
+ const uri = buildQRUri(token, kp.publicKey, this.opts.displayName, roomId);
649
+ this.lastPairingInvite = uri;
650
+ return uri;
651
+ }
652
+ emitPairOk(relay, appPeerId, inner, roomId) {
653
+ this.sendInner(relay, appPeerId, {
654
+ type: "pair_ok",
655
+ in_reply_to: inner.id,
656
+ session_name: this.sessionName,
657
+ session_started_at: this.sessionStartedAt,
658
+ room_id: roomId,
659
+ harness: { name: "pi-durable host", version: "0.1.0" },
660
+ hostname: this.opts.machineName ?? hostname(),
661
+ protocol_version: 1,
662
+ capabilities: sessionCapabilities(),
663
+ });
664
+ }
665
+ /** un-plane error reply, correlated to the request id when present.
666
+ * Same best-effort wire as sendEnvelope — a relay hiccup mid-reply must
667
+ * not throw into the fire-and-forget ub dispatch. */
668
+ sendUbError(sender, inReplyTo, message) {
669
+ try {
670
+ sender.sendEnvelope({
671
+ type: "ub",
672
+ ub: {
673
+ type: "error",
674
+ ...(inReplyTo !== undefined ? { in_reply_to: inReplyTo } : {}),
675
+ code: "internal_error",
676
+ message,
677
+ },
678
+ });
679
+ }
680
+ catch {
681
+ /* best-effort */
682
+ }
683
+ }
684
+ sendInner(relay, appPeerId, msg) {
685
+ try {
686
+ const ct = Buffer.from(JSON.stringify(msg)).toString("base64");
687
+ relay.send(JSON.stringify({ peer: appPeerId, ct }));
688
+ }
689
+ catch {
690
+ /* best-effort */
691
+ }
692
+ }
693
+ /** Attach (or re-attach) an owner channel and say hello. */
694
+ attachOwner(relay, appPeerId, _peerName) {
695
+ const existing = this.activePeers.get(appPeerId);
696
+ if (existing) {
697
+ try {
698
+ existing.detach();
699
+ }
700
+ catch {
701
+ /* best-effort */
702
+ }
703
+ this.activePeers.delete(appPeerId);
704
+ }
705
+ const channel = new PlainPeerChannel(relay, appPeerId, this.myRoomId ?? undefined, (msg) => this.routeStock(channel, msg), () => {
706
+ this.activePeers.delete(appPeerId);
707
+ }, (env) => {
708
+ if (env.ub === undefined)
709
+ this.routeRpc(channel, env);
710
+ else
711
+ void this.routeUnBienPlane(channel, env);
712
+ }, () => this.port.sessionId ?? undefined);
713
+ this.activePeers.set(appPeerId, channel);
714
+ channel.sendEnvelope(helloEnvelope(sessionCapabilities(), this.port.sessionId ?? undefined));
715
+ return channel;
716
+ }
717
+ // ── Inbound routing ──────────────────────────────────────────────────────
718
+ buildRpcDeps() {
719
+ const port = this.port;
720
+ return {
721
+ agent: this.agentConfig,
722
+ session: port,
723
+ sessionControl: this.control,
724
+ rootState: () => ({ turnId: null }),
725
+ imageDeps: {
726
+ session: port,
727
+ rootState: () => ({ turnId: null }),
728
+ myRoomMeta: null,
729
+ wakeAgent: (content, _label, deliverAs) => port.submit(content, { deliverAs }),
730
+ },
731
+ wakeAgent: (content, _label, deliverAs) => port.submit(content, { deliverAs }),
732
+ abortCurrentTurn: () => port.abort(),
733
+ };
734
+ }
735
+ /** Envelope-carried pi rpc command dispatch (the shared handlers). */
736
+ routeRpc(sender, env) {
737
+ const frame = env.rpc;
738
+ if (!frame || typeof frame !== "object")
739
+ return;
740
+ const handlers = createRpcHandlers(this.buildRpcDeps(), sender);
741
+ void dispatchRpcCommand(frame, handlers)
742
+ .then((resp) => {
743
+ if (resp)
744
+ sender.sendEnvelope(resp);
745
+ })
746
+ .catch((err) => {
747
+ const detail = err instanceof Error ? err.message : String(err);
748
+ this.opts.log(`rpc inbound dispatch failed: ${detail}`, "warning");
749
+ });
750
+ }
751
+ /** un-bien plane: session_sync (minimal ack) + session_fork/
752
+ * session_navigate + terminate. */
753
+ async routeUnBienPlane(sender, env) {
754
+ const frame = env.ub;
755
+ if (!frame || typeof frame !== "object")
756
+ return;
757
+ const type = frame.type;
758
+ if (type === "session_sync") {
759
+ const f = frame;
760
+ // The durable host has no panel/ask surfaces; the transcript arrives
761
+ // through get_entries. Answer with the terminator so the app's open +
762
+ // reconnect cycle completes.
763
+ sender.sendEnvelope({
764
+ ub: {
765
+ type: "session_sync_end",
766
+ ...(typeof f.id === "string" ? { in_reply_to: f.id } : {}),
767
+ session_started_at: this.sessionStartedAt,
768
+ session_name: this.sessionName,
769
+ },
770
+ });
771
+ return;
772
+ }
773
+ if (type === "session_fork" || type === "session_navigate") {
774
+ // app->host: fork a NEW conversation / branch in place from a transcript
775
+ // entry. The durable analog of the pi extension's routing.ts handlers:
776
+ // the DurableSessionControl implements both verbs directly (no command
777
+ // ctx to self-dispatch through — durable tools ARE the surface).
778
+ const f = frame;
779
+ const entryId = typeof f.entry_id === "string" ? f.entry_id : "";
780
+ const reqId = typeof f.id === "string" ? f.id : undefined;
781
+ const replyError = (message) => {
782
+ this.sendUbError(sender, reqId, message);
783
+ };
784
+ if (!entryId) {
785
+ this.opts.log(`${String(type)}(ub): missing entry_id — ignored`, "warning");
786
+ replyError("session_fork/session_navigate requires an entry_id");
787
+ return;
788
+ }
789
+ const control = this.control;
790
+ if (!control) {
791
+ // No conversation attached (the bridge never attach()ed) — the app
792
+ // must learn the verb didn't land instead of waiting for a tile that
793
+ // never appears.
794
+ const message = `No conversation attached — ${String(type)} unavailable`;
795
+ this.opts.log(`${String(type)}(ub): ${message}`, "warning");
796
+ replyError(message);
797
+ return;
798
+ }
799
+ if (type === "session_fork") {
800
+ const position = f.position === "at" || f.position === "before" ? f.position : undefined;
801
+ try {
802
+ // The request id threads through so the retargeted bridge's first
803
+ // session_sync can echo forked_from_req (the app's auto-nav).
804
+ await control.fork(entryId, {
805
+ ...(position ? { position } : {}),
806
+ ...(reqId ? { requestId: reqId } : {}),
807
+ });
808
+ }
809
+ catch (err) {
810
+ const detail = err instanceof Error ? err.message : String(err);
811
+ this.opts.log(`session_fork(ub) error: ${detail}`, "warning");
812
+ replyError(`session_fork failed: ${detail}`);
813
+ }
814
+ return;
815
+ }
816
+ try {
817
+ await control.navigateTree(entryId);
818
+ }
819
+ catch (err) {
820
+ const detail = err instanceof Error ? err.message : String(err);
821
+ this.opts.log(`session_navigate(ub) error: ${detail}`, "warning");
822
+ replyError(`session_navigate failed: ${detail}`);
823
+ }
824
+ return;
825
+ }
826
+ if (type === "terminate") {
827
+ // DELIBERATE ASYMMETRY vs the pi binding, documented here: on pi,
828
+ // terminate kills ONE session — a TUI the user may be locally typing
829
+ // into — so the gate is a per-session concern with a per-session
830
+ // config file. The durable host is a headless box with exactly ONE
831
+ // conversation and no local UI — the app IS the UI, so remote
832
+ // terminate is the machine's off switch and is served by design
833
+ // (shutdownHost: bridge stop → mesh leave → harness close → exit).
834
+ // What is NOT asymmetric: the advertised capability and the honored
835
+ // behavior must read the same config — sessionCapabilities() already
836
+ // gates the `remote_terminate` cap on this same check, so a host that
837
+ // advertises the cap honors it, and a host that opted out never shows
838
+ // the red trash in the first place. Same check, same layering
839
+ // (UNBIEN_DIRECT_CONFIG env wins, then the cwd's config.json, then the
840
+ // global defaults) — the headless box pins it via env.
841
+ if (!effectiveAllowRemoteTerminate(loadLocalConfig(process.cwd()))) {
842
+ this.opts.log("terminate(ub): remote terminate disabled on this machine — refused", "warning");
843
+ return;
844
+ }
845
+ this.opts.log("terminate(ub): graceful host shutdown requested", "warning");
846
+ // routeUnBienPlane runs fire-and-forget from the channel loop — a
847
+ // failed shutdown must log here, not escape as an unhandled rejection.
848
+ try {
849
+ await this.opts.shutdownHost();
850
+ }
851
+ catch (err) {
852
+ const detail = err instanceof Error ? err.message : String(err);
853
+ this.opts.log(`terminate(ub) shutdown failed: ${detail}`, "error");
854
+ }
855
+ return;
856
+ }
857
+ }
858
+ /** Stock ClientMessage switch: ping/pong + cancel. */
859
+ routeStock(sender, msg) {
860
+ if (msg.type === "ping") {
861
+ sender.send({ type: "pong", in_reply_to: msg.id });
862
+ return;
863
+ }
864
+ if (msg.type === "cancel") {
865
+ try {
866
+ const aborted = this.port.abort();
867
+ if (!aborted) {
868
+ sender.send({
869
+ type: "error",
870
+ code: "internal_error",
871
+ in_reply_to: msg.id,
872
+ message: "No active run to abort",
873
+ });
874
+ }
875
+ }
876
+ catch (err) {
877
+ const detail = err instanceof Error ? err.message : String(err);
878
+ sender.send({
879
+ type: "error",
880
+ code: "internal_error",
881
+ in_reply_to: msg.id,
882
+ message: `Abort failed: ${detail}`,
883
+ });
884
+ }
885
+ return;
886
+ }
887
+ if (msg.type === "pair_request")
888
+ return; // idempotent re-confirm; handled by the listener
889
+ }
890
+ // ── Outbound fanout ─────────────────────────────────────────────────────
891
+ broadcast(env) {
892
+ for (const channel of this.activePeers.values()) {
893
+ try {
894
+ channel.sendEnvelope(env);
895
+ }
896
+ catch {
897
+ /* best-effort per channel */
898
+ }
899
+ }
900
+ }
901
+ async broadcastFlushed(env, timeoutMs = 1_000) {
902
+ let timer;
903
+ try {
904
+ await Promise.race([
905
+ Promise.all([...this.activePeers.values()].map((ch) => ch.sendEnvelopeFlushed(env))),
906
+ new Promise((resolve) => {
907
+ timer = setTimeout(resolve, timeoutMs);
908
+ }),
909
+ ]);
910
+ }
911
+ finally {
912
+ if (timer)
913
+ clearTimeout(timer);
914
+ }
915
+ }
916
+ }
917
+ // The production relay factory (tests inject their own via opts.createRelay).
918
+ function defaultCreateRelay(wsUrl, keypair) {
919
+ return new RelayClient(wsUrl, keypair);
920
+ }
921
+ //# sourceMappingURL=bridge.js.map