pubblue 0.6.8 → 0.7.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.
@@ -2,22 +2,29 @@ import {
2
2
  CHANNELS,
3
3
  CONTROL_CHANNEL,
4
4
  PubApiClient,
5
- PubApiError,
5
+ buildClaudeArgs,
6
+ buildSessionBriefing,
6
7
  createClaudeCodeBridgeRunner,
8
+ createClaudeSdkBridgeRunner,
7
9
  createOpenClawBridgeRunner,
8
10
  decodeMessage,
9
11
  encodeMessage,
10
12
  errorMessage,
11
13
  latestCliVersionPath,
12
14
  makeAckMessage,
15
+ makeDeliveryReceiptMessage,
16
+ makeEventMessage,
13
17
  parseAckMessage,
14
18
  readLatestCliVersion,
15
- shouldAcknowledgeMessage
16
- } from "./chunk-AZQD654L.js";
19
+ resolveClaudeCodePath,
20
+ resolveOpenClawRuntime,
21
+ shouldAcknowledgeMessage,
22
+ writeLiveSessionContentFile
23
+ } from "./chunk-5ODXW2EM.js";
17
24
 
18
25
  // src/lib/live-daemon.ts
26
+ import { randomUUID } from "crypto";
19
27
  import * as fs from "fs";
20
- import * as net from "net";
21
28
  import * as path from "path";
22
29
 
23
30
  // ../shared/ack-routing-core.ts
@@ -27,67 +34,946 @@ function resolveAckChannel(input) {
27
34
  return null;
28
35
  }
