pubblue 0.6.1 → 0.6.8
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/dist/chunk-AZQD654L.js +1489 -0
- package/dist/index.js +201 -204
- package/dist/live-daemon-entry.js +778 -0
- package/package.json +4 -4
- package/dist/chunk-BBJOOZHS.js +0 -676
- package/dist/chunk-WXNNDR4T.js +0 -1313
- package/dist/tunnel-daemon-BR5XKNEA.js +0 -7
- package/dist/tunnel-daemon-entry.js +0 -27
- /package/dist/{tunnel-daemon-entry.d.ts → live-daemon-entry.d.ts} +0 -0
|
@@ -0,0 +1,778 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CHANNELS,
|
|
3
|
+
CONTROL_CHANNEL,
|
|
4
|
+
PubApiClient,
|
|
5
|
+
PubApiError,
|
|
6
|
+
createClaudeCodeBridgeRunner,
|
|
7
|
+
createOpenClawBridgeRunner,
|
|
8
|
+
decodeMessage,
|
|
9
|
+
encodeMessage,
|
|
10
|
+
errorMessage,
|
|
11
|
+
latestCliVersionPath,
|
|
12
|
+
makeAckMessage,
|
|
13
|
+
parseAckMessage,
|
|
14
|
+
readLatestCliVersion,
|
|
15
|
+
shouldAcknowledgeMessage
|
|
16
|
+
} from "./chunk-AZQD654L.js";
|
|
17
|
+
|
|
18
|
+
// src/lib/live-daemon.ts
|
|
19
|
+
import * as fs from "fs";
|
|
20
|
+
import * as net from "net";
|
|
21
|
+
import * as path from "path";
|
|
22
|
+
|
|
23
|
+
// ../shared/ack-routing-core.ts
|
|
24
|
+
function resolveAckChannel(input) {
|
|
25
|
+
if (input.messageChannelOpen) return input.messageChannel;
|
|
26
|
+
if (input.controlChannelOpen) return CONTROL_CHANNEL;
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// src/lib/live-daemon-answer.ts
|
|
31
|
+
function createAnswer(peer, browserOffer, timeoutMs) {
|
|
32
|
+
return new Promise((resolve, reject) => {
|
|
33
|
+
let resolved = false;
|
|
34
|
+
const done = (sdp, type) => {
|
|
35
|
+
if (resolved) return;
|
|
36
|
+
resolved = true;
|
|
37
|
+
clearTimeout(timeout);
|
|
38
|
+
resolve(JSON.stringify({ sdp, type }));
|
|
39
|
+
};
|
|
40
|
+
const offer = JSON.parse(browserOffer);
|
|
41
|
+
peer.setRemoteDescription(offer.sdp, offer.type);
|
|
42
|
+
peer.onLocalDescription((sdp, type) => {
|
|
43
|
+
done(sdp, type);
|
|
44
|
+
});
|
|
45
|
+
peer.onGatheringStateChange((state) => {
|
|
46
|
+
if (state === "complete" && !resolved) {
|
|
47
|
+
const desc = peer.localDescription();
|
|
48
|
+
if (desc) done(desc.sdp, desc.type);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
const timeout = setTimeout(() => {
|
|
52
|
+
if (resolved) return;
|
|
53
|
+
const desc = peer.localDescription();
|
|
54
|
+
if (desc) {
|
|
55
|
+
done(desc.sdp, desc.type);
|
|
56
|
+
} else {
|
|
57
|
+
resolved = true;
|
|
58
|
+
reject(new Error(`Timed out after ${timeoutMs}ms`));
|
|
59
|
+
}
|
|
60
|
+
}, timeoutMs);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// src/lib/live-daemon-shared.ts
|
|
65
|
+
function buildBridgeInstructions(mode) {
|
|
66
|
+
if (mode === "claude-code") {
|
|
67
|
+
return {
|
|
68
|
+
replyHint: 'Reply by running: pubblue write "<your reply>"',
|
|
69
|
+
canvasHint: "Canvas update: pubblue write -c canvas -f /path/to/file.html",
|
|
70
|
+
systemPrompt: [
|
|
71
|
+
"You are in a live P2P session with a user.",
|
|
72
|
+
"The canvas is an iframe visible to the user alongside the chat.",
|
|
73
|
+
"Always `use pubblue write` for all communication with the user."
|
|
74
|
+
].join("\n")
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
replyHint: 'Reply by running: write "<your reply>"',
|
|
79
|
+
canvasHint: "Canvas update: write -c canvas -f /path/to/file.html",
|
|
80
|
+
systemPrompt: null
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
var OFFER_TIMEOUT_MS = 1e4;
|
|
84
|
+
var SIGNAL_POLL_WAITING_MS = 5e3;
|
|
85
|
+
var SIGNAL_POLL_CONNECTED_MS = 15e3;
|
|
86
|
+
var LOCAL_CANDIDATE_FLUSH_MS = 2e3;
|
|
87
|
+
var WRITE_ACK_TIMEOUT_MS = 5e3;
|
|
88
|
+
var NOT_CONNECTED_WRITE_ERROR = "No browser connected. Ask the user to open the pub URL first, then retry.";
|
|
89
|
+
function getLiveWriteReadinessError(isConnected) {
|
|
90
|
+
return isConnected ? null : NOT_CONNECTED_WRITE_ERROR;
|
|
91
|
+
}
|
|
92
|
+
function shouldRecoverForBrowserOfferChange(params) {
|
|
93
|
+
const { incomingBrowserOffer, lastAppliedBrowserOffer } = params;
|
|
94
|
+
if (!incomingBrowserOffer) return false;
|
|
95
|
+
if (!lastAppliedBrowserOffer) return false;
|
|
96
|
+
return incomingBrowserOffer !== lastAppliedBrowserOffer;
|
|
97
|
+
}
|
|
98
|
+
var MAX_CANVAS_PERSIST_SIZE = 100 * 1024;
|
|
99
|
+
function getStickyCanvasHtml(stickyOutbound, canvasChannel) {
|
|
100
|
+
const msg = stickyOutbound.get(canvasChannel);
|
|
101
|
+
if (!msg) return null;
|
|
102
|
+
if (msg.type !== "html") return null;
|
|
103
|
+
const html = msg.data;
|
|
104
|
+
if (!html) return null;
|
|
105
|
+
if (new TextEncoder().encode(html).byteLength > MAX_CANVAS_PERSIST_SIZE) return null;
|
|
106
|
+
return html;
|
|
107
|
+
}
|
|
108
|
+
function getSignalPollDelayMs(params) {
|
|
109
|
+
const baseDelay = params.hasActiveConnection ? SIGNAL_POLL_CONNECTED_MS : SIGNAL_POLL_WAITING_MS;
|
|
110
|
+
if (params.retryAfterSeconds === void 0) return baseDelay;
|
|
111
|
+
if (!Number.isFinite(params.retryAfterSeconds) || params.retryAfterSeconds <= 0) {
|
|
112
|
+
return baseDelay;
|
|
113
|
+
}
|
|
114
|
+
return Math.max(baseDelay, Math.ceil(params.retryAfterSeconds * 1e3));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// src/lib/live-daemon.ts
|
|
118
|
+
var HEARTBEAT_INTERVAL_MS = 3e4;
|
|
119
|
+
var HEALTH_CHECK_INTERVAL_MS = 60 * 60 * 1e3;
|
|
120
|
+
var PERSIST_TIMEOUT_MS = 3e3;
|
|
121
|
+
async function startDaemon(config) {
|
|
122
|
+
const { apiClient: apiClient2, socketPath: socketPath2, infoPath: infoPath2, cliVersion: cliVersion2, agentName: agentName2 } = config;
|
|
123
|
+
const ndc = await import("node-datachannel");
|
|
124
|
+
const buffer = { messages: [] };
|
|
125
|
+
const startTime = Date.now();
|
|
126
|
+
let stopped = false;
|
|
127
|
+
let connected = false;
|
|
128
|
+
let recovering = false;
|
|
129
|
+
let activeSlug = null;
|
|
130
|
+
let lastAppliedBrowserOffer = null;
|
|
131
|
+
let lastBrowserCandidateCount = 0;
|
|
132
|
+
let lastSentCandidateCount = 0;
|
|
133
|
+
const localCandidates = [];
|
|
134
|
+
const stickyOutboundByChannel = /* @__PURE__ */ new Map();
|
|
135
|
+
const pendingOutboundAcks = /* @__PURE__ */ new Map();
|
|
136
|
+
const pendingDeliveryAcks = /* @__PURE__ */ new Map();
|
|
137
|
+
let peer = null;
|
|
138
|
+
let channels = /* @__PURE__ */ new Map();
|
|
139
|
+
let pendingInboundBinaryMeta = /* @__PURE__ */ new Map();
|
|
140
|
+
let pollingTimer = null;
|
|
141
|
+
let heartbeatTimer = null;
|
|
142
|
+
let localCandidateInterval = null;
|
|
143
|
+
let localCandidateStopTimer = null;
|
|
144
|
+
let healthCheckTimer = null;
|
|
145
|
+
let lastError = null;
|
|
146
|
+
const debugEnabled = process.env.PUBBLUE_LIVE_DEBUG === "1";
|
|
147
|
+
const versionFilePath = latestCliVersionPath();
|
|
148
|
+
let bridgeRunner = null;
|
|
149
|
+
function debugLog(message, error) {
|
|
150
|
+
if (!debugEnabled) return;
|
|
151
|
+
const detail = error === void 0 ? "" : ` | ${error instanceof Error ? `${error.name}: ${error.message}` : typeof error === "string" ? error : JSON.stringify(error)}`;
|
|
152
|
+
console.error(`[pubblue-agent] ${message}${detail}`);
|
|
153
|
+
}
|
|
154
|
+
function markError(message, error) {
|
|
155
|
+
lastError = error === void 0 ? message : `${message}: ${errorMessage(error)}`;
|
|
156
|
+
debugLog(message, error);
|
|
157
|
+
}
|
|
158
|
+
function clearPollingTimer() {
|
|
159
|
+
if (pollingTimer) {
|
|
160
|
+
clearTimeout(pollingTimer);
|
|
161
|
+
pollingTimer = null;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
function clearLocalCandidateTimers() {
|
|
165
|
+
if (localCandidateInterval) {
|
|
166
|
+
clearInterval(localCandidateInterval);
|
|
167
|
+
localCandidateInterval = null;
|
|
168
|
+
}
|
|
169
|
+
if (localCandidateStopTimer) {
|
|
170
|
+
clearTimeout(localCandidateStopTimer);
|
|
171
|
+
localCandidateStopTimer = null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
function clearHealthCheckTimer() {
|
|
175
|
+
if (healthCheckTimer) {
|
|
176
|
+
clearInterval(healthCheckTimer);
|
|
177
|
+
healthCheckTimer = null;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
function clearHeartbeatTimer() {
|
|
181
|
+
if (heartbeatTimer) {
|
|
182
|
+
clearInterval(heartbeatTimer);
|
|
183
|
+
heartbeatTimer = null;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
function runHealthCheck() {
|
|
187
|
+
if (stopped) return;
|
|
188
|
+
if (cliVersion2) {
|
|
189
|
+
const latest = readLatestCliVersion(versionFilePath);
|
|
190
|
+
if (latest && latest !== cliVersion2) {
|
|
191
|
+
markError(`detected CLI upgrade (${cliVersion2} \u2192 ${latest}); shutting down`);
|
|
192
|
+
void shutdown();
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
function startHealthCheckTimer() {
|
|
197
|
+
clearHealthCheckTimer();
|
|
198
|
+
healthCheckTimer = setInterval(runHealthCheck, HEALTH_CHECK_INTERVAL_MS);
|
|
199
|
+
runHealthCheck();
|
|
200
|
+
}
|
|
201
|
+
function setupChannel(name, dc) {
|
|
202
|
+
channels.set(name, dc);
|
|
203
|
+
dc.onOpen(() => {
|
|
204
|
+
if (name === CONTROL_CHANNEL) flushQueuedAcks();
|
|
205
|
+
});
|
|
206
|
+
dc.onMessage((data) => {
|
|
207
|
+
if (typeof data === "string") {
|
|
208
|
+
const msg = decodeMessage(data);
|
|
209
|
+
if (!msg) return;
|
|
210
|
+
const ack = parseAckMessage(msg);
|
|
211
|
+
if (ack) {
|
|
212
|
+
settlePendingAck(ack.messageId, true);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (msg.type === "binary" && !msg.data) {
|
|
216
|
+
pendingInboundBinaryMeta.set(name, msg);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (shouldAcknowledgeMessage(name, msg)) {
|
|
220
|
+
queueAck(msg.id, name);
|
|
221
|
+
}
|
|
222
|
+
buffer.messages.push({ channel: name, msg, timestamp: Date.now() });
|
|
223
|
+
bridgeRunner?.enqueue([{ channel: name, msg }]);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
const pendingMeta = pendingInboundBinaryMeta.get(name);
|
|
227
|
+
if (pendingMeta) pendingInboundBinaryMeta.delete(name);
|
|
228
|
+
const binMsg = pendingMeta ? {
|
|
229
|
+
id: pendingMeta.id,
|
|
230
|
+
type: "binary",
|
|
231
|
+
data: data.toString("base64"),
|
|
232
|
+
meta: { ...pendingMeta.meta, size: data.length }
|
|
233
|
+
} : {
|
|
234
|
+
id: `bin-${Date.now()}`,
|
|
235
|
+
type: "binary",
|
|
236
|
+
data: data.toString("base64"),
|
|
237
|
+
meta: { size: data.length }
|
|
238
|
+
};
|
|
239
|
+
if (shouldAcknowledgeMessage(name, binMsg)) {
|
|
240
|
+
queueAck(binMsg.id, name);
|
|
241
|
+
}
|
|
242
|
+
buffer.messages.push({ channel: name, msg: binMsg, timestamp: Date.now() });
|
|
243
|
+
bridgeRunner?.enqueue([{ channel: name, msg: binMsg }]);
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
function getAckKey(messageId, channel) {
|
|
247
|
+
return `${channel}:${messageId}`;
|
|
248
|
+
}
|
|
249
|
+
function queueAck(messageId, channel) {
|
|
250
|
+
pendingOutboundAcks.set(getAckKey(messageId, channel), { messageId, channel });
|
|
251
|
+
flushQueuedAcks();
|
|
252
|
+
}
|
|
253
|
+
function flushQueuedAcks() {
|
|
254
|
+
const controlDc = channels.get(CONTROL_CHANNEL);
|
|
255
|
+
for (const [ackKey, ack] of pendingOutboundAcks) {
|
|
256
|
+
const messageDc = channels.get(ack.channel);
|
|
257
|
+
const targetChannel = resolveAckChannel({
|
|
258
|
+
controlChannelOpen: Boolean(controlDc?.isOpen()),
|
|
259
|
+
messageChannelOpen: Boolean(messageDc?.isOpen()),
|
|
260
|
+
messageChannel: ack.channel
|
|
261
|
+
});
|
|
262
|
+
if (!targetChannel) continue;
|
|
263
|
+
const encodedAck = encodeMessage(makeAckMessage(ack.messageId, ack.channel));
|
|
264
|
+
const primaryDc = targetChannel === CONTROL_CHANNEL ? controlDc : messageDc;
|
|
265
|
+
try {
|
|
266
|
+
if (primaryDc?.isOpen()) {
|
|
267
|
+
primaryDc.sendMessage(encodedAck);
|
|
268
|
+
pendingOutboundAcks.delete(ackKey);
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
} catch (error) {
|
|
272
|
+
markError("failed to flush queued ack on primary channel", error);
|
|
273
|
+
}
|
|
274
|
+
const fallbackChannel = targetChannel === ack.channel ? CONTROL_CHANNEL : ack.channel;
|
|
275
|
+
const fallbackDc = fallbackChannel === CONTROL_CHANNEL ? controlDc : messageDc;
|
|
276
|
+
try {
|
|
277
|
+
if (fallbackDc?.isOpen()) {
|
|
278
|
+
fallbackDc.sendMessage(encodedAck);
|
|
279
|
+
pendingOutboundAcks.delete(ackKey);
|
|
280
|
+
}
|
|
281
|
+
} catch (error) {
|
|
282
|
+
markError("failed to flush queued ack on fallback channel", error);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
function waitForDeliveryAck(messageId, timeoutMs) {
|
|
287
|
+
return new Promise((resolve) => {
|
|
288
|
+
const timeout = setTimeout(() => {
|
|
289
|
+
pendingDeliveryAcks.delete(messageId);
|
|
290
|
+
resolve(false);
|
|
291
|
+
}, timeoutMs);
|
|
292
|
+
pendingDeliveryAcks.set(messageId, { resolve, timeout });
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
function settlePendingAck(messageId, received) {
|
|
296
|
+
const pending = pendingDeliveryAcks.get(messageId);
|
|
297
|
+
if (!pending) return;
|
|
298
|
+
clearTimeout(pending.timeout);
|
|
299
|
+
pendingDeliveryAcks.delete(messageId);
|
|
300
|
+
pending.resolve(received);
|
|
301
|
+
}
|
|
302
|
+
function failPendingAcks() {
|
|
303
|
+
for (const [messageId, pending] of pendingDeliveryAcks) {
|
|
304
|
+
clearTimeout(pending.timeout);
|
|
305
|
+
pending.resolve(false);
|
|
306
|
+
pendingDeliveryAcks.delete(messageId);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
function openDataChannel(name) {
|
|
310
|
+
if (!peer) throw new Error("PeerConnection not initialized");
|
|
311
|
+
const existing = channels.get(name);
|
|
312
|
+
if (existing) return existing;
|
|
313
|
+
const dc = peer.createDataChannel(name, { ordered: true });
|
|
314
|
+
setupChannel(name, dc);
|
|
315
|
+
return dc;
|
|
316
|
+
}
|
|
317
|
+
async function waitForChannelOpen(dc, timeoutMs = 5e3) {
|
|
318
|
+
if (dc.isOpen()) return;
|
|
319
|
+
await new Promise((resolve, reject) => {
|
|
320
|
+
let settled = false;
|
|
321
|
+
const timeout = setTimeout(() => {
|
|
322
|
+
if (settled) return;
|
|
323
|
+
settled = true;
|
|
324
|
+
reject(new Error("DataChannel open timed out"));
|
|
325
|
+
}, timeoutMs);
|
|
326
|
+
dc.onOpen(() => {
|
|
327
|
+
if (settled) return;
|
|
328
|
+
settled = true;
|
|
329
|
+
clearTimeout(timeout);
|
|
330
|
+
resolve();
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
function maybePersistStickyOutbound(channel, msg) {
|
|
335
|
+
if (channel !== CHANNELS.CANVAS) return;
|
|
336
|
+
if (msg.type === "event" && msg.data === "hide") {
|
|
337
|
+
stickyOutboundByChannel.delete(channel);
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
if (msg.type !== "html") return;
|
|
341
|
+
stickyOutboundByChannel.set(channel, {
|
|
342
|
+
...msg,
|
|
343
|
+
meta: msg.meta ? { ...msg.meta } : void 0
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
async function replayStickyOutboundMessages() {
|
|
347
|
+
if (!connected || recovering || stopped) return;
|
|
348
|
+
for (const [channel, msg] of stickyOutboundByChannel) {
|
|
349
|
+
try {
|
|
350
|
+
let targetDc = channels.get(channel);
|
|
351
|
+
if (!targetDc) targetDc = openDataChannel(channel);
|
|
352
|
+
await waitForChannelOpen(targetDc, 3e3);
|
|
353
|
+
targetDc.sendMessage(encodeMessage(msg));
|
|
354
|
+
} catch (error) {
|
|
355
|
+
debugLog(`sticky outbound replay failed for channel ${channel}`, error);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
function attachPeerHandlers(currentPeer) {
|
|
360
|
+
currentPeer.onLocalCandidate((candidate, mid) => {
|
|
361
|
+
if (stopped || currentPeer !== peer) return;
|
|
362
|
+
localCandidates.push(JSON.stringify({ candidate, sdpMid: mid }));
|
|
363
|
+
});
|
|
364
|
+
currentPeer.onStateChange((state) => {
|
|
365
|
+
if (stopped || currentPeer !== peer) return;
|
|
366
|
+
if (state === "connected") {
|
|
367
|
+
connected = true;
|
|
368
|
+
flushQueuedAcks();
|
|
369
|
+
void replayStickyOutboundMessages();
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
if (state === "disconnected" || state === "failed" || state === "closed") {
|
|
373
|
+
const wasConnected = connected;
|
|
374
|
+
connected = false;
|
|
375
|
+
if (wasConnected) void persistCanvasContent();
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
currentPeer.onDataChannel((dc) => {
|
|
379
|
+
if (stopped || currentPeer !== peer) return;
|
|
380
|
+
setupChannel(dc.getLabel(), dc);
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
function createPeer() {
|
|
384
|
+
const nextPeer = new ndc.PeerConnection("agent", {
|
|
385
|
+
iceServers: ["stun:stun.l.google.com:19302", "stun:stun1.l.google.com:19302"]
|
|
386
|
+
});
|
|
387
|
+
peer = nextPeer;
|
|
388
|
+
channels = /* @__PURE__ */ new Map();
|
|
389
|
+
pendingInboundBinaryMeta = /* @__PURE__ */ new Map();
|
|
390
|
+
attachPeerHandlers(nextPeer);
|
|
391
|
+
}
|
|
392
|
+
function closeCurrentPeer() {
|
|
393
|
+
failPendingAcks();
|
|
394
|
+
for (const dc of channels.values()) {
|
|
395
|
+
try {
|
|
396
|
+
dc.close();
|
|
397
|
+
} catch (error) {
|
|
398
|
+
debugLog("failed to close data channel cleanly", error);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
channels.clear();
|
|
402
|
+
pendingInboundBinaryMeta.clear();
|
|
403
|
+
if (peer) {
|
|
404
|
+
try {
|
|
405
|
+
peer.close();
|
|
406
|
+
} catch (error) {
|
|
407
|
+
debugLog("failed to close peer connection cleanly", error);
|
|
408
|
+
}
|
|
409
|
+
peer = null;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
function resetNegotiationState() {
|
|
413
|
+
connected = false;
|
|
414
|
+
failPendingAcks();
|
|
415
|
+
lastAppliedBrowserOffer = null;
|
|
416
|
+
lastBrowserCandidateCount = 0;
|
|
417
|
+
lastSentCandidateCount = 0;
|
|
418
|
+
localCandidates.length = 0;
|
|
419
|
+
clearLocalCandidateTimers();
|
|
420
|
+
}
|
|
421
|
+
function startLocalCandidateFlush(slug) {
|
|
422
|
+
clearLocalCandidateTimers();
|
|
423
|
+
localCandidateInterval = setInterval(async () => {
|
|
424
|
+
if (localCandidates.length <= lastSentCandidateCount) return;
|
|
425
|
+
const newOnes = localCandidates.slice(lastSentCandidateCount);
|
|
426
|
+
lastSentCandidateCount = localCandidates.length;
|
|
427
|
+
await apiClient2.signalAnswer({ slug, candidates: newOnes }).catch((error) => {
|
|
428
|
+
debugLog("failed to publish local ICE candidates", error);
|
|
429
|
+
});
|
|
430
|
+
}, LOCAL_CANDIDATE_FLUSH_MS);
|
|
431
|
+
localCandidateStopTimer = setTimeout(() => {
|
|
432
|
+
clearLocalCandidateTimers();
|
|
433
|
+
}, 3e4);
|
|
434
|
+
}
|
|
435
|
+
async function handleIncomingLive(slug, browserOffer) {
|
|
436
|
+
if (recovering) return;
|
|
437
|
+
recovering = true;
|
|
438
|
+
try {
|
|
439
|
+
await persistCanvasContent();
|
|
440
|
+
await stopBridge();
|
|
441
|
+
closeCurrentPeer();
|
|
442
|
+
createPeer();
|
|
443
|
+
resetNegotiationState();
|
|
444
|
+
if (!peer) throw new Error("PeerConnection not initialized");
|
|
445
|
+
const answer = await createAnswer(peer, browserOffer, OFFER_TIMEOUT_MS);
|
|
446
|
+
lastAppliedBrowserOffer = browserOffer;
|
|
447
|
+
activeSlug = slug;
|
|
448
|
+
await apiClient2.signalAnswer({ slug, answer, agentName: agentName2 });
|
|
449
|
+
startLocalCandidateFlush(slug);
|
|
450
|
+
void startBridge();
|
|
451
|
+
} catch (error) {
|
|
452
|
+
markError("failed to handle incoming live request", error);
|
|
453
|
+
} finally {
|
|
454
|
+
recovering = false;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
function scheduleNextPoll(delayMs) {
|
|
458
|
+
if (stopped) return;
|
|
459
|
+
clearPollingTimer();
|
|
460
|
+
pollingTimer = setTimeout(() => {
|
|
461
|
+
void runPollingLoop();
|
|
462
|
+
}, delayMs);
|
|
463
|
+
}
|
|
464
|
+
async function pollSignalingOnce() {
|
|
465
|
+
const live = await apiClient2.getPendingLive();
|
|
466
|
+
if (!live) {
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
if (live.browserOffer && !live.agentAnswer) {
|
|
470
|
+
if (shouldRecoverForBrowserOfferChange({
|
|
471
|
+
incomingBrowserOffer: live.browserOffer,
|
|
472
|
+
lastAppliedBrowserOffer
|
|
473
|
+
}) || !lastAppliedBrowserOffer) {
|
|
474
|
+
await handleIncomingLive(live.slug, live.browserOffer);
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
if (live.browserOffer && live.agentAnswer && live.slug === activeSlug) {
|
|
479
|
+
if (live.browserCandidates.length > lastBrowserCandidateCount) {
|
|
480
|
+
const newCandidates = live.browserCandidates.slice(lastBrowserCandidateCount);
|
|
481
|
+
lastBrowserCandidateCount = live.browserCandidates.length;
|
|
482
|
+
for (const c of newCandidates) {
|
|
483
|
+
try {
|
|
484
|
+
const parsed = JSON.parse(c);
|
|
485
|
+
if (typeof parsed.candidate !== "string") continue;
|
|
486
|
+
const sdpMid = typeof parsed.sdpMid === "string" ? parsed.sdpMid : "0";
|
|
487
|
+
if (!peer) continue;
|
|
488
|
+
peer.addRemoteCandidate(parsed.candidate, sdpMid);
|
|
489
|
+
} catch (error) {
|
|
490
|
+
debugLog("failed to parse/apply browser ICE candidate", error);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
async function runPollingLoop() {
|
|
497
|
+
if (stopped) return;
|
|
498
|
+
let retryAfterSeconds;
|
|
499
|
+
try {
|
|
500
|
+
await pollSignalingOnce();
|
|
501
|
+
} catch (error) {
|
|
502
|
+
if (error instanceof PubApiError && error.status === 429) {
|
|
503
|
+
retryAfterSeconds = error.retryAfterSeconds;
|
|
504
|
+
}
|
|
505
|
+
markError("signaling poll failed", error);
|
|
506
|
+
}
|
|
507
|
+
const baseDelay = getSignalPollDelayMs({
|
|
508
|
+
hasActiveConnection: connected,
|
|
509
|
+
retryAfterSeconds
|
|
510
|
+
});
|
|
511
|
+
scheduleNextPoll(baseDelay);
|
|
512
|
+
}
|
|
513
|
+
if (fs.existsSync(socketPath2)) {
|
|
514
|
+
let stale = true;
|
|
515
|
+
try {
|
|
516
|
+
const raw = fs.readFileSync(infoPath2, "utf-8");
|
|
517
|
+
const info = JSON.parse(raw);
|
|
518
|
+
process.kill(info.pid, 0);
|
|
519
|
+
stale = false;
|
|
520
|
+
} catch (error) {
|
|
521
|
+
debugLog("stale socket check failed (assuming stale)", error);
|
|
522
|
+
}
|
|
523
|
+
if (stale) {
|
|
524
|
+
try {
|
|
525
|
+
fs.unlinkSync(socketPath2);
|
|
526
|
+
} catch (error) {
|
|
527
|
+
debugLog("failed to remove stale daemon socket", error);
|
|
528
|
+
}
|
|
529
|
+
} else {
|
|
530
|
+
throw new Error(`Daemon already running (socket: ${socketPath2})`);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
await apiClient2.goOnline();
|
|
534
|
+
heartbeatTimer = setInterval(async () => {
|
|
535
|
+
if (stopped) return;
|
|
536
|
+
try {
|
|
537
|
+
await apiClient2.heartbeat();
|
|
538
|
+
} catch (error) {
|
|
539
|
+
markError("heartbeat failed", error);
|
|
540
|
+
}
|
|
541
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
542
|
+
const ipcServer = net.createServer((conn) => {
|
|
543
|
+
let data = "";
|
|
544
|
+
conn.on("data", (chunk) => {
|
|
545
|
+
data += chunk.toString();
|
|
546
|
+
const newlineIdx = data.indexOf("\n");
|
|
547
|
+
if (newlineIdx === -1) return;
|
|
548
|
+
const line = data.slice(0, newlineIdx);
|
|
549
|
+
data = data.slice(newlineIdx + 1);
|
|
550
|
+
let request;
|
|
551
|
+
try {
|
|
552
|
+
request = JSON.parse(line);
|
|
553
|
+
} catch {
|
|
554
|
+
conn.write(`${JSON.stringify({ ok: false, error: "Invalid JSON" })}
|
|
555
|
+
`);
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
handleIpcRequest(request).then((response) => conn.write(`${JSON.stringify(response)}
|
|
559
|
+
`)).catch((err) => conn.write(`${JSON.stringify({ ok: false, error: errorMessage(err) })}
|
|
560
|
+
`));
|
|
561
|
+
});
|
|
562
|
+
});
|
|
563
|
+
ipcServer.listen(socketPath2);
|
|
564
|
+
const infoDir = path.dirname(infoPath2);
|
|
565
|
+
if (!fs.existsSync(infoDir)) fs.mkdirSync(infoDir, { recursive: true });
|
|
566
|
+
fs.writeFileSync(
|
|
567
|
+
infoPath2,
|
|
568
|
+
JSON.stringify({ pid: process.pid, socketPath: socketPath2, startedAt: startTime, cliVersion: cliVersion2 })
|
|
569
|
+
);
|
|
570
|
+
startHealthCheckTimer();
|
|
571
|
+
scheduleNextPoll(0);
|
|
572
|
+
function sendOnChannel(channel, msg) {
|
|
573
|
+
if (stopped || !connected) return;
|
|
574
|
+
let targetDc = channels.get(channel);
|
|
575
|
+
if (!targetDc) {
|
|
576
|
+
try {
|
|
577
|
+
targetDc = openDataChannel(channel);
|
|
578
|
+
} catch (error) {
|
|
579
|
+
debugLog(`bridge sendOnChannel: failed to open channel ${channel}`, error);
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
try {
|
|
584
|
+
if (targetDc.isOpen()) {
|
|
585
|
+
targetDc.sendMessage(encodeMessage(msg));
|
|
586
|
+
}
|
|
587
|
+
} catch (error) {
|
|
588
|
+
debugLog(`bridge sendOnChannel failed for ${channel}`, error);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
async function startBridge() {
|
|
592
|
+
if (stopped || !activeSlug) return;
|
|
593
|
+
if (!config.bridgeMode) return;
|
|
594
|
+
await stopBridge();
|
|
595
|
+
const instructions = buildBridgeInstructions(config.bridgeMode);
|
|
596
|
+
const bridgeConfig = { slug: activeSlug, sendMessage: sendOnChannel, debugLog, instructions };
|
|
597
|
+
try {
|
|
598
|
+
bridgeRunner = config.bridgeMode === "claude-code" ? await createClaudeCodeBridgeRunner(bridgeConfig) : await createOpenClawBridgeRunner(bridgeConfig);
|
|
599
|
+
} catch (error) {
|
|
600
|
+
markError("bridge runner failed to start", error);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
async function stopBridge() {
|
|
604
|
+
if (bridgeRunner) {
|
|
605
|
+
await bridgeRunner.stop();
|
|
606
|
+
bridgeRunner = null;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
async function persistCanvasContent() {
|
|
610
|
+
if (!activeSlug) return;
|
|
611
|
+
const html = getStickyCanvasHtml(stickyOutboundByChannel, CHANNELS.CANVAS);
|
|
612
|
+
if (!html) return;
|
|
613
|
+
try {
|
|
614
|
+
const timeout = new Promise(
|
|
615
|
+
(_, reject) => setTimeout(() => reject(new Error("persist timeout")), PERSIST_TIMEOUT_MS)
|
|
616
|
+
);
|
|
617
|
+
await Promise.race([
|
|
618
|
+
apiClient2.update({ slug: activeSlug, content: html, filename: "canvas.html" }),
|
|
619
|
+
timeout
|
|
620
|
+
]);
|
|
621
|
+
} catch (error) {
|
|
622
|
+
debugLog("failed to persist canvas content", error);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
async function cleanup() {
|
|
626
|
+
if (stopped) return;
|
|
627
|
+
stopped = true;
|
|
628
|
+
clearPollingTimer();
|
|
629
|
+
clearLocalCandidateTimers();
|
|
630
|
+
clearHealthCheckTimer();
|
|
631
|
+
clearHeartbeatTimer();
|
|
632
|
+
try {
|
|
633
|
+
await apiClient2.goOffline();
|
|
634
|
+
} catch (error) {
|
|
635
|
+
debugLog("failed to go offline", error);
|
|
636
|
+
}
|
|
637
|
+
await persistCanvasContent();
|
|
638
|
+
await stopBridge();
|
|
639
|
+
closeCurrentPeer();
|
|
640
|
+
ipcServer.close();
|
|
641
|
+
try {
|
|
642
|
+
fs.unlinkSync(socketPath2);
|
|
643
|
+
} catch (error) {
|
|
644
|
+
debugLog("failed to remove daemon socket during cleanup", error);
|
|
645
|
+
}
|
|
646
|
+
try {
|
|
647
|
+
fs.unlinkSync(infoPath2);
|
|
648
|
+
} catch (error) {
|
|
649
|
+
debugLog("failed to remove daemon info file during cleanup", error);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
async function shutdown() {
|
|
653
|
+
await cleanup();
|
|
654
|
+
process.exit(0);
|
|
655
|
+
}
|
|
656
|
+
process.on("SIGTERM", () => {
|
|
657
|
+
void shutdown();
|
|
658
|
+
});
|
|
659
|
+
process.on("SIGINT", () => {
|
|
660
|
+
void shutdown();
|
|
661
|
+
});
|
|
662
|
+
async function handleIpcRequest(req) {
|
|
663
|
+
switch (req.method) {
|
|
664
|
+
case "write": {
|
|
665
|
+
const channel = req.params.channel || CHANNELS.CHAT;
|
|
666
|
+
const readinessError = getLiveWriteReadinessError(connected);
|
|
667
|
+
if (readinessError) return { ok: false, error: readinessError };
|
|
668
|
+
const msg = req.params.msg;
|
|
669
|
+
const binaryBase64 = typeof req.params.binaryBase64 === "string" ? req.params.binaryBase64 : void 0;
|
|
670
|
+
const binaryPayload = msg.type === "binary" && binaryBase64 ? Buffer.from(binaryBase64, "base64") : void 0;
|
|
671
|
+
let targetDc = channels.get(channel);
|
|
672
|
+
if (!targetDc) targetDc = openDataChannel(channel);
|
|
673
|
+
try {
|
|
674
|
+
await waitForChannelOpen(targetDc);
|
|
675
|
+
} catch (error) {
|
|
676
|
+
markError(`channel "${channel}" failed to open`, error);
|
|
677
|
+
return { ok: false, error: `Channel "${channel}" not open: ${errorMessage(error)}` };
|
|
678
|
+
}
|
|
679
|
+
const waitForAck = shouldAcknowledgeMessage(channel, msg) ? waitForDeliveryAck(msg.id, WRITE_ACK_TIMEOUT_MS) : null;
|
|
680
|
+
try {
|
|
681
|
+
if (msg.type === "binary" && binaryPayload) {
|
|
682
|
+
targetDc.sendMessage(
|
|
683
|
+
encodeMessage({
|
|
684
|
+
...msg,
|
|
685
|
+
meta: { ...msg.meta || {}, size: binaryPayload.length }
|
|
686
|
+
})
|
|
687
|
+
);
|
|
688
|
+
targetDc.sendMessageBinary(binaryPayload);
|
|
689
|
+
} else {
|
|
690
|
+
targetDc.sendMessage(encodeMessage(msg));
|
|
691
|
+
}
|
|
692
|
+
} catch (error) {
|
|
693
|
+
if (waitForAck) settlePendingAck(msg.id, false);
|
|
694
|
+
markError(`failed to send message on channel "${channel}"`, error);
|
|
695
|
+
return {
|
|
696
|
+
ok: false,
|
|
697
|
+
error: `Failed to send on channel "${channel}": ${errorMessage(error)}`
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
if (waitForAck) {
|
|
701
|
+
const acked = await waitForAck;
|
|
702
|
+
if (!acked) {
|
|
703
|
+
markError(`delivery ack timeout for message ${msg.id}`);
|
|
704
|
+
return {
|
|
705
|
+
ok: false,
|
|
706
|
+
error: `Delivery not confirmed for message ${msg.id} within ${WRITE_ACK_TIMEOUT_MS}ms.`
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
maybePersistStickyOutbound(channel, msg);
|
|
711
|
+
return { ok: true, delivered: true };
|
|
712
|
+
}
|
|
713
|
+
case "read": {
|
|
714
|
+
const channel = req.params.channel;
|
|
715
|
+
let msgs;
|
|
716
|
+
if (channel) {
|
|
717
|
+
msgs = buffer.messages.filter((m) => m.channel === channel);
|
|
718
|
+
buffer.messages = buffer.messages.filter((m) => m.channel !== channel);
|
|
719
|
+
} else {
|
|
720
|
+
msgs = [...buffer.messages];
|
|
721
|
+
buffer.messages = [];
|
|
722
|
+
}
|
|
723
|
+
return { ok: true, messages: msgs };
|
|
724
|
+
}
|
|
725
|
+
case "channels": {
|
|
726
|
+
const chList = [...channels.keys()].map((name) => ({ name, direction: "bidi" }));
|
|
727
|
+
return { ok: true, channels: chList };
|
|
728
|
+
}
|
|
729
|
+
case "status": {
|
|
730
|
+
return {
|
|
731
|
+
ok: true,
|
|
732
|
+
connected,
|
|
733
|
+
activeSlug,
|
|
734
|
+
uptime: Math.floor((Date.now() - startTime) / 1e3),
|
|
735
|
+
channels: [...channels.keys()],
|
|
736
|
+
bufferedMessages: buffer.messages.length,
|
|
737
|
+
lastError,
|
|
738
|
+
bridgeMode: config.bridgeMode ?? null,
|
|
739
|
+
bridge: bridgeRunner?.status() ?? null
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
case "active-slug": {
|
|
743
|
+
return { ok: true, slug: activeSlug };
|
|
744
|
+
}
|
|
745
|
+
case "close": {
|
|
746
|
+
void shutdown();
|
|
747
|
+
return { ok: true };
|
|
748
|
+
}
|
|
749
|
+
default:
|
|
750
|
+
return { ok: false, error: `Unknown method: ${req.method}` };
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// src/live-daemon-entry.ts
|
|
756
|
+
var baseUrl = process.env.PUBBLUE_DAEMON_BASE_URL;
|
|
757
|
+
var apiKey = process.env.PUBBLUE_DAEMON_API_KEY;
|
|
758
|
+
var socketPath = process.env.PUBBLUE_DAEMON_SOCKET;
|
|
759
|
+
var infoPath = process.env.PUBBLUE_DAEMON_INFO;
|
|
760
|
+
var cliVersion = process.env.PUBBLUE_CLI_VERSION;
|
|
761
|
+
var agentName = process.env.PUBBLUE_DAEMON_AGENT_NAME;
|
|
762
|
+
var bridgeModeRaw = process.env.PUBBLUE_DAEMON_BRIDGE_MODE;
|
|
763
|
+
if (!bridgeModeRaw) {
|
|
764
|
+
console.error("Missing PUBBLUE_DAEMON_BRIDGE_MODE env var.");
|
|
765
|
+
process.exit(1);
|
|
766
|
+
}
|
|
767
|
+
var bridgeMode = bridgeModeRaw;
|
|
768
|
+
if (!baseUrl || !apiKey || !socketPath || !infoPath) {
|
|
769
|
+
console.error("Missing required env vars for daemon.");
|
|
770
|
+
process.exit(1);
|
|
771
|
+
}
|
|
772
|
+
var apiClient = new PubApiClient(baseUrl, apiKey);
|
|
773
|
+
void startDaemon({ apiClient, socketPath, infoPath, cliVersion, bridgeMode, agentName }).catch(
|
|
774
|
+
(error) => {
|
|
775
|
+
console.error(`Daemon failed to start: ${errorMessage(error)}`);
|
|
776
|
+
process.exit(1);
|
|
777
|
+
}
|
|
778
|
+
);
|