@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.
- package/LICENSE +21 -0
- package/README.md +52 -2
- package/dist/agent_config.d.ts +56 -0
- package/dist/agent_config.js +92 -0
- package/dist/agent_config.js.map +1 -0
- package/dist/bridge.d.ts +175 -0
- package/dist/bridge.js +921 -0
- package/dist/bridge.js.map +1 -0
- package/dist/event_source.d.ts +53 -0
- package/dist/event_source.js +124 -0
- package/dist/event_source.js.map +1 -0
- package/dist/extension.d.ts +34 -0
- package/dist/extension.js +46 -0
- package/dist/extension.js.map +1 -0
- package/dist/host.d.ts +2 -0
- package/dist/host.js +261 -0
- package/dist/host.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +18 -0
- package/dist/index.js.map +1 -0
- package/dist/launch_wait_quit.d.ts +12 -0
- package/dist/launch_wait_quit.js +136 -0
- package/dist/launch_wait_quit.js.map +1 -0
- package/dist/mesh_delivery.d.ts +28 -0
- package/dist/mesh_delivery.js +31 -0
- package/dist/mesh_delivery.js.map +1 -0
- package/dist/mirror.d.ts +56 -0
- package/dist/mirror.js +136 -0
- package/dist/mirror.js.map +1 -0
- package/dist/ollama_cloud.d.ts +2 -0
- package/dist/ollama_cloud.js +98 -0
- package/dist/ollama_cloud.js.map +1 -0
- package/dist/pi_launch.d.ts +50 -0
- package/dist/pi_launch.js +84 -0
- package/dist/pi_launch.js.map +1 -0
- package/dist/port.d.ts +94 -0
- package/dist/port.js +121 -0
- package/dist/port.js.map +1 -0
- package/dist/registrar.d.ts +7 -0
- package/dist/registrar.js +31 -0
- package/dist/registrar.js.map +1 -0
- package/dist/thinking.d.ts +24 -0
- package/dist/thinking.js +36 -0
- package/dist/thinking.js.map +1 -0
- package/dist/view_translator.d.ts +83 -0
- package/dist/view_translator.js +317 -0
- package/dist/view_translator.js.map +1 -0
- 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
|