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