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.
- package/dist/chunk-5ODXW2EM.js +1927 -0
- package/dist/index.js +958 -256
- package/dist/live-daemon-entry.js +1546 -328
- package/dist/sdk-IV5ZYS3G.js +12299 -0
- package/package.json +6 -2
- package/dist/chunk-AZQD654L.js +0 -1489
|
@@ -2,22 +2,29 @@ import {
|
|
|
2
2
|
CHANNELS,
|
|
3
3
|
CONTROL_CHANNEL,
|
|
4
4
|
PubApiClient,
|
|
5
|
-
|
|
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
|
-
|
|
16
|
-
|
|
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-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
265
|
+
fn();
|
|
39
266
|
};
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
46
|
-
if (
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
69
|
-
canvasHint: "Canvas
|
|
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
|
|
72
|
-
"The
|
|
73
|
-
"Always `
|
|
74
|
-
|
|
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
|
|
79
|
-
canvasHint: "Canvas
|
|
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
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
159
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
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: {
|
|
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: {
|
|
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
|
-
|
|
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(
|
|
1534
|
+
pendingDeliveryAcks.delete(key);
|
|
290
1535
|
resolve(false);
|
|
291
1536
|
}, timeoutMs);
|
|
292
|
-
pendingDeliveryAcks.set(
|
|
1537
|
+
pendingDeliveryAcks.set(key, { resolve, timeout });
|
|
293
1538
|
});
|
|
294
1539
|
}
|
|
295
|
-
function settlePendingAck(messageId, received) {
|
|
296
|
-
const
|
|
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(
|
|
1545
|
+
pendingDeliveryAcks.delete(key);
|
|
300
1546
|
pending.resolve(received);
|
|
301
1547
|
}
|
|
302
1548
|
function failPendingAcks() {
|
|
303
|
-
for (const [
|
|
1549
|
+
for (const [ackKey, pending] of pendingDeliveryAcks) {
|
|
304
1550
|
clearTimeout(pending.timeout);
|
|
305
1551
|
pending.resolve(false);
|
|
306
|
-
pendingDeliveryAcks.delete(
|
|
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
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
-
|
|
351
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1628
|
+
browserConnected = true;
|
|
368
1629
|
flushQueuedAcks();
|
|
369
|
-
|
|
1630
|
+
startPingPong();
|
|
1631
|
+
void ensureBridgePrimed();
|
|
370
1632
|
return;
|
|
371
1633
|
}
|
|
372
1634
|
if (state === "disconnected" || state === "failed" || state === "closed") {
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
|
|
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
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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
|
-
|
|
572
|
-
function sendOnChannel(channel, msg) {
|
|
573
|
-
if (stopped || !
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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
|
|
593
|
-
if (!config.bridgeMode)
|
|
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
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
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
|
-
|
|
1950
|
+
let shuttingDown = false;
|
|
1951
|
+
async function shutdown(exitCode = 0) {
|
|
1952
|
+
if (shuttingDown) return;
|
|
1953
|
+
shuttingDown = true;
|
|
653
1954
|
await cleanup();
|
|
654
|
-
process.exit(
|
|
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
|