29
36
 
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
+ // src/lib/live-command-handler.ts
38
+ import { spawn } from "child_process";
39
+
40
+ // ../shared/command-protocol-core.ts
41
+ var COMMAND_PROTOCOL_VERSION = 1;
42
+ var COMMAND_MANIFEST_MAX_FUNCTIONS = 64;
43
+ function makeCommandResultMessage(payload) {
44
+ return makeEventMessage("command.result", payload);
45
+ }
46
+ function readRecord(input) {
47
+ return input && typeof input === "object" && !Array.isArray(input) ? input : null;
48
+ }
49
+ function readString(input) {
50
+ return typeof input === "string" && input.trim().length > 0 ? input : void 0;
51
+ }
52
+ function readReturnType(input) {
53
+ if (input === "void" || input === "text" || input === "json") return input;
54
+ return void 0;
55
+ }
56
+ function readFiniteNumber(input) {
57
+ if (typeof input !== "number" || !Number.isFinite(input)) return void 0;
58
+ return input;
59
+ }
60
+ function readStringArray(input) {
61
+ if (!Array.isArray(input)) return void 0;
62
+ const values = input.filter((entry) => typeof entry === "string");
63
+ return values.length === input.length ? values : void 0;
64
+ }
65
+ function readStringRecord(input) {
66
+ const record = readRecord(input);
67
+ if (!record) return void 0;
68
+ const values = Object.entries(record).filter((entry) => {
69
+ const [_key, value] = entry;
70
+ return typeof value === "string";
71
+ });
72
+ if (values.length !== Object.keys(record).length) return void 0;
73
+ return Object.fromEntries(values);
74
+ }
75
+ function parseExecutor(input) {
76
+ const record = readRecord(input);
77
+ if (!record) return void 0;
78
+ const kind = readString(record.kind);
79
+ if (!kind) return void 0;
80
+ if (kind === "exec") {
81
+ const command = readString(record.command);
82
+ if (!command) return void 0;
83
+ return {
84
+ kind: "exec",
85
+ command,
86
+ args: readStringArray(record.args),
87
+ cwd: readString(record.cwd),
88
+ timeoutMs: readFiniteNumber(record.timeoutMs),
89
+ env: readStringRecord(record.env)
90
+ };
91
+ }
92
+ if (kind === "shell") {
93
+ const script = readString(record.script);
94
+ if (!script) return void 0;
95
+ return {
96
+ kind: "shell",
97
+ script,
98
+ shell: readString(record.shell),
99
+ cwd: readString(record.cwd),
100
+ timeoutMs: readFiniteNumber(record.timeoutMs)
101
+ };
102
+ }
103
+ if (kind === "agent") {
104
+ const prompt = readString(record.prompt);
105
+ if (!prompt) return void 0;
106
+ const providerRaw = readString(record.provider);
107
+ const provider = providerRaw === "claude-code" || providerRaw === "openclaw" || providerRaw === "auto" ? providerRaw : void 0;
108
+ const outputRaw = readString(record.output);
109
+ const output = outputRaw === "json" || outputRaw === "text" ? outputRaw : void 0;
110
+ return {
111
+ kind: "agent",
112
+ prompt,
113
+ provider,
114
+ timeoutMs: readFiniteNumber(record.timeoutMs),
115
+ output
116
+ };
117
+ }
118
+ return void 0;
119
+ }
120
+ function parseFunctionSpec(input, fallbackName) {
121
+ const record = readRecord(input);
122
+ if (!record) return null;
123
+ const name = readString(record.name) ?? fallbackName;
124
+ if (!name) return null;
125
+ return {
126
+ name,
127
+ returns: readReturnType(record.returns),
128
+ timeoutMs: readFiniteNumber(record.timeoutMs),
129
+ description: readString(record.description),
130
+ executor: parseExecutor(record.executor)
131
+ };
132
+ }
133
+ function parseFunctionList(input) {
134
+ if (Array.isArray(input)) {
135
+ return input.map((entry) => parseFunctionSpec(entry)).filter((entry) => entry !== null).slice(0, COMMAND_MANIFEST_MAX_FUNCTIONS);
136
+ }
137
+ const record = readRecord(input);
138
+ if (!record) return [];
139
+ return Object.entries(record).map(([name, value]) => parseFunctionSpec(value, name)).filter((entry) => entry !== null).slice(0, COMMAND_MANIFEST_MAX_FUNCTIONS);
140
+ }
141
+ function parseMetaRecord(msg) {
142
+ return msg.type === "event" && msg.meta ? readRecord(msg.meta) : null;
143
+ }
144
+ function parseCommandInvokeMessage(msg) {
145
+ if (msg.type !== "event" || msg.data !== "command.invoke") return null;
146
+ const meta = parseMetaRecord(msg);
147
+ if (!meta) return null;
148
+ const callId = readString(meta.callId);
149
+ const name = readString(meta.name);
150
+ if (!callId || !name) return null;
151
+ return {
152
+ v: readFiniteNumber(meta.v) ?? COMMAND_PROTOCOL_VERSION,
153
+ callId,
154
+ name,
155
+ args: readRecord(meta.args) ?? void 0,
156
+ timeoutMs: readFiniteNumber(meta.timeoutMs)
157
+ };
158
+ }
159
+ function parseCommandCancelMessage(msg) {
160
+ if (msg.type !== "event" || msg.data !== "command.cancel") return null;
161
+ const meta = parseMetaRecord(msg);
162
+ if (!meta) return null;
163
+ const callId = readString(meta.callId);
164
+ if (!callId) return null;
165
+ return {
166
+ v: readFiniteNumber(meta.v) ?? COMMAND_PROTOCOL_VERSION,
167
+ callId,
168
+ reason: readString(meta.reason)
169
+ };
170
+ }
171
+ var MANIFEST_SCRIPT_RE = /<script\s[^>]*type\s*=\s*["']application\/pubblue-command-manifest\+json["'][^>]*>([\s\S]*?)<\/script>/i;
172
+ function extractManifestFromHtml(html) {
173
+ const match = MANIFEST_SCRIPT_RE.exec(html);
174
+ if (!match?.[1]) return null;
175
+ const raw = match[1].trim();
176
+ if (raw.length === 0) return null;
177
+ let parsed;
178
+ try {
179
+ parsed = JSON.parse(raw);
180
+ } catch {
181
+ return null;
182
+ }
183
+ if (!parsed || typeof parsed !== "object") return null;
184
+ const record = parsed;
185
+ const manifestId = typeof record.manifestId === "string" && record.manifestId.length > 0 ? record.manifestId : `manifest-${Date.now().toString(36)}`;
186
+ const functions = parseFunctionList(record.functions);
187
+ return {
188
+ v: typeof record.version === "number" ? record.version : 1,
189
+ manifestId,
190
+ functions
191
+ };
192
+ }
193
+
194
+ // src/lib/live-command-handler.ts
195
+ var DEFAULT_RECENT_RESULT_TTL_MS = 12e4;
196
+ var DEFAULT_COMMAND_TIMEOUT_MS = 15e3;
197
+ var DEFAULT_MAX_OUTPUT_BYTES = 256 * 1024;
198
+ var DEFAULT_MAX_CONCURRENT = 6;
199
+ function readPositiveNumberEnv(key, fallback) {
200
+ const value = process.env[key];
201
+ if (!value) return fallback;
202
+ const parsed = Number.parseInt(value, 10);
203
+ if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
204
+ return parsed;
205
+ }
206
+ function readRuntimeConfig() {
207
+ return {
208
+ defaultTimeoutMs: readPositiveNumberEnv(
209
+ "PUBBLUE_COMMAND_DEFAULT_TIMEOUT_MS",
210
+ DEFAULT_COMMAND_TIMEOUT_MS
211
+ ),
212
+ maxOutputBytes: readPositiveNumberEnv(
213
+ "PUBBLUE_COMMAND_MAX_OUTPUT_BYTES",
214
+ DEFAULT_MAX_OUTPUT_BYTES
215
+ ),
216
+ maxConcurrent: readPositiveNumberEnv("PUBBLUE_COMMAND_MAX_CONCURRENT", DEFAULT_MAX_CONCURRENT)
217
+ };
218
+ }
219
+ function readArgPath(args, path2) {
220
+ const parts = path2.split(".");
221
+ let value = args;
222
+ for (const part of parts) {
223
+ if (!value || typeof value !== "object") return void 0;
224
+ value = value[part];
225
+ }
226
+ return value;
227
+ }
228
+ function interpolateTemplate(input, args) {
229
+ return input.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (_match, path2) => {
230
+ const value = readArgPath(args, path2);
231
+ if (value === void 0 || value === null) return "";
232
+ if (typeof value === "string") return value;
233
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
234
+ return JSON.stringify(value);
235
+ });
236
+ }
237
+ function buildCommandError(code, message, retryable = false) {
238
+ return { code, message, retryable };
239
+ }
240
+ function toCommandReturnValue(output, returnType) {
241
+ if (returnType === "void") return null;
242
+ if (returnType === "json") {
243
+ const trimmed = output.trim();
244
+ if (trimmed.length === 0) return {};
245
+ return JSON.parse(trimmed);
246
+ }
247
+ return output;
248
+ }
249
+ async function executeProcessCommand(params) {
250
+ return await new Promise((resolve, reject) => {
251
+ const child = spawn(params.command, params.args, {
252
+ cwd: params.cwd,
253
+ env: { ...process.env, ...params.env ?? {} },
254
+ signal: params.signal,
255
+ shell: false,
256
+ stdio: ["ignore", "pipe", "pipe"]
257
+ });
258
+ let stdout = "";
259
+ let stderr = "";
260
+ let settled = false;
261
+ const finish = (fn) => {
262
+ if (settled) return;
263
+ settled = true;
37
264
  clearTimeout(timeout);
38
- resolve(JSON.stringify({ sdp, type }));
265
+ fn();
39
266
  };
40
- const offer = JSON.parse(browserOffer);
41
- peer.setRemoteDescription(offer.sdp, offer.type);
42
- peer.onLocalDescription((sdp, type) => {
43
- done(sdp, type);
267
+ const timeout = setTimeout(() => {
268
+ child.kill("SIGTERM");
269
+ finish(() => reject(new Error(`Command timed out after ${params.timeoutMs}ms`)));
270
+ }, params.timeoutMs);
271
+ child.stdout.on("data", (chunk) => {
272
+ if (settled) return;
273
+ stdout += chunk.toString("utf-8");
274
+ if (stdout.length > params.maxOutputBytes) {
275
+ child.kill("SIGTERM");
276
+ finish(() => reject(new Error(`stdout exceeded ${params.maxOutputBytes} bytes`)));
277
+ }
44
278
  });
45
- peer.onGatheringStateChange((state) => {
46
- if (state === "complete" && !resolved) {
47
- const desc = peer.localDescription();
48
- if (desc) done(desc.sdp, desc.type);
279
+ child.stderr.on("data", (chunk) => {
280
+ if (settled) return;
281
+ stderr += chunk.toString("utf-8");
282
+ if (stderr.length > params.maxOutputBytes) {
283
+ child.kill("SIGTERM");
284
+ finish(() => reject(new Error(`stderr exceeded ${params.maxOutputBytes} bytes`)));
49
285
  }
50
286
  });
287
+ child.on("error", (error) => {
288
+ finish(() => reject(error));
289
+ });
290
+ child.on("close", (code) => {
291
+ if (code === 0) {
292
+ finish(() => resolve({ stdout, stderr }));
293
+ return;
294
+ }
295
+ const detail = stderr.trim().length > 0 ? stderr.trim() : `exit code ${code}`;
296
+ finish(() => reject(new Error(detail)));
297
+ });
298
+ });
299
+ }
300
+ async function executeShellCommand(params) {
301
+ const shell = params.shell?.trim() || "/bin/sh";
302
+ return await executeProcessCommand({
303
+ command: shell,
304
+ args: ["-lc", params.script],
305
+ cwd: params.cwd,
306
+ timeoutMs: params.timeoutMs,
307
+ maxOutputBytes: params.maxOutputBytes,
308
+ signal: params.signal
309
+ });
310
+ }
311
+ function readClaudeAssistantOutput(line) {
312
+ if (!line.trim().startsWith("{")) return "";
313
+ try {
314
+ const event = JSON.parse(line);
315
+ if (typeof event.text === "string") return event.text;
316
+ if (event.delta && typeof event.delta.text === "string") return event.delta.text;
317
+ if (event.message && event.message.role === "assistant" && typeof event.message.content === "string") {
318
+ return event.message.content;
319
+ }
320
+ return "";
321
+ } catch (_error) {
322
+ return "";
323
+ }
324
+ }
325
+ async function executeClaudeAgentCommand(params) {
326
+ const claudePath = resolveClaudeCodePath(process.env);
327
+ const args = buildClaudeArgs(params.prompt, null, null, process.env);
328
+ if (!args.includes("--max-turns")) {
329
+ args.push("--max-turns", "4");
330
+ }
331
+ const cwd = process.env.CLAUDE_CODE_CWD?.trim() || process.env.PUBBLUE_PROJECT_ROOT || void 0;
332
+ const outputText = await new Promise((resolve, reject) => {
333
+ const child = spawn(claudePath, args, {
334
+ cwd,
335
+ env: { ...process.env },
336
+ signal: params.signal,
337
+ stdio: ["ignore", "pipe", "pipe"]
338
+ });
339
+ let stdout = "";
340
+ let stderr = "";
341
+ let settled = false;
342
+ const finish = (fn) => {
343
+ if (settled) return;
344
+ settled = true;
345
+ clearTimeout(timeout);
346
+ fn();
347
+ };
51
348
  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`));
349
+ child.kill("SIGTERM");
350
+ finish(() => reject(new Error(`Agent command timed out after ${params.timeoutMs}ms`)));
351
+ }, params.timeoutMs);
352
+ child.stdout.on("data", (chunk) => {
353
+ stdout += chunk.toString("utf-8");
354
+ if (stdout.length > params.maxOutputBytes) {
355
+ child.kill("SIGTERM");
356
+ finish(() => reject(new Error(`stdout exceeded ${params.maxOutputBytes} bytes`)));
357
+ }
358
+ });
359
+ child.stderr.on("data", (chunk) => {
360
+ stderr += chunk.toString("utf-8");
361
+ if (stderr.length > params.maxOutputBytes) {
362
+ child.kill("SIGTERM");
363
+ finish(() => reject(new Error(`stderr exceeded ${params.maxOutputBytes} bytes`)));
364
+ }
365
+ });
366
+ child.on("error", (error) => {
367
+ finish(() => reject(error));
368
+ });
369
+ child.on("close", (code) => {
370
+ if (code !== 0) {
371
+ const detail = stderr.trim().length > 0 ? stderr.trim() : `exit code ${code}`;
372
+ finish(() => reject(new Error(detail)));
373
+ return;
374
+ }
375
+ const lines = stdout.split(/\r?\n/);
376
+ const chunks = lines.map(readClaudeAssistantOutput).filter((entry) => entry.length > 0);
377
+ const joined = chunks.join("").trim();
378
+ finish(() => resolve(joined.length > 0 ? joined : stdout.trim()));
379
+ });
380
+ });
381
+ if (params.output === "json") {
382
+ const trimmed = outputText.trim();
383
+ if (trimmed.length === 0) return {};
384
+ return JSON.parse(trimmed);
385
+ }
386
+ return outputText;
387
+ }
388
+ async function executeOpenClawAgentCommand(params) {
389
+ const runtime = resolveOpenClawRuntime(process.env);
390
+ const invocationArgs = [
391
+ "agent",
392
+ "--local",
393
+ "--session-id",
394
+ runtime.sessionId,
395
+ "-m",
396
+ params.prompt
397
+ ];
398
+ const command = runtime.openclawPath.endsWith(".js") ? process.execPath : runtime.openclawPath;
399
+ const args = runtime.openclawPath.endsWith(".js") ? [runtime.openclawPath, ...invocationArgs] : invocationArgs;
400
+ const result = await executeProcessCommand({
401
+ command,
402
+ args,
403
+ cwd: process.env.PUBBLUE_PROJECT_ROOT || process.cwd(),
404
+ timeoutMs: params.timeoutMs,
405
+ maxOutputBytes: params.maxOutputBytes,
406
+ signal: params.signal
407
+ });
408
+ const output = result.stdout.trim();
409
+ if (params.output === "json") {
410
+ return output.length === 0 ? {} : JSON.parse(output);
411
+ }
412
+ return output;
413
+ }
414
+ function normalizeFunctionSpec(input) {
415
+ return {
416
+ ...input,
417
+ returns: input.returns === "text" || input.returns === "json" ? input.returns : "void"
418
+ };
419
+ }
420
+ function createLiveCommandHandler(params) {
421
+ const runtime = readRuntimeConfig();
422
+ const boundFunctions = /* @__PURE__ */ new Map();
423
+ const running = /* @__PURE__ */ new Map();
424
+ const recentResults = /* @__PURE__ */ new Map();
425
+ function buildCancelledResult(callId, startedAt) {
426
+ return {
427
+ v: COMMAND_PROTOCOL_VERSION,
428
+ callId,
429
+ ok: false,
430
+ error: buildCommandError("COMMAND_CANCELLED", "Command execution was cancelled."),
431
+ durationMs: Date.now() - startedAt
432
+ };
433
+ }
434
+ function getSpec(name) {
435
+ return boundFunctions.get(name) ?? null;
436
+ }
437
+ async function sendResult(payload) {
438
+ recentResults.set(payload.callId, {
439
+ payload,
440
+ expiresAt: Date.now() + DEFAULT_RECENT_RESULT_TTL_MS
441
+ });
442
+ await params.sendCommandMessage(makeCommandResultMessage(payload));
443
+ }
444
+ async function executeFunction(spec, args, abortSignal) {
445
+ const executor = spec.executor;
446
+ if (!executor) {
447
+ throw new Error(`Function "${spec.name}" is missing executor definition.`);
448
+ }
449
+ const timeoutMs = (typeof executor.timeoutMs === "number" && executor.timeoutMs > 0 ? executor.timeoutMs : void 0) ?? (typeof spec.timeoutMs === "number" && spec.timeoutMs > 0 ? spec.timeoutMs : void 0) ?? runtime.defaultTimeoutMs;
450
+ const returnType = spec.returns === "json" || spec.returns === "text" ? spec.returns : "void";
451
+ if (executor.kind === "exec") {
452
+ const command = interpolateTemplate(executor.command, args);
453
+ const commandArgs = (executor.args ?? []).map((entry) => interpolateTemplate(entry, args));
454
+ const cwd = executor.cwd ? interpolateTemplate(executor.cwd, args) : void 0;
455
+ const env = executor.env ? Object.fromEntries(
456
+ Object.entries(executor.env).map(([key, value]) => [
457
+ key,
458
+ interpolateTemplate(value, args)
459
+ ])
460
+ ) : void 0;
461
+ const result = await executeProcessCommand({
462
+ command,
463
+ args: commandArgs,
464
+ cwd,
465
+ env,
466
+ timeoutMs,
467
+ maxOutputBytes: runtime.maxOutputBytes,
468
+ signal: abortSignal
469
+ });
470
+ return toCommandReturnValue(result.stdout, returnType);
471
+ }
472
+ if (executor.kind === "shell") {
473
+ const script = interpolateTemplate(executor.script, args);
474
+ const cwd = executor.cwd ? interpolateTemplate(executor.cwd, args) : void 0;
475
+ const result = await executeShellCommand({
476
+ script,
477
+ shell: executor.shell,
478
+ cwd,
479
+ timeoutMs,
480
+ maxOutputBytes: runtime.maxOutputBytes,
481
+ signal: abortSignal
482
+ });
483
+ return toCommandReturnValue(result.stdout, returnType);
484
+ }
485
+ const agentSpec = executor;
486
+ const prompt = interpolateTemplate(agentSpec.prompt, args);
487
+ const output = agentSpec.output === "json" ? "json" : "text";
488
+ const provider = agentSpec.provider && agentSpec.provider !== "auto" ? agentSpec.provider : params.bridgeMode === "openclaw" ? "openclaw" : "claude-code";
489
+ if (provider === "openclaw") {
490
+ return await executeOpenClawAgentCommand({
491
+ prompt,
492
+ timeoutMs,
493
+ output,
494
+ maxOutputBytes: runtime.maxOutputBytes,
495
+ signal: abortSignal
496
+ });
497
+ }
498
+ return await executeClaudeAgentCommand({
499
+ prompt,
500
+ timeoutMs,
501
+ output,
502
+ maxOutputBytes: runtime.maxOutputBytes,
503
+ signal: abortSignal
504
+ });
505
+ }
506
+ function bindFunctions(functions) {
507
+ boundFunctions.clear();
508
+ for (const entry of functions) {
509
+ const normalized = normalizeFunctionSpec(entry);
510
+ if (!normalized.executor) {
511
+ params.debugLog(`commands skipped "${normalized.name}" \u2014 missing executor`);
512
+ continue;
513
+ }
514
+ boundFunctions.set(normalized.name, normalized);
515
+ }
516
+ params.debugLog(`commands bound=[${[...boundFunctions.keys()].join(", ")}]`);
517
+ }
518
+ function bindFromHtml(html) {
519
+ const manifest = extractManifestFromHtml(html);
520
+ if (!manifest) {
521
+ boundFunctions.clear();
522
+ params.debugLog("commands no manifest found in HTML, cleared bindings");
523
+ return;
524
+ }
525
+ params.debugLog(`commands manifestId=${manifest.manifestId}`);
526
+ bindFunctions(manifest.functions);
527
+ }
528
+ async function handleInvoke(message) {
529
+ if (!message) return;
530
+ const existing = recentResults.get(message.callId);
531
+ if (existing && existing.expiresAt > Date.now()) {
532
+ await sendResult(existing.payload);
533
+ return;
534
+ }
535
+ if (running.has(message.callId)) return;
536
+ if (running.size >= runtime.maxConcurrent) {
537
+ await sendResult({
538
+ v: COMMAND_PROTOCOL_VERSION,
539
+ callId: message.callId,
540
+ ok: false,
541
+ error: buildCommandError("MAX_CONCURRENCY", "Too many commands are already running."),
542
+ durationMs: 0
543
+ });
544
+ return;
545
+ }
546
+ const spec = getSpec(message.name);
547
+ if (!spec) {
548
+ params.debugLog(`commands invoke COMMAND_NOT_FOUND "${message.name}"`);
549
+ await sendResult({
550
+ v: COMMAND_PROTOCOL_VERSION,
551
+ callId: message.callId,
552
+ ok: false,
553
+ error: buildCommandError(
554
+ "COMMAND_NOT_FOUND",
555
+ `Command "${message.name}" is not registered.`
556
+ ),
557
+ durationMs: 0
558
+ });
559
+ return;
560
+ }
561
+ params.debugLog(
562
+ `commands invoke "${message.name}" callId=${message.callId} args=${JSON.stringify(message.args ?? {}).slice(0, 200)}`
563
+ );
564
+ const abort = new AbortController();
565
+ const startedAt = Date.now();
566
+ running.set(message.callId, { abort, startedAt, cancelled: false });
567
+ try {
568
+ const value = await executeFunction(spec, message.args ?? {}, abort.signal);
569
+ const active = running.get(message.callId);
570
+ if (abort.signal.aborted || active?.cancelled) {
571
+ params.debugLog(
572
+ `commands invoke "${message.name}" cancelled after ${Date.now() - startedAt}ms`
573
+ );
574
+ await sendResult(buildCancelledResult(message.callId, startedAt));
575
+ return;
576
+ }
577
+ const durationMs = Date.now() - startedAt;
578
+ params.debugLog(
579
+ `commands invoke "${message.name}" ok=${true} duration=${durationMs}ms value=${JSON.stringify(value).slice(0, 200)}`
580
+ );
581
+ await sendResult({
582
+ v: COMMAND_PROTOCOL_VERSION,
583
+ callId: message.callId,
584
+ ok: true,
585
+ value: spec.returns === "void" ? null : value,
586
+ durationMs
587
+ });
588
+ } catch (error) {
589
+ const detail = error instanceof Error && error.message.trim().length > 0 ? error.message : "Command execution failed";
590
+ if (abort.signal.aborted || running.get(message.callId)?.cancelled) {
591
+ await sendResult(buildCancelledResult(message.callId, startedAt));
592
+ return;
593
+ }
594
+ const durationMs = Date.now() - startedAt;
595
+ params.debugLog(
596
+ `commands invoke "${message.name}" FAILED duration=${durationMs}ms error=${detail.slice(0, 300)}`
597
+ );
598
+ await sendResult({
599
+ v: COMMAND_PROTOCOL_VERSION,
600
+ callId: message.callId,
601
+ ok: false,
602
+ error: buildCommandError("COMMAND_EXECUTION_FAILED", detail),
603
+ durationMs
604
+ });
605
+ } finally {
606
+ running.delete(message.callId);
607
+ }
608
+ }
609
+ async function handleCancel(message) {
610
+ if (!message) return;
611
+ const active = running.get(message.callId);
612
+ if (!active) return;
613
+ active.cancelled = true;
614
+ active.abort.abort();
615
+ }
616
+ async function handleBridgeMessage(message) {
617
+ if (message.type !== "event") return;
618
+ params.debugLog(
619
+ `commands message type=${message.type} data=${typeof message.data === "string" ? message.data.slice(0, 120) : "?"}`
620
+ );
621
+ for (const [callId, result] of recentResults) {
622
+ if (result.expiresAt <= Date.now()) {
623
+ recentResults.delete(callId);
59
624
  }
625
+ }
626
+ const invoke = parseCommandInvokeMessage(message);
627
+ if (invoke) {
628
+ await handleInvoke(invoke);
629
+ return;
630
+ }
631
+ const cancel = parseCommandCancelMessage(message);
632
+ if (cancel) {
633
+ await handleCancel(cancel);
634
+ }
635
+ }
636
+ return {
637
+ bindFromHtml(html) {
638
+ bindFromHtml(html);
639
+ },
640
+ stop() {
641
+ for (const [callId, active] of running) {
642
+ active.abort.abort();
643
+ running.delete(callId);
644
+ }
645
+ },
646
+ async onMessage(message) {
647
+ await handleBridgeMessage(message).catch((error) => {
648
+ params.markError("command handler failed", error);
649
+ });
650
+ }
651
+ };
652
+ }
653
+
654
+ // ../shared/webrtc-negotiation-core.ts
655
+ function createAgentAnswerFromBrowserOffer(peer, browserOffer, timeoutMs) {
656
+ return new Promise((resolve, reject) => {
657
+ let settled = false;
658
+ let timeout = null;
659
+ const finish = (description) => {
660
+ if (settled) return;
661
+ settled = true;
662
+ if (timeout) clearTimeout(timeout);
663
+ resolve(encodeSessionDescription(description));
664
+ };
665
+ const fail = (error) => {
666
+ if (settled) return;
667
+ settled = true;
668
+ if (timeout) clearTimeout(timeout);
669
+ reject(error);
670
+ };
671
+ peer.onLocalDescription((sdp, type) => {
672
+ finish(assertSessionDescription({ sdp, type }, "Agent local description"));
673
+ });
674
+ peer.onGatheringStateChange((state) => {
675
+ if (state !== "complete" || settled) return;
676
+ const local = peer.getLocalDescription();
677
+ if (!local) return;
678
+ finish(assertSessionDescription(local, "Agent local description"));
679
+ });
680
+ try {
681
+ const parsedOffer = parseSessionDescription(browserOffer, "Browser offer");
682
+ peer.setRemoteDescription(parsedOffer.sdp, parsedOffer.type);
683
+ } catch (error) {
684
+ fail(error instanceof Error ? error : new Error(String(error)));
685
+ return;
686
+ }
687
+ timeout = setTimeout(() => {
688
+ const local = peer.getLocalDescription();
689
+ if (local) {
690
+ finish(assertSessionDescription(local, "Agent local description"));
691
+ return;
692
+ }
693
+ fail(new Error(`Timed out after ${timeoutMs}ms`));
60
694
  }, timeoutMs);
61
695
  });
62
696
  }
697
+ function parseSessionDescription(descriptionJson, label = "Session description") {
698
+ let parsed;
699
+ try {
700
+ parsed = JSON.parse(descriptionJson);
701
+ } catch {
702
+ throw new Error(`${label} is not valid JSON`);
703
+ }
704
+ return assertSessionDescription(parsed, label);
705
+ }
706
+ function encodeSessionDescription(description) {
707
+ const normalized = assertSessionDescription(description, "Session description");
708
+ return JSON.stringify({ sdp: normalized.sdp, type: normalized.type });
709
+ }
710
+ function assertSessionDescription(value, label) {
711
+ if (!value || typeof value !== "object") {
712
+ throw new Error(`${label} must be an object with sdp/type`);
713
+ }
714
+ const maybeSdp = value.sdp;
715
+ const maybeType = value.type;
716
+ if (typeof maybeSdp !== "string" || maybeSdp.length === 0) {
717
+ throw new Error(`${label} must include a non-empty sdp`);
718
+ }
719
+ if (typeof maybeType !== "string" || maybeType.length === 0) {
720
+ throw new Error(`${label} must include a non-empty type`);
721
+ }
722
+ return { sdp: maybeSdp, type: maybeType };
723
+ }
724
+
725
+ // src/lib/live-daemon-answer.ts
726
+ function createAnswer(peer, browserOffer, timeoutMs) {
727
+ return createAgentAnswerFromBrowserOffer(
728
+ {
729
+ setRemoteDescription: (sdp, type) => {
730
+ peer.setRemoteDescription(sdp, type);
731
+ },
732
+ onLocalDescription: (cb) => {
733
+ peer.onLocalDescription((sdp, type) => cb(sdp, type));
734
+ },
735
+ onGatheringStateChange: (cb) => {
736
+ peer.onGatheringStateChange((state) => cb(state));
737
+ },
738
+ getLocalDescription: () => peer.localDescription()
739
+ },
740
+ browserOffer,
741
+ timeoutMs
742
+ );
743
+ }
744
+
745
+ // src/lib/live-daemon-ipc-handler.ts
746
+ function createDaemonIpcHandler(params) {
747
+ return async function handleIpcRequest(req) {
748
+ switch (req.method) {
749
+ case "write": {
750
+ const channel = req.params.channel || "chat";
751
+ const msg = req.params.msg;
752
+ if (channel === CHANNELS.CANVAS && msg.type === "html" && typeof msg.data === "string") {
753
+ const slug = params.getActiveSlug();
754
+ if (!slug) return { ok: false, error: "No active live session." };
755
+ try {
756
+ await params.apiClient.update({
757
+ slug,
758
+ content: msg.data,
759
+ filename: "live-canvas.html"
760
+ });
761
+ params.bindCanvasCommands(msg.data);
762
+ return { ok: true, delivered: true };
763
+ } catch (error) {
764
+ const errMsg = error instanceof Error ? error.message : String(error);
765
+ params.markError(`failed to persist canvas HTML for "${slug}"`, error);
766
+ return { ok: false, error: `Canvas update failed: ${errMsg}` };
767
+ }
768
+ }
769
+ const readinessError = params.getWriteReadinessError();
770
+ if (readinessError) return { ok: false, error: readinessError };
771
+ const binaryBase64 = typeof req.params.binaryBase64 === "string" ? req.params.binaryBase64 : void 0;
772
+ const binaryPayload = msg.type === "binary" && binaryBase64 ? Buffer.from(binaryBase64, "base64") : void 0;
773
+ const maxAttempts = Math.max(1, params.writeAckMaxAttempts);
774
+ let lastError = null;
775
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
776
+ let targetDc;
777
+ try {
778
+ targetDc = params.openDataChannel(channel);
779
+ await params.waitForChannelOpen(targetDc);
780
+ } catch (error) {
781
+ params.markError(
782
+ `channel "${channel}" failed to open (attempt ${attempt}/${maxAttempts})`,
783
+ error
784
+ );
785
+ lastError = `Channel "${channel}" not open: ${error instanceof Error ? error.message : String(error)}`;
786
+ continue;
787
+ }
788
+ const waitForAck = shouldAcknowledgeMessage(channel, msg) ? params.waitForDeliveryAck(msg.id, channel, params.writeAckTimeoutMs) : null;
789
+ try {
790
+ if (msg.type === "binary" && binaryPayload) {
791
+ targetDc.sendMessage(
792
+ encodeMessage({
793
+ ...msg,
794
+ meta: { ...msg.meta || {}, size: binaryPayload.length }
795
+ })
796
+ );
797
+ targetDc.sendMessageBinary(binaryPayload);
798
+ } else {
799
+ targetDc.sendMessage(encodeMessage(msg));
800
+ }
801
+ } catch (error) {
802
+ if (waitForAck) params.settlePendingAck(msg.id, channel, false);
803
+ params.markError(
804
+ `failed to send message on channel "${channel}" (attempt ${attempt}/${maxAttempts})`,
805
+ error
806
+ );
807
+ lastError = `Failed to send on channel "${channel}": ${error instanceof Error ? error.message : String(error)}`;
808
+ continue;
809
+ }
810
+ if (waitForAck) {
811
+ const acked = await waitForAck;
812
+ if (!acked) {
813
+ params.markError(
814
+ `delivery ack timeout for message ${msg.id} on "${channel}" (attempt ${attempt}/${maxAttempts})`
815
+ );
816
+ lastError = `Delivery not confirmed for message ${msg.id} within ${params.writeAckTimeoutMs}ms.`;
817
+ continue;
818
+ }
819
+ }
820
+ return { ok: true, delivered: true };
821
+ }
822
+ return {
823
+ ok: false,
824
+ error: lastError ?? `Failed to send on channel "${channel}" after ${maxAttempts} attempt${maxAttempts === 1 ? "" : "s"}.`
825
+ };
826
+ }
827
+ case "read": {
828
+ const channel = req.params.channel;
829
+ const buffered = params.getBufferedMessages();
830
+ let msgs;
831
+ if (channel) {
832
+ msgs = buffered.filter((m) => m.channel === channel);
833
+ params.setBufferedMessages(buffered.filter((m) => m.channel !== channel));
834
+ } else {
835
+ msgs = [...buffered];
836
+ params.setBufferedMessages([]);
837
+ }
838
+ return { ok: true, messages: msgs };
839
+ }
840
+ case "channels": {
841
+ const chList = params.getChannels().map((name) => ({ name, direction: "bidi" }));
842
+ return { ok: true, channels: chList };
843
+ }
844
+ case "status": {
845
+ return {
846
+ ok: true,
847
+ connected: params.getConnected(),
848
+ signalingConnected: params.getSignalingConnected(),
849
+ activeSlug: params.getActiveSlug(),
850
+ uptime: params.getUptimeSeconds(),
851
+ channels: params.getChannels(),
852
+ bufferedMessages: params.getBufferedMessages().length,
853
+ lastError: params.getLastError(),
854
+ bridgeMode: params.getBridgeMode(),
855
+ bridge: params.getBridgeStatus()
856
+ };
857
+ }
858
+ case "active-slug": {
859
+ return { ok: true, slug: params.getActiveSlug() };
860
+ }
861
+ case "close": {
862
+ params.shutdown();
863
+ return { ok: true };
864
+ }
865
+ default:
866
+ return { ok: false, error: `Unknown method: ${req.method}` };
867
+ }
868
+ };
869
+ }
870
+
871
+ // src/lib/live-daemon-ipc-server.ts
872
+ import * as net from "net";
873
+ function createDaemonIpcServer(handler) {
874
+ return net.createServer((conn) => {
875
+ let data = "";
876
+ conn.on("data", (chunk) => {
877
+ data += chunk.toString();
878
+ const newlineIdx = data.indexOf("\n");
879
+ if (newlineIdx === -1) return;
880
+ const line = data.slice(0, newlineIdx);
881
+ data = data.slice(newlineIdx + 1);
882
+ let request;
883
+ try {
884
+ request = JSON.parse(line);
885
+ } catch {
886
+ conn.write(`${JSON.stringify({ ok: false, error: "Invalid JSON" })}
887
+ `);
888
+ return;
889
+ }
890
+ handler(request).then((response) => conn.write(`${JSON.stringify(response)}
891
+ `)).catch((err) => conn.write(`${JSON.stringify({ ok: false, error: errorMessage(err) })}
892
+ `));
893
+ });
894
+ });
895
+ }
896
+
897
+ // src/lib/live-prompt-content.ts
898
+ var CANVAS_COMMAND_PROTOCOL_GUIDE_MARKDOWN = [
899
+ "## Canvas Command Channel",
900
+ "Use this when canvas UI interactions need local refetches, side effects, or rerunning local tools without regenerating the whole canvas.",
901
+ "",
902
+ "### Protocol",
903
+ "1. Put a command manifest in the HTML:",
904
+ "```html",
905
+ '<script type="application/pubblue-command-manifest+json">',
906
+ "{",
907
+ ' "manifestId": "mail-ui",',
908
+ ' "functions": [',
909
+ " {",
910
+ ' "name": "archiveEmail",',
911
+ ' "returns": "void",',
912
+ ' "executor": {',
913
+ ' "kind": "exec",',
914
+ ' "command": "gog",',
915
+ ' "args": ["archive", "{{emailId}}"]',
916
+ " }",
917
+ " },",
918
+ " {",
919
+ ' "name": "getEmail",',
920
+ ' "returns": "json",',
921
+ ' "executor": {',
922
+ ' "kind": "exec",',
923
+ ' "command": "gog",',
924
+ ' "args": ["get", "{{emailId}}", "--json"]',
925
+ " }",
926
+ " },",
927
+ " {",
928
+ ' "name": "summarizeEmail",',
929
+ ' "returns": "text",',
930
+ ' "executor": {',
931
+ ' "kind": "agent",',
932
+ ' "prompt": "Summarize email {{emailId}}"',
933
+ " }",
934
+ " }",
935
+ " ]",
936
+ "}",
937
+ "</script>",
938
+ "```",
939
+ "2. In canvas JS, call actions with `await pubblue.command(name, args)` or `await pubblue.commands.<name>(args)`.",
940
+ "3. Return semantics:",
941
+ ' - `returns: "void"` for side effects (resolves `null`).',
942
+ ' - `returns: "text" | "json"` for payload responses (promise resolves with value; errors reject).'
943
+ ].join("\n");
63
944
 
64
945
  // src/lib/live-daemon-shared.ts
65
946
  function buildBridgeInstructions(mode) {
66
- if (mode === "claude-code") {
947
+ if (mode === "claude-code" || mode === "claude-sdk") {
67
948
  return {
68
- replyHint: 'Reply by running: pubblue write "<your reply>"',
69
- canvasHint: "Canvas update: pubblue write -c canvas -f /path/to/file.html",
949
+ replyHint: 'Reply command: pubblue write "<your reply>"',
950
+ canvasHint: "Canvas command: pubblue write -c canvas -f /path/to/file.html",
70
951
  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")
952
+ "You are in a live pub.blue session with a user.",
953
+ "The user sees chat and a canvas iframe.",
954
+ "Always communicate by running `pubblue write` commands.",
955
+ "Use canvas for output; use chat for short replies.",
956
+ "Canvas supports inline local calls for interactive visualizations that may require refetching data or rerunning local tools.",
957
+ "When needed, include command-manifest actions so browser interactions can call the daemon and receive results back in canvas.",
958
+ "Follow the Canvas Command Channel protocol from the session briefing exactly."
959
+ ].join("\n"),
960
+ commandProtocolGuide: CANVAS_COMMAND_PROTOCOL_GUIDE_MARKDOWN
75
961
  };
76
962
  }
77
963
  return {
78
- replyHint: 'Reply by running: write "<your reply>"',
79
- canvasHint: "Canvas update: write -c canvas -f /path/to/file.html",
80
- systemPrompt: null
964
+ replyHint: 'Reply command: write "<your reply>"',
965
+ canvasHint: "Canvas command: write -c canvas -f /path/to/file.html",
966
+ systemPrompt: null,
967
+ commandProtocolGuide: CANVAS_COMMAND_PROTOCOL_GUIDE_MARKDOWN
81
968
  };
82
969
  }
83
970
  var OFFER_TIMEOUT_MS = 1e4;
84
- var SIGNAL_POLL_WAITING_MS = 5e3;
85
- var SIGNAL_POLL_CONNECTED_MS = 15e3;
86
971
  var LOCAL_CANDIDATE_FLUSH_MS = 2e3;
87
972
  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;
973
+ var PING_INTERVAL_MS = 1e4;
974
+ var PONG_TIMEOUT_MS = 15e3;
975
+ function getLiveWriteReadinessError(isReady) {
976
+ return isReady ? null : "Live session is not established yet. Wait for browser connect and initial context sync, then retry.";
91
977
  }
92
978
  function shouldRecoverForBrowserOfferChange(params) {
93
979
  const { incomingBrowserOffer, lastAppliedBrowserOffer } = params;
@@ -95,57 +981,250 @@ function shouldRecoverForBrowserOfferChange(params) {
95
981
  if (!lastAppliedBrowserOffer) return false;
96
982
  return incomingBrowserOffer !== lastAppliedBrowserOffer;
97
983
  }
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;
984
+
985
+ // src/lib/live-daemon-signaling.ts
986
+ import { ConvexClient } from "convex/browser";
987
+ import { makeFunctionReference } from "convex/server";
988
+
989
+ // src/lib/live-signaling.ts
990
+ function decideSignalingUpdate(params) {
991
+ const { live, activeSlug, lastAppliedBrowserOffer, lastBrowserCandidateCount } = params;
992
+ if (!live) {
993
+ return { type: "noop", nextBrowserCandidateCount: lastBrowserCandidateCount };
994
+ }
995
+ if (live.browserOffer && !live.agentAnswer) {
996
+ const shouldRecover = !lastAppliedBrowserOffer || shouldRecoverForBrowserOfferChange({
997
+ incomingBrowserOffer: live.browserOffer,
998
+ lastAppliedBrowserOffer
999
+ });
1000
+ if (shouldRecover) {
1001
+ return {
1002
+ type: "recover",
1003
+ slug: live.slug,
1004
+ browserOffer: live.browserOffer,
1005
+ nextBrowserCandidateCount: 0
1006
+ };
1007
+ }
1008
+ return { type: "noop", nextBrowserCandidateCount: lastBrowserCandidateCount };
1009
+ }
1010
+ if (live.browserOffer && live.agentAnswer && live.slug === activeSlug) {
1011
+ if (live.browserCandidates.length > lastBrowserCandidateCount) {
1012
+ return {
1013
+ type: "apply-browser-candidates",
1014
+ candidatePayloads: live.browserCandidates.slice(lastBrowserCandidateCount),
1015
+ nextBrowserCandidateCount: live.browserCandidates.length
1016
+ };
1017
+ }
1018
+ }
1019
+ return { type: "noop", nextBrowserCandidateCount: lastBrowserCandidateCount };
107
1020
  }
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;
1021
+
1022
+ // src/lib/live-daemon-signaling.ts
1023
+ var LIVE_SIGNAL_QUERY = makeFunctionReference("pubs:getLiveForAgentByApiKey");
1024
+ function parseLiveSnapshot(result) {
1025
+ if (result === null || result === void 0) return null;
1026
+ if (typeof result !== "object") {
1027
+ throw new Error("Invalid signaling snapshot: expected object or null");
113
1028
  }
114
- return Math.max(baseDelay, Math.ceil(params.retryAfterSeconds * 1e3));
1029
+ const live = result;
1030
+ if (typeof live.slug !== "string") throw new Error("Invalid signaling snapshot: missing slug");
1031
+ if (!Array.isArray(live.browserCandidates)) {
1032
+ throw new Error("Invalid signaling snapshot: missing browserCandidates");
1033
+ }
1034
+ if (!Array.isArray(live.agentCandidates)) {
1035
+ throw new Error("Invalid signaling snapshot: missing agentCandidates");
1036
+ }
1037
+ if (typeof live.createdAt !== "number") {
1038
+ throw new Error("Invalid signaling snapshot: missing timestamps");
1039
+ }
1040
+ return {
1041
+ slug: live.slug,
1042
+ status: live.status,
1043
+ browserOffer: live.browserOffer,
1044
+ agentAnswer: live.agentAnswer,
1045
+ browserCandidates: live.browserCandidates,
1046
+ agentCandidates: live.agentCandidates,
1047
+ createdAt: live.createdAt
1048
+ };
1049
+ }
1050
+ function createSignalingController(params) {
1051
+ const {
1052
+ apiClient: apiClient2,
1053
+ daemonSessionId,
1054
+ debugLog,
1055
+ markError,
1056
+ isStopped,
1057
+ getActiveSlug,
1058
+ getLastAppliedBrowserOffer,
1059
+ getLastBrowserCandidateCount,
1060
+ setLastBrowserCandidateCount,
1061
+ onRecover,
1062
+ onApplyBrowserCandidates
1063
+ } = params;
1064
+ let signalingClient = null;
1065
+ let signalingUnsubscribe = null;
1066
+ let connectionStateUnsubscribe = null;
1067
+ let signalingQueue = Promise.resolve();
1068
+ let signalingConnectionKnown = false;
1069
+ let signalingConnectionOpen = false;
1070
+ function status() {
1071
+ return { known: signalingConnectionKnown, open: signalingConnectionOpen };
1072
+ }
1073
+ function observeSignalingConnectionState(state) {
1074
+ if (!signalingConnectionKnown) {
1075
+ signalingConnectionKnown = true;
1076
+ signalingConnectionOpen = state.isWebSocketConnected;
1077
+ debugLog(
1078
+ `signaling websocket initial state: ${state.isWebSocketConnected ? "connected" : "disconnected"}`
1079
+ );
1080
+ return;
1081
+ }
1082
+ if (state.isWebSocketConnected !== signalingConnectionOpen) {
1083
+ signalingConnectionOpen = state.isWebSocketConnected;
1084
+ if (state.isWebSocketConnected) {
1085
+ debugLog("signaling websocket reconnected");
1086
+ } else {
1087
+ markError(
1088
+ `signaling websocket disconnected (retries=${state.connectionRetries}, connections=${state.connectionCount})`
1089
+ );
1090
+ }
1091
+ }
1092
+ }
1093
+ async function handleSignalingSnapshot(live) {
1094
+ const decision = decideSignalingUpdate({
1095
+ live,
1096
+ activeSlug: getActiveSlug(),
1097
+ lastAppliedBrowserOffer: getLastAppliedBrowserOffer(),
1098
+ lastBrowserCandidateCount: getLastBrowserCandidateCount()
1099
+ });
1100
+ setLastBrowserCandidateCount(decision.nextBrowserCandidateCount);
1101
+ if (decision.type === "recover") {
1102
+ await onRecover(decision.slug, decision.browserOffer);
1103
+ return;
1104
+ }
1105
+ if (decision.type === "apply-browser-candidates") {
1106
+ await onApplyBrowserCandidates(decision.candidatePayloads);
1107
+ }
1108
+ }
1109
+ function enqueueSignalingSnapshot(live) {
1110
+ signalingQueue = signalingQueue.then(async () => {
1111
+ if (isStopped()) return;
1112
+ await handleSignalingSnapshot(live);
1113
+ }).catch((error) => {
1114
+ markError("failed to process signaling snapshot", error);
1115
+ });
1116
+ }
1117
+ function start() {
1118
+ if (signalingClient) return;
1119
+ signalingClient = new ConvexClient(apiClient2.getConvexCloudUrl(), {
1120
+ onServerDisconnectError: (message) => {
1121
+ markError(`signaling server disconnect: ${message}`);
1122
+ }
1123
+ });
1124
+ connectionStateUnsubscribe = signalingClient.subscribeToConnectionState((state) => {
1125
+ observeSignalingConnectionState({
1126
+ isWebSocketConnected: state.isWebSocketConnected,
1127
+ connectionCount: state.connectionCount,
1128
+ connectionRetries: state.connectionRetries
1129
+ });
1130
+ });
1131
+ const unsubscribe = signalingClient.onUpdate(
1132
+ LIVE_SIGNAL_QUERY,
1133
+ { apiKey: apiClient2.getApiKey(), daemonSessionId },
1134
+ (result) => {
1135
+ let live;
1136
+ try {
1137
+ live = parseLiveSnapshot(result);
1138
+ } catch (error) {
1139
+ markError("received malformed signaling snapshot", error);
1140
+ return;
1141
+ }
1142
+ enqueueSignalingSnapshot(live);
1143
+ },
1144
+ (error) => {
1145
+ markError("signaling subscription failed", error);
1146
+ }
1147
+ );
1148
+ signalingUnsubscribe = () => unsubscribe();
1149
+ }
1150
+ async function stop() {
1151
+ if (signalingUnsubscribe) {
1152
+ signalingUnsubscribe();
1153
+ signalingUnsubscribe = null;
1154
+ }
1155
+ if (connectionStateUnsubscribe) {
1156
+ connectionStateUnsubscribe();
1157
+ connectionStateUnsubscribe = null;
1158
+ }
1159
+ if (signalingClient) {
1160
+ await signalingClient.close().catch((error) => {
1161
+ debugLog("failed to close signaling client cleanly", error);
1162
+ });
1163
+ signalingClient = null;
1164
+ }
1165
+ signalingConnectionKnown = false;
1166
+ signalingConnectionOpen = false;
1167
+ }
1168
+ return {
1169
+ start,
1170
+ stop,
1171
+ status
1172
+ };
115
1173
  }
116
1174
 
117
1175
  // src/lib/live-daemon.ts
118
1176
  var HEARTBEAT_INTERVAL_MS = 3e4;
119
1177
  var HEALTH_CHECK_INTERVAL_MS = 60 * 60 * 1e3;
120
- var PERSIST_TIMEOUT_MS = 3e3;
1178
+ var OUTBOUND_SEND_MAX_ATTEMPTS = 2;
1179
+ var MAX_SEEN_INBOUND_MESSAGES = 1e4;
1180
+ var MAX_BUFFERED_MESSAGES = 200;
121
1181
  async function startDaemon(config) {
122
1182
  const { apiClient: apiClient2, socketPath: socketPath2, infoPath: infoPath2, cliVersion: cliVersion2, agentName: agentName2 } = config;
123
1183
  const ndc = await import("node-datachannel");
124
1184
  const buffer = { messages: [] };
125
1185
  const startTime = Date.now();
1186
+ const daemonSessionId = randomUUID();
126
1187
  let stopped = false;
127
- let connected = false;
1188
+ let browserConnected = false;
1189
+ let bridgePrimed = false;
1190
+ let bridgePriming = null;
1191
+ let bridgeAbort = null;
128
1192
  let recovering = false;
129
1193
  let activeSlug = null;
130
1194
  let lastAppliedBrowserOffer = null;
131
1195
  let lastBrowserCandidateCount = 0;
132
1196
  let lastSentCandidateCount = 0;
133
1197
  const localCandidates = [];
134
- const stickyOutboundByChannel = /* @__PURE__ */ new Map();
135
1198
  const pendingOutboundAcks = /* @__PURE__ */ new Map();
136
1199
  const pendingDeliveryAcks = /* @__PURE__ */ new Map();
137
1200
  let peer = null;
138
1201
  let channels = /* @__PURE__ */ new Map();
139
1202
  let pendingInboundBinaryMeta = /* @__PURE__ */ new Map();
140
- let pollingTimer = null;
1203
+ let inboundStreams = /* @__PURE__ */ new Map();
1204
+ let seenInboundMessageKeys = /* @__PURE__ */ new Set();
1205
+ let commandProcessingQueue = Promise.resolve();
141
1206
  let heartbeatTimer = null;
142
1207
  let localCandidateInterval = null;
143
1208
  let localCandidateStopTimer = null;
144
1209
  let healthCheckTimer = null;
1210
+ let pingTimer = null;
1211
+ let pongTimeout = null;
145
1212
  let lastError = null;
146
1213
  const debugEnabled = process.env.PUBBLUE_LIVE_DEBUG === "1";
147
1214
  const versionFilePath = latestCliVersionPath();
148
1215
  let bridgeRunner = null;
1216
+ const commandHandler = createLiveCommandHandler({
1217
+ bridgeMode: config.bridgeMode,
1218
+ debugLog: (message, error) => debugLog(message, error),
1219
+ markError,
1220
+ sendCommandMessage: async (msg) => {
1221
+ if (!isLiveConnected()) return false;
1222
+ return sendOutboundMessageWithAck(CHANNELS.COMMAND, msg, {
1223
+ context: 'command outbound on "command"',
1224
+ maxAttempts: OUTBOUND_SEND_MAX_ATTEMPTS
1225
+ });
1226
+ }
1227
+ });
149
1228
  function debugLog(message, error) {
150
1229
  if (!debugEnabled) return;
151
1230
  const detail = error === void 0 ? "" : ` | ${error instanceof Error ? `${error.name}: ${error.message}` : typeof error === "string" ? error : JSON.stringify(error)}`;
@@ -155,11 +1234,8 @@ async function startDaemon(config) {
155
1234
  lastError = error === void 0 ? message : `${message}: ${errorMessage(error)}`;
156
1235
  debugLog(message, error);
157
1236
  }
158
- function clearPollingTimer() {
159
- if (pollingTimer) {
160
- clearTimeout(pollingTimer);
161
- pollingTimer = null;
162
- }
1237
+ function isLiveConnected() {
1238
+ return browserConnected && bridgePrimed;
163
1239
  }
164
1240
  function clearLocalCandidateTimers() {
165
1241
  if (localCandidateInterval) {
@@ -183,13 +1259,49 @@ async function startDaemon(config) {
183
1259
  heartbeatTimer = null;
184
1260
  }
185
1261
  }
1262
+ function startPingPong() {
1263
+ stopPingPong();
1264
+ pingTimer = setInterval(() => {
1265
+ if (!browserConnected || stopped) {
1266
+ stopPingPong();
1267
+ return;
1268
+ }
1269
+ const controlDc = channels.get(CONTROL_CHANNEL);
1270
+ if (!controlDc) return;
1271
+ try {
1272
+ controlDc.sendMessage(encodeMessage(makeEventMessage("ping")));
1273
+ if (pongTimeout) clearTimeout(pongTimeout);
1274
+ pongTimeout = setTimeout(() => {
1275
+ if (!browserConnected || stopped) return;
1276
+ debugLog("pong timeout \u2014 treating as disconnected");
1277
+ handleConnectionClosed("pong-timeout");
1278
+ }, PONG_TIMEOUT_MS);
1279
+ } catch (error) {
1280
+ debugLog("ping send failed", error);
1281
+ }
1282
+ }, PING_INTERVAL_MS);
1283
+ }
1284
+ function stopPingPong() {
1285
+ if (pingTimer) {
1286
+ clearInterval(pingTimer);
1287
+ pingTimer = null;
1288
+ }
1289
+ if (pongTimeout) {
1290
+ clearTimeout(pongTimeout);
1291
+ pongTimeout = null;
1292
+ }
1293
+ }
186
1294
  function runHealthCheck() {
187
1295
  if (stopped) return;
188
1296
  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();
1297
+ try {
1298
+ const latest = readLatestCliVersion(versionFilePath);
1299
+ if (latest && latest !== cliVersion2) {
1300
+ markError(`detected CLI upgrade (${cliVersion2} \u2192 ${latest}); shutting down`);
1301
+ void shutdown();
1302
+ }
1303
+ } catch (error) {
1304
+ markError("health check failed to read latest CLI version", error);
193
1305
  }
194
1306
  }
195
1307
  }
@@ -198,20 +1310,109 @@ async function startDaemon(config) {
198
1310
  healthCheckTimer = setInterval(runHealthCheck, HEALTH_CHECK_INTERVAL_MS);
199
1311
  runHealthCheck();
200
1312
  }
1313
+ function appendBufferedMessage(entry) {
1314
+ if (entry.channel === CHANNELS.CANVAS || entry.channel === CHANNELS.COMMAND) return;
1315
+ buffer.messages.push(entry);
1316
+ if (buffer.messages.length > MAX_BUFFERED_MESSAGES) {
1317
+ buffer.messages.splice(0, buffer.messages.length - MAX_BUFFERED_MESSAGES);
1318
+ }
1319
+ }
1320
+ function handleConnectionClosed(reason) {
1321
+ debugLog(`connection closed: ${reason}`);
1322
+ const hadConnection = browserConnected || bridgePrimed;
1323
+ browserConnected = false;
1324
+ bridgePrimed = false;
1325
+ bridgePriming = null;
1326
+ if (bridgeAbort) {
1327
+ bridgeAbort.abort();
1328
+ bridgeAbort = null;
1329
+ }
1330
+ if (!hadConnection) return;
1331
+ buffer.messages = [];
1332
+ failPendingAcks();
1333
+ stopPingPong();
1334
+ }
1335
+ function emitDeliveryStatus(params) {
1336
+ if (!params.messageId || params.channel === CONTROL_CHANNEL) return;
1337
+ const controlDc = channels.get(CONTROL_CHANNEL);
1338
+ const messageDc = channels.get(params.channel);
1339
+ const encoded = encodeMessage(
1340
+ makeDeliveryReceiptMessage({
1341
+ messageId: params.messageId,
1342
+ channel: params.channel,
1343
+ stage: params.stage,
1344
+ error: params.error
1345
+ })
1346
+ );
1347
+ try {
1348
+ if (controlDc?.isOpen()) {
1349
+ controlDc.sendMessage(encoded);
1350
+ return;
1351
+ }
1352
+ if (messageDc?.isOpen()) {
1353
+ messageDc.sendMessage(encoded);
1354
+ }
1355
+ } catch (error) {
1356
+ debugLog("failed to emit delivery status", error);
1357
+ }
1358
+ }
201
1359
  function setupChannel(name, dc) {
202
1360
  channels.set(name, dc);
203
1361
  dc.onOpen(() => {
204
1362
  if (name === CONTROL_CHANNEL) flushQueuedAcks();
205
1363
  });
1364
+ dc.onClosed(() => {
1365
+ channels.delete(name);
1366
+ pendingInboundBinaryMeta.delete(name);
1367
+ inboundStreams.delete(name);
1368
+ debugLog(`datachannel "${name}" closed`);
1369
+ });
1370
+ dc.onError((err) => {
1371
+ debugLog(`datachannel "${name}" error: ${err}`);
1372
+ });
206
1373
  dc.onMessage((data) => {
207
1374
  if (typeof data === "string") {
208
1375
  const msg = decodeMessage(data);
209
1376
  if (!msg) return;
210
1377
  const ack = parseAckMessage(msg);
211
1378
  if (ack) {
212
- settlePendingAck(ack.messageId, true);
1379
+ settlePendingAck(ack.messageId, ack.channel, true);
213
1380
  return;
214
1381
  }
1382
+ if (msg.type === "event" && msg.data === "pong") {
1383
+ if (pongTimeout) {
1384
+ clearTimeout(pongTimeout);
1385
+ pongTimeout = null;
1386
+ }
1387
+ return;
1388
+ }
1389
+ const duplicate = isDuplicateInboundMessage(name, msg.id);
1390
+ if (duplicate) {
1391
+ if (msg.type === "binary" && !msg.data) {
1392
+ pendingInboundBinaryMeta.set(name, msg);
1393
+ return;
1394
+ }
1395
+ if (shouldAcknowledgeMessage(name, msg)) {
1396
+ queueAck(msg.id, name);
1397
+ }
1398
+ return;
1399
+ }
1400
+ if (msg.type === "stream-start") {
1401
+ inboundStreams.set(name, { streamId: msg.id });
1402
+ }
1403
+ if (msg.type === "stream-end") {
1404
+ const stream = inboundStreams.get(name);
1405
+ const requestedStreamId = typeof msg.meta?.streamId === "string" ? msg.meta.streamId : void 0;
1406
+ if (!stream) {
1407
+ } else if (!requestedStreamId || requestedStreamId === stream.streamId) {
1408
+ emitDeliveryStatus({
1409
+ channel: name,
1410
+ messageId: stream.streamId,
1411
+ stage: "received"
1412
+ });
1413
+ inboundStreams.delete(name);
1414
+ }
1415
+ }
215
1416
  if (msg.type === "binary" && !msg.data) {
216
1417
  pendingInboundBinaryMeta.set(name, msg);
217
1418
  return;
@@ -219,33 +1420,71 @@ async function startDaemon(config) {
219
1420
  if (shouldAcknowledgeMessage(name, msg)) {
220
1421
  queueAck(msg.id, name);
221
1422
  }
222
- buffer.messages.push({ channel: name, msg, timestamp: Date.now() });
1423
+ if (name === CHANNELS.COMMAND) {
1424
+ commandProcessingQueue = commandProcessingQueue.then(() => commandHandler.onMessage(msg)).catch((error) => {
1425
+ markError("command message processing failed", error);
1426
+ });
1427
+ return;
1428
+ }
1429
+ appendBufferedMessage({ channel: name, msg, timestamp: Date.now() });
223
1430
  bridgeRunner?.enqueue([{ channel: name, msg }]);
1431
+ if (name !== CONTROL_CHANNEL && (msg.type === "text" || msg.type === "html" || msg.type === "binary" && !!msg.data)) {
1432
+ emitDeliveryStatus({ channel: name, messageId: msg.id, stage: "received" });
1433
+ }
224
1434
  return;
225
1435
  }
226
1436
  const pendingMeta = pendingInboundBinaryMeta.get(name);
1437
+ const activeStream = inboundStreams.get(name);
227
1438
  if (pendingMeta) pendingInboundBinaryMeta.delete(name);
1439
+ if (name === CHANNELS.COMMAND) {
1440
+ return;
1441
+ }
228
1442
  const binMsg = pendingMeta ? {
229
1443
  id: pendingMeta.id,
230
1444
  type: "binary",
231
1445
  data: data.toString("base64"),
232
- meta: { ...pendingMeta.meta, size: data.length }
1446
+ meta: {
1447
+ ...pendingMeta.meta,
1448
+ ...activeStream ? { streamId: activeStream.streamId } : {},
1449
+ size: data.length
1450
+ }
233
1451
  } : {
234
1452
  id: `bin-${Date.now()}`,
235
1453
  type: "binary",
236
1454
  data: data.toString("base64"),
237
- meta: { size: data.length }
1455
+ meta: {
1456
+ ...activeStream ? { streamId: activeStream.streamId } : {},
1457
+ size: data.length
1458
+ }
238
1459
  };
1460
+ if (isDuplicateInboundMessage(name, binMsg.id)) {
1461
+ if (shouldAcknowledgeMessage(name, binMsg)) {
1462
+ queueAck(binMsg.id, name);
1463
+ }
1464
+ return;
1465
+ }
239
1466
  if (shouldAcknowledgeMessage(name, binMsg)) {
240
1467
  queueAck(binMsg.id, name);
241
1468
  }
242
- buffer.messages.push({ channel: name, msg: binMsg, timestamp: Date.now() });
1469
+ appendBufferedMessage({ channel: name, msg: binMsg, timestamp: Date.now() });
243
1470
  bridgeRunner?.enqueue([{ channel: name, msg: binMsg }]);
1471
+ if (!activeStream) {
1472
+ emitDeliveryStatus({ channel: name, messageId: binMsg.id, stage: "received" });
1473
+ }
244
1474
  });
245
1475
  }
246
1476
  function getAckKey(messageId, channel) {
247
1477
  return `${channel}:${messageId}`;
248
1478
  }
1479
+ function isDuplicateInboundMessage(channel, messageId) {
1480
+ const key = `${channel}:${messageId}`;
1481
+ if (seenInboundMessageKeys.has(key)) return true;
1482
+ seenInboundMessageKeys.add(key);
1483
+ if (seenInboundMessageKeys.size > MAX_SEEN_INBOUND_MESSAGES) {
1484
+ seenInboundMessageKeys.clear();
1485
+ }
1486
+ return false;
1487
+ }
249
1488
  function queueAck(messageId, channel) {
250
1489
  pendingOutboundAcks.set(getAckKey(messageId, channel), { messageId, channel });
251
1490
  flushQueuedAcks();
@@ -283,27 +1522,34 @@ async function startDaemon(config) {
283
1522
  }
284
1523
  }
285
1524
  }
286
- function waitForDeliveryAck(messageId, timeoutMs) {
1525
+ function waitForDeliveryAck(messageId, channel, timeoutMs) {
287
1526
  return new Promise((resolve) => {
1527
+ const key = getAckKey(messageId, channel);
1528
+ const existing = pendingDeliveryAcks.get(key);
1529
+ if (existing) {
1530
+ clearTimeout(existing.timeout);
1531
+ pendingDeliveryAcks.delete(key);
1532
+ }
288
1533
  const timeout = setTimeout(() => {
289
- pendingDeliveryAcks.delete(messageId);
1534
+ pendingDeliveryAcks.delete(key);
290
1535
  resolve(false);
291
1536
  }, timeoutMs);
292
- pendingDeliveryAcks.set(messageId, { resolve, timeout });
1537
+ pendingDeliveryAcks.set(key, { resolve, timeout });
293
1538
  });
294
1539
  }
295
- function settlePendingAck(messageId, received) {
296
- const pending = pendingDeliveryAcks.get(messageId);
1540
+ function settlePendingAck(messageId, channel, received) {
1541
+ const key = getAckKey(messageId, channel);
1542
+ const pending = pendingDeliveryAcks.get(key);
297
1543
  if (!pending) return;
298
1544
  clearTimeout(pending.timeout);
299
- pendingDeliveryAcks.delete(messageId);
1545
+ pendingDeliveryAcks.delete(key);
300
1546
  pending.resolve(received);
301
1547
  }
302
1548
  function failPendingAcks() {
303
- for (const [messageId, pending] of pendingDeliveryAcks) {
1549
+ for (const [ackKey, pending] of pendingDeliveryAcks) {
304
1550
  clearTimeout(pending.timeout);
305
1551
  pending.resolve(false);
306
- pendingDeliveryAcks.delete(messageId);
1552
+ pendingDeliveryAcks.delete(ackKey);
307
1553
  }
308
1554
  }
309
1555
  function openDataChannel(name) {
@@ -331,30 +1577,45 @@ async function startDaemon(config) {
331
1577
  });
332
1578
  });
333
1579
  }
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) {
1580
+ async function sendOutboundMessageWithAck(channel, msg, options) {
1581
+ const maxAttempts = options?.maxAttempts ?? OUTBOUND_SEND_MAX_ATTEMPTS;
1582
+ const context = options?.context ?? `channel "${channel}"`;
1583
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
1584
+ if (stopped || !browserConnected) return false;
1585
+ let targetDc;
349
1586
  try {
350
- let targetDc = channels.get(channel);
351
- if (!targetDc) targetDc = openDataChannel(channel);
352
- await waitForChannelOpen(targetDc, 3e3);
353
- targetDc.sendMessage(encodeMessage(msg));
1587
+ targetDc = channels.get(channel) ?? openDataChannel(channel);
1588
+ await waitForChannelOpen(targetDc);
354
1589
  } catch (error) {
355
- debugLog(`sticky outbound replay failed for channel ${channel}`, error);
1590
+ markError(`${context} failed to open (attempt ${attempt}/${maxAttempts})`, error);
1591
+ continue;
356
1592
  }
1593
+ const waitForAck = shouldAcknowledgeMessage(channel, msg) ? waitForDeliveryAck(msg.id, channel, WRITE_ACK_TIMEOUT_MS) : null;
1594
+ try {
1595
+ if (msg.type === "binary" && options?.binaryPayload) {
1596
+ targetDc.sendMessage(
1597
+ encodeMessage({
1598
+ ...msg,
1599
+ meta: { ...msg.meta || {}, size: options.binaryPayload.length }
1600
+ })
1601
+ );
1602
+ targetDc.sendMessageBinary(options.binaryPayload);
1603
+ } else {
1604
+ targetDc.sendMessage(encodeMessage(msg));
1605
+ }
1606
+ } catch (error) {
1607
+ if (waitForAck) settlePendingAck(msg.id, channel, false);
1608
+ markError(`${context} failed to send (attempt ${attempt}/${maxAttempts})`, error);
1609
+ continue;
1610
+ }
1611
+ if (!waitForAck) return true;
1612
+ const acked = await waitForAck;
1613
+ if (acked) return true;
1614
+ markError(
1615
+ `${context} delivery ack timeout for message ${msg.id} (attempt ${attempt}/${maxAttempts})`
1616
+ );
357
1617
  }
1618
+ return false;
358
1619
  }
359
1620
  function attachPeerHandlers(currentPeer) {
360
1621
  currentPeer.onLocalCandidate((candidate, mid) => {
@@ -364,15 +1625,21 @@ async function startDaemon(config) {
364
1625
  currentPeer.onStateChange((state) => {
365
1626
  if (stopped || currentPeer !== peer) return;
366
1627
  if (state === "connected") {
367
- connected = true;
1628
+ browserConnected = true;
368
1629
  flushQueuedAcks();
369
- void replayStickyOutboundMessages();
1630
+ startPingPong();
1631
+ void ensureBridgePrimed();
370
1632
  return;
371
1633
  }
372
1634
  if (state === "disconnected" || state === "failed" || state === "closed") {
373
- const wasConnected = connected;
374
- connected = false;
375
- if (wasConnected) void persistCanvasContent();
1635
+ handleConnectionClosed(`peer-state:${state}`);
1636
+ }
1637
+ });
1638
+ currentPeer.onIceStateChange((state) => {
1639
+ if (stopped || currentPeer !== peer) return;
1640
+ debugLog(`ICE state: ${state}`);
1641
+ if ((state === "disconnected" || state === "failed") && browserConnected) {
1642
+ handleConnectionClosed(`ice-state:${state}`);
376
1643
  }
377
1644
  });
378
1645
  currentPeer.onDataChannel((dc) => {
@@ -387,6 +1654,8 @@ async function startDaemon(config) {
387
1654
  peer = nextPeer;
388
1655
  channels = /* @__PURE__ */ new Map();
389
1656
  pendingInboundBinaryMeta = /* @__PURE__ */ new Map();
1657
+ inboundStreams = /* @__PURE__ */ new Map();
1658
+ seenInboundMessageKeys = /* @__PURE__ */ new Set();
390
1659
  attachPeerHandlers(nextPeer);
391
1660
  }
392
1661
  function closeCurrentPeer() {
@@ -400,6 +1669,8 @@ async function startDaemon(config) {
400
1669
  }
401
1670
  channels.clear();
402
1671
  pendingInboundBinaryMeta.clear();
1672
+ inboundStreams.clear();
1673
+ seenInboundMessageKeys.clear();
403
1674
  if (peer) {
404
1675
  try {
405
1676
  peer.close();
@@ -410,13 +1681,19 @@ async function startDaemon(config) {
410
1681
  }
411
1682
  }
412
1683
  function resetNegotiationState() {
413
- connected = false;
1684
+ browserConnected = false;
1685
+ bridgePrimed = false;
1686
+ bridgePriming = null;
1687
+ buffer.messages = [];
414
1688
  failPendingAcks();
1689
+ stopPingPong();
415
1690
  lastAppliedBrowserOffer = null;
416
1691
  lastBrowserCandidateCount = 0;
417
1692
  lastSentCandidateCount = 0;
418
1693
  localCandidates.length = 0;
419
1694
  clearLocalCandidateTimers();
1695
+ inboundStreams.clear();
1696
+ seenInboundMessageKeys.clear();
420
1697
  }
421
1698
  function startLocalCandidateFlush(slug) {
422
1699
  clearLocalCandidateTimers();
@@ -424,7 +1701,7 @@ async function startDaemon(config) {
424
1701
  if (localCandidates.length <= lastSentCandidateCount) return;
425
1702
  const newOnes = localCandidates.slice(lastSentCandidateCount);
426
1703
  lastSentCandidateCount = localCandidates.length;
427
- await apiClient2.signalAnswer({ slug, candidates: newOnes }).catch((error) => {
1704
+ await apiClient2.signalAnswer({ slug, daemonSessionId, candidates: newOnes }).catch((error) => {
428
1705
  debugLog("failed to publish local ICE candidates", error);
429
1706
  });
430
1707
  }, LOCAL_CANDIDATE_FLUSH_MS);
@@ -436,7 +1713,6 @@ async function startDaemon(config) {
436
1713
  if (recovering) return;
437
1714
  recovering = true;
438
1715
  try {
439
- await persistCanvasContent();
440
1716
  await stopBridge();
441
1717
  closeCurrentPeer();
442
1718
  createPeer();
@@ -445,71 +1721,42 @@ async function startDaemon(config) {
445
1721
  const answer = await createAnswer(peer, browserOffer, OFFER_TIMEOUT_MS);
446
1722
  lastAppliedBrowserOffer = browserOffer;
447
1723
  activeSlug = slug;
448
- await apiClient2.signalAnswer({ slug, answer, agentName: agentName2 });
1724
+ await apiClient2.signalAnswer({ slug, daemonSessionId, answer, agentName: agentName2 });
449
1725
  startLocalCandidateFlush(slug);
450
- void startBridge();
451
1726
  } catch (error) {
452
1727
  markError("failed to handle incoming live request", error);
453
1728
  } finally {
454
1729
  recovering = false;
455
1730
  }
456
1731
  }
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;
1732
+ async function applyBrowserCandidates(candidatePayloads) {
1733
+ for (const c of candidatePayloads) {
1734
+ try {
1735
+ const parsed = JSON.parse(c);
1736
+ if (typeof parsed.candidate !== "string") continue;
1737
+ const sdpMid = typeof parsed.sdpMid === "string" ? parsed.sdpMid : "0";
1738
+ if (!peer) continue;
1739
+ peer.addRemoteCandidate(parsed.candidate, sdpMid);
1740
+ } catch (error) {
1741
+ debugLog("failed to parse/apply browser ICE candidate", error);
504
1742
  }
505
- markError("signaling poll failed", error);
506
1743
  }
507
- const baseDelay = getSignalPollDelayMs({
508
- hasActiveConnection: connected,
509
- retryAfterSeconds
510
- });
511
- scheduleNextPoll(baseDelay);
512
1744
  }
1745
+ const signaling = createSignalingController({
1746
+ apiClient: apiClient2,
1747
+ daemonSessionId,
1748
+ debugLog,
1749
+ markError,
1750
+ isStopped: () => stopped,
1751
+ getActiveSlug: () => activeSlug,
1752
+ getLastAppliedBrowserOffer: () => lastAppliedBrowserOffer,
1753
+ getLastBrowserCandidateCount: () => lastBrowserCandidateCount,
1754
+ setLastBrowserCandidateCount: (count) => {
1755
+ lastBrowserCandidateCount = count;
1756
+ },
1757
+ onRecover: handleIncomingLive,
1758
+ onApplyBrowserCandidates: applyBrowserCandidates
1759
+ });
513
1760
  if (fs.existsSync(socketPath2)) {
514
1761
  let stale = true;
515
1762
  try {
@@ -530,36 +1777,46 @@ async function startDaemon(config) {
530
1777
  throw new Error(`Daemon already running (socket: ${socketPath2})`);
531
1778
  }
532
1779
  }
533
- await apiClient2.goOnline();
1780
+ await apiClient2.goOnline({ daemonSessionId, agentName: agentName2 });
534
1781
  heartbeatTimer = setInterval(async () => {
535
1782
  if (stopped) return;
536
1783
  try {
537
- await apiClient2.heartbeat();
1784
+ await apiClient2.heartbeat({ daemonSessionId });
538
1785
  } catch (error) {
539
1786
  markError("heartbeat failed", error);
540
1787
  }
541
1788
  }, 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
- });
1789
+ const handleIpcRequest = createDaemonIpcHandler({
1790
+ apiClient: apiClient2,
1791
+ bindCanvasCommands: (html) => commandHandler.bindFromHtml(html),
1792
+ getConnected: () => isLiveConnected(),
1793
+ getSignalingConnected: () => {
1794
+ const state = signaling.status();
1795
+ return state.known ? state.open : null;
1796
+ },
1797
+ getActiveSlug: () => activeSlug,
1798
+ getUptimeSeconds: () => Math.floor((Date.now() - startTime) / 1e3),
1799
+ getChannels: () => [...channels.keys()],
1800
+ getBufferedMessages: () => buffer.messages,
1801
+ setBufferedMessages: (messages) => {
1802
+ buffer.messages = messages;
1803
+ },
1804
+ getLastError: () => lastError,
1805
+ getBridgeMode: () => config.bridgeMode ?? null,
1806
+ getBridgeStatus: () => bridgeRunner?.status() ?? null,
1807
+ getWriteReadinessError: () => getLiveWriteReadinessError(isLiveConnected()),
1808
+ openDataChannel,
1809
+ waitForChannelOpen,
1810
+ waitForDeliveryAck,
1811
+ settlePendingAck,
1812
+ markError,
1813
+ shutdown: () => {
1814
+ void shutdown();
1815
+ },
1816
+ writeAckTimeoutMs: WRITE_ACK_TIMEOUT_MS,
1817
+ writeAckMaxAttempts: OUTBOUND_SEND_MAX_ATTEMPTS
562
1818
  });
1819
+ const ipcServer = createDaemonIpcServer(handleIpcRequest);
563
1820
  ipcServer.listen(socketPath2);
564
1821
  const infoDir = path.dirname(infoPath2);
565
1822
  if (!fs.existsSync(infoDir)) fs.mkdirSync(infoDir, { recursive: true });
@@ -568,74 +1825,115 @@ async function startDaemon(config) {
568
1825
  JSON.stringify({ pid: process.pid, socketPath: socketPath2, startedAt: startTime, cliVersion: cliVersion2 })
569
1826
  );
570
1827
  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);
1828
+ signaling.start();
1829
+ async function sendOnChannel(channel, msg) {
1830
+ if (stopped || !isLiveConnected()) return false;
1831
+ return sendOutboundMessageWithAck(channel, msg, {
1832
+ context: `bridge outbound on "${channel}"`,
1833
+ maxAttempts: OUTBOUND_SEND_MAX_ATTEMPTS
1834
+ });
1835
+ }
1836
+ async function buildInitialSessionBriefing(params) {
1837
+ const pub = await apiClient2.get(params.slug);
1838
+ const content = typeof pub.content === "string" ? pub.content : "";
1839
+ if (content.length > 0) {
1840
+ commandHandler.bindFromHtml(content);
589
1841
  }
1842
+ const canvasContentFilePath = content.length > 0 ? writeLiveSessionContentFile({
1843
+ slug: params.slug,
1844
+ contentType: pub.contentType,
1845
+ content
1846
+ }) : void 0;
1847
+ return buildSessionBriefing(
1848
+ params.slug,
1849
+ {
1850
+ title: pub.title,
1851
+ contentType: pub.contentType,
1852
+ isPublic: pub.isPublic,
1853
+ canvasContentFilePath
1854
+ },
1855
+ params.instructions
1856
+ );
590
1857
  }
591
- async function startBridge() {
592
- if (stopped || !activeSlug) return;
593
- if (!config.bridgeMode) return;
1858
+ async function startBridge(slug) {
1859
+ if (stopped) return;
1860
+ if (!config.bridgeMode) {
1861
+ throw new Error("Bridge mode is required for live session bootstrap.");
1862
+ }
1863
+ if (activeSlug !== slug) return;
594
1864
  await stopBridge();
1865
+ const abort = new AbortController();
1866
+ bridgeAbort = abort;
595
1867
  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);
1868
+ const sessionBriefing = await buildInitialSessionBriefing({ slug, instructions });
1869
+ const bridgeConfig = {
1870
+ slug,
1871
+ sessionBriefing,
1872
+ sendMessage: sendOnChannel,
1873
+ onDeliveryUpdate: ({
1874
+ channel,
1875
+ messageId,
1876
+ stage,
1877
+ error
1878
+ }) => {
1879
+ emitDeliveryStatus({ channel, messageId, stage, error });
1880
+ },
1881
+ debugLog,
1882
+ instructions
1883
+ };
1884
+ const runner = config.bridgeMode === "claude-sdk" ? await createClaudeSdkBridgeRunner(bridgeConfig, abort.signal) : config.bridgeMode === "claude-code" ? await createClaudeCodeBridgeRunner(bridgeConfig, abort.signal) : await createOpenClawBridgeRunner(bridgeConfig);
1885
+ if (stopped || activeSlug !== slug || abort.signal.aborted) {
1886
+ await runner.stop();
1887
+ return;
601
1888
  }
1889
+ bridgeRunner = runner;
1890
+ }
1891
+ async function ensureBridgePrimed() {
1892
+ if (stopped || !browserConnected || bridgePrimed || bridgePriming || !activeSlug) return;
1893
+ const slug = activeSlug;
1894
+ const primePromise = (async () => {
1895
+ try {
1896
+ await startBridge(slug);
1897
+ if (stopped || !browserConnected || activeSlug !== slug) return;
1898
+ bridgePrimed = true;
1899
+ debugLog(`bridge primed for "${slug}"`);
1900
+ } catch (error) {
1901
+ bridgePrimed = false;
1902
+ markError(`failed to prime bridge session for "${slug}"`, error);
1903
+ } finally {
1904
+ bridgePriming = null;
1905
+ }
1906
+ })();
1907
+ bridgePriming = primePromise;
1908
+ await primePromise;
602
1909
  }
603
1910
  async function stopBridge() {
1911
+ bridgePrimed = false;
1912
+ bridgePriming = null;
1913
+ if (bridgeAbort) {
1914
+ bridgeAbort.abort();
1915
+ bridgeAbort = null;
1916
+ }
604
1917
  if (bridgeRunner) {
605
1918
  await bridgeRunner.stop();
606
1919
  bridgeRunner = null;
607
1920
  }
608
1921
  }
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
1922
  async function cleanup() {
626
1923
  if (stopped) return;
627
1924
  stopped = true;
628
- clearPollingTimer();
629
1925
  clearLocalCandidateTimers();
630
1926
  clearHealthCheckTimer();
631
1927
  clearHeartbeatTimer();
1928
+ stopPingPong();
1929
+ await signaling.stop();
632
1930
  try {
633
- await apiClient2.goOffline();
1931
+ await apiClient2.goOffline({ daemonSessionId });
634
1932
  } catch (error) {
635
1933
  debugLog("failed to go offline", error);
636
1934
  }
637
- await persistCanvasContent();
638
1935
  await stopBridge();
1936
+ commandHandler.stop();
639
1937
  closeCurrentPeer();
640
1938
  ipcServer.close();
641
1939
  try {
@@ -649,107 +1947,27 @@ async function startDaemon(config) {
649
1947
  debugLog("failed to remove daemon info file during cleanup", error);
650
1948
  }
651
1949
  }
652
- async function shutdown() {
1950
+ let shuttingDown = false;
1951
+ async function shutdown(exitCode = 0) {
1952
+ if (shuttingDown) return;
1953
+ shuttingDown = true;
653
1954
  await cleanup();
654
- process.exit(0);
1955
+ process.exit(exitCode);
655
1956
  }
656
1957
  process.on("SIGTERM", () => {
657
- void shutdown();
1958
+ void shutdown(0);
658
1959
  });
659
1960
  process.on("SIGINT", () => {
660
- void shutdown();
1961
+ void shutdown(0);
1962
+ });
1963
+ process.on("uncaughtException", (error) => {
1964
+ markError("uncaught exception in daemon", error);
1965
+ void shutdown(1);
1966
+ });
1967
+ process.on("unhandledRejection", (reason) => {
1968
+ markError("unhandled rejection in daemon", reason);
1969
+ void shutdown(1);
661
1970
  });
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
1971
  }
754
1972
 
755
1973
  // src/live-daemon-entry.ts