carouselbot 0.2.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/src/daemon.mjs ADDED
@@ -0,0 +1,717 @@
1
+ #!/usr/bin/env node
2
+ import { createServer } from "node:http";
3
+ import { randomBytes, randomUUID } from "node:crypto";
4
+ import { appendFile, mkdir, open, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
5
+ import { basename, extname } from "node:path";
6
+ import {
7
+ ALLOWED_ORIGINS, AUDIT_LOG_PATH, BRIDGE_HOST, BRIDGE_PORT, BRIDGE_URL, DAEMON_LOCK_PATH,
8
+ DAEMON_STATE_PATH, PACKAGE_NAME, PACKAGE_VERSION, PROTOCOL_VERSION, STATE_DIRECTORY,
9
+ } from "./config.mjs";
10
+
11
+ const MAX_JSON_BYTES = 40 * 1024 * 1024;
12
+ const MAX_MEDIA_BYTES = 25 * 1024 * 1024;
13
+ const EDITOR_TTL_MS = Number(process.env.CAROUSELBOT_EDITOR_TTL_MS || process.env.SLIDE_STUDIO_EDITOR_TTL_MS) || 60_000;
14
+ const CLIENT_TTL_MS = 45_000;
15
+ const MEDIA_TTL_MS = 5 * 60_000;
16
+ const COMMAND_TIMEOUT_MS = 90_000;
17
+ const EDIT_SESSION_TTL_MS = Number(process.env.CAROUSELBOT_EDIT_SESSION_TTL_MS || process.env.SLIDE_STUDIO_EDIT_SESSION_TTL_MS) || 5 * 60_000;
18
+ const MAX_AUDIT_EVENTS = 500;
19
+ const MAX_AUDIT_BYTES = 2 * 1024 * 1024;
20
+ const EVENT_POLL_TIMEOUT_MS = Number(process.env.CAROUSELBOT_EVENT_POLL_TIMEOUT_MS || process.env.SLIDE_STUDIO_EVENT_POLL_TIMEOUT_MS) || 500;
21
+ const MAX_EVENT_POLL_TIMEOUT_MS = 5_000;
22
+ const daemonSecret = randomBytes(32).toString("base64url");
23
+ const editors = new Map();
24
+ const clients = new Map();
25
+ const inflight = new Map();
26
+ const media = new Map();
27
+ const editSessions = new Map();
28
+ const auditEvents = [];
29
+ let focusedEditorId = null;
30
+ let lockHandle = null;
31
+ let idleSince = null;
32
+ let auditWrite = Promise.resolve();
33
+
34
+ function log(message) {
35
+ process.stderr.write(`[carouselbot-daemon] ${message}\n`);
36
+ }
37
+
38
+ function editorHasInflightCommand(editorId) {
39
+ for (const pending of inflight.values()) if (pending.editorId === editorId) return true;
40
+ return false;
41
+ }
42
+
43
+ function activeEditors() {
44
+ const cutoff = Date.now() - EDITOR_TTL_MS;
45
+ return [...editors.values()].filter((editor) => (
46
+ editor.lastSeen >= cutoff
47
+ || Boolean(editor.poll && !editor.poll.destroyed && !editor.poll.writableEnded)
48
+ || editorHasInflightCommand(editor.id)
49
+ ));
50
+ }
51
+
52
+ function activeClients() {
53
+ const cutoff = Date.now() - CLIENT_TTL_MS;
54
+ return [...clients.values()].filter((client) => client.lastSeen >= cutoff);
55
+ }
56
+
57
+ function publicClient(client) {
58
+ return { id: client.id, name: client.name || "MCP agent", version: client.version || null };
59
+ }
60
+
61
+ function codedError(code, message, details = {}) {
62
+ const error = new Error(`[${code}] ${message}`);
63
+ error.code = code;
64
+ error.details = details;
65
+ return error;
66
+ }
67
+
68
+ function publicSession(session) {
69
+ return {
70
+ id: session.id,
71
+ editSessionId: session.id,
72
+ editorId: session.editorId,
73
+ projectId: session.projectId || null,
74
+ purpose: session.purpose,
75
+ owner: session.owner,
76
+ createdAt: session.createdAt,
77
+ leaseExpiresAt: session.lastSeen + EDIT_SESSION_TTL_MS,
78
+ };
79
+ }
80
+
81
+ function broadcastEditSessions() {
82
+ const event = { kind: "system", type: "edit-sessions.changed", editSessions: activeEditSessions().map(publicSession) };
83
+ for (const editor of activeEditors()) queueEditorEvent(editor, event);
84
+ }
85
+
86
+ function activeEditSessions() {
87
+ const cutoff = Date.now() - EDIT_SESSION_TTL_MS;
88
+ const activeEditorIds = new Set(activeEditors().map((editor) => editor.id));
89
+ return [...editSessions.values()].filter((session) => session.lastSeen >= cutoff && activeEditorIds.has(session.editorId));
90
+ }
91
+
92
+ function releaseEditSession(sessionId, reason = "released") {
93
+ const session = editSessions.get(sessionId);
94
+ if (!session) return null;
95
+ editSessions.delete(sessionId);
96
+ for (const client of clients.values()) if (client.implicitSessionId === sessionId) client.implicitSessionId = null;
97
+ recordAudit({ action: "edit_session.end", status: "ok", session, message: reason });
98
+ broadcastEditSessions();
99
+ return session;
100
+ }
101
+
102
+ function recordAudit({ action, status = "ok", client = null, session = null, editorId = null, projectId = null, toolName = null, revision = null, message = null }) {
103
+ const event = {
104
+ id: randomUUID(), at: new Date().toISOString(), action, status,
105
+ ...(client ? { client: publicClient(client) } : {}),
106
+ ...(session ? { editSessionId: session.id, owner: session.owner } : {}),
107
+ ...(editorId || session?.editorId ? { editorId: editorId || session.editorId } : {}),
108
+ ...(projectId || session?.projectId ? { projectId: projectId || session.projectId } : {}),
109
+ ...(toolName ? { toolName } : {}),
110
+ ...(revision != null ? { revision } : {}),
111
+ ...(message ? { message: String(message).slice(0, 300) } : {}),
112
+ };
113
+ auditEvents.push(event);
114
+ if (auditEvents.length > MAX_AUDIT_EVENTS) auditEvents.splice(0, auditEvents.length - MAX_AUDIT_EVENTS);
115
+ auditWrite = auditWrite.then(() => appendFile(AUDIT_LOG_PATH, `${JSON.stringify(event)}\n`, { mode: 0o600 })).catch((error) => log(`Could not write operation audit: ${error.message}`));
116
+ return event;
117
+ }
118
+
119
+ function sessionForEditor(editorId) {
120
+ return activeEditSessions().find((session) => session.editorId === editorId) || null;
121
+ }
122
+
123
+ function sessionForProject(projectId) {
124
+ return projectId ? activeEditSessions().find((session) => session.projectId === projectId) || null : null;
125
+ }
126
+
127
+ function requireEditSession(sessionId) {
128
+ const session = editSessions.get(sessionId);
129
+ if (!session || session.lastSeen < Date.now() - EDIT_SESSION_TTL_MS) {
130
+ if (session) releaseEditSession(session.id, "lease expired");
131
+ throw codedError("EDIT_SESSION_EXPIRED", "The edit session is missing or expired. Begin a new edit session and retry.");
132
+ }
133
+ if (!activeEditors().some((editor) => editor.id === session.editorId)) {
134
+ releaseEditSession(session.id, "editor disconnected");
135
+ throw codedError("EDITOR_DISCONNECTED", "The browser tab assigned to this edit session is no longer connected.");
136
+ }
137
+ session.lastSeen = Date.now();
138
+ return session;
139
+ }
140
+
141
+ function claimProject(session, projectId) {
142
+ if (!projectId) return;
143
+ const conflict = sessionForProject(projectId);
144
+ if (conflict && conflict.id !== session.id) {
145
+ recordAudit({ action: "edit_session.conflict", status: "blocked", session, projectId, message: `Project held by ${conflict.owner.name}` });
146
+ throw codedError("PROJECT_BUSY", `Project ${projectId} is being edited by ${conflict.owner.name} (${conflict.purpose}). Use a different project or wait for edit session ${conflict.id} to end.`, { session: publicSession(conflict) });
147
+ }
148
+ if (session.projectId && session.projectId !== projectId) {
149
+ if (session.implicit) {
150
+ session.projectId = projectId;
151
+ return;
152
+ }
153
+ throw codedError("SESSION_PROJECT_MISMATCH", `This edit session is assigned to project ${session.projectId}. End it and begin another session for ${projectId}.`);
154
+ }
155
+ session.projectId = projectId;
156
+ }
157
+
158
+ function beginEditSession(client, { editorId, projectId, purpose }) {
159
+ const connected = activeEditors();
160
+ if (!connected.length) throw codedError("NO_EDITOR", "No CarouselBot editor is connected. Open the editor in the user's normal browser and click Connect AI.");
161
+ let editor = editorId ? connected.find((item) => item.id === editorId) : null;
162
+ if (editorId && !editor) throw codedError("EDITOR_DISCONNECTED", `Editor is not connected: ${editorId}`);
163
+ if (!editor) {
164
+ const selected = client.selectedEditorId && connected.find((item) => item.id === client.selectedEditorId);
165
+ const available = connected.filter((item) => !sessionForEditor(item.id));
166
+ editor = selected && !sessionForEditor(selected.id) ? selected : available.length === 1 ? available[0] : null;
167
+ if (!editor) throw codedError("EDITOR_SELECTION_REQUIRED", "Multiple browser tabs are available. Call list_editors, choose an unassigned editor, then begin_edit_session with editorId.");
168
+ }
169
+ const editorConflict = sessionForEditor(editor.id);
170
+ if (editorConflict) throw codedError("EDITOR_BUSY", `Editor ${editor.id} is assigned to ${editorConflict.owner.name} (${editorConflict.purpose}) until ${new Date(editorConflict.lastSeen + EDIT_SESSION_TTL_MS).toISOString()}.`, { session: publicSession(editorConflict) });
171
+ const projectConflict = sessionForProject(projectId);
172
+ if (projectConflict) throw codedError("PROJECT_BUSY", `Project ${projectId} is being edited by ${projectConflict.owner.name} (${projectConflict.purpose}).`, { session: publicSession(projectConflict) });
173
+ const now = Date.now();
174
+ const session = {
175
+ id: randomUUID(), editorId: editor.id, projectId: projectId || null,
176
+ purpose: String(purpose || "Edit CarouselBot").slice(0, 160), owner: publicClient(client),
177
+ creatorClientId: client.id, lastClientId: client.id, implicit: false, createdAt: now, lastSeen: now,
178
+ };
179
+ editSessions.set(session.id, session);
180
+ client.selectedEditorId = editor.id;
181
+ recordAudit({ action: "edit_session.begin", client, session });
182
+ broadcastEditSessions();
183
+ return session;
184
+ }
185
+
186
+ function browserCors(origin) {
187
+ if (!origin || !ALLOWED_ORIGINS.has(origin)) return null;
188
+ return {
189
+ "Access-Control-Allow-Origin": origin,
190
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
191
+ "Access-Control-Allow-Headers": "Authorization, Content-Type",
192
+ "Access-Control-Expose-Headers": "X-CarouselBot-Filename, X-Slide-Studio-Filename",
193
+ "Access-Control-Allow-Private-Network": "true",
194
+ "Access-Control-Max-Age": "600",
195
+ "Cache-Control": "no-store",
196
+ Vary: "Origin",
197
+ };
198
+ }
199
+
200
+ function sendJson(response, statusCode, value, headers = {}) {
201
+ const body = JSON.stringify(value);
202
+ response.writeHead(statusCode, { "Content-Type": "application/json; charset=utf-8", "Content-Length": Buffer.byteLength(body), "Cache-Control": "no-store", ...headers });
203
+ response.end(body);
204
+ }
205
+
206
+ function readJson(request) {
207
+ return new Promise((resolve, reject) => {
208
+ const chunks = [];
209
+ let size = 0;
210
+ request.on("data", (chunk) => {
211
+ size += chunk.length;
212
+ if (size > MAX_JSON_BYTES) {
213
+ reject(new Error("Request body is too large."));
214
+ request.destroy();
215
+ return;
216
+ }
217
+ chunks.push(chunk);
218
+ });
219
+ request.on("end", () => {
220
+ try { resolve(JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}")); }
221
+ catch { reject(new Error("Request body must be valid JSON.")); }
222
+ });
223
+ request.on("error", reject);
224
+ });
225
+ }
226
+
227
+ function bearer(request) {
228
+ const value = request.headers.authorization || "";
229
+ return value.startsWith("Bearer ") ? value.slice(7) : null;
230
+ }
231
+
232
+ function requireInternal(request, response) {
233
+ if (bearer(request) === daemonSecret) return true;
234
+ sendJson(response, 401, { error: "Unauthorized." });
235
+ return false;
236
+ }
237
+
238
+ function requireEditor(request, response, editorId, cors) {
239
+ const editor = editors.get(editorId);
240
+ if (!editor || bearer(request) !== editor.sessionToken) {
241
+ sendJson(response, 401, { error: "Editor session is not authorized." }, cors);
242
+ return null;
243
+ }
244
+ editor.lastSeen = Date.now();
245
+ return editor;
246
+ }
247
+
248
+ function queueEditorEvent(editor, event) {
249
+ editor.queue.push(event);
250
+ if (editor.queue.length > 100) editor.queue.splice(0, editor.queue.length - 100);
251
+ deliverNext(editor);
252
+ }
253
+
254
+ function endEditorPoll(editor) {
255
+ if (!editor?.poll) return;
256
+ const response = editor.poll;
257
+ editor.poll = null;
258
+ clearTimeout(editor.pollTimer);
259
+ editor.pollTimer = null;
260
+ if (!response.writableEnded && !response.destroyed) {
261
+ response.writeHead(204, editor.cors || {});
262
+ response.end();
263
+ }
264
+ }
265
+
266
+ function disconnectEditor(editorId, message = "Browser editor disconnected.") {
267
+ const editor = editors.get(editorId);
268
+ if (!editor) return false;
269
+ endEditorPoll(editor);
270
+ for (const session of editSessions.values()) if (session.editorId === editorId) releaseEditSession(session.id, "editor disconnected");
271
+ editors.delete(editorId);
272
+ if (focusedEditorId === editorId) focusedEditorId = null;
273
+ for (const [requestId, pending] of inflight) {
274
+ if (pending.editorId !== editorId) continue;
275
+ inflight.delete(requestId);
276
+ clearTimeout(pending.timer);
277
+ pending.reject(new Error(message));
278
+ }
279
+ return true;
280
+ }
281
+
282
+ function deliverNext(editor) {
283
+ if (!editor?.poll || !editor.queue.length) return;
284
+ const response = editor.poll;
285
+ editor.poll = null;
286
+ clearTimeout(editor.pollTimer);
287
+ editor.pollTimer = null;
288
+ sendJson(response, 200, editor.queue.shift(), editor.cors);
289
+ }
290
+
291
+ function broadcastAgents() {
292
+ const event = { kind: "system", type: "agents.changed", agents: activeClients().map(publicClient) };
293
+ for (const editor of activeEditors()) queueEditorEvent(editor, event);
294
+ }
295
+
296
+ function selectEditor(clientId) {
297
+ const connected = activeEditors();
298
+ const client = clients.get(clientId);
299
+ const selected = client?.selectedEditorId && connected.find((editor) => editor.id === client.selectedEditorId);
300
+ if (selected) return selected;
301
+ const focused = focusedEditorId && connected.find((editor) => editor.id === focusedEditorId);
302
+ if (focused) return focused;
303
+ if (connected.length === 1) return connected[0];
304
+ if (!connected.length) throw new Error("No CarouselBot editor is connected. Open the editor and click Connect AI.");
305
+ throw new Error("Multiple editors are connected and none is selected. Call list_editors, then select_editor.");
306
+ }
307
+
308
+ function resolveBrowserTarget(clientId, { editSessionId, mutating, projectId }) {
309
+ const client = clients.get(clientId) || { id: clientId, name: "MCP agent" };
310
+ if (editSessionId) {
311
+ const session = requireEditSession(editSessionId);
312
+ session.lastClientId = clientId;
313
+ if (mutating) claimProject(session, projectId);
314
+ return { client, editor: editors.get(session.editorId), session };
315
+ }
316
+ if (!mutating) return { client, editor: selectEditor(clientId), session: null };
317
+ let session = client.implicitSessionId && editSessions.get(client.implicitSessionId);
318
+ if (session) {
319
+ session = requireEditSession(session.id);
320
+ claimProject(session, projectId);
321
+ return { client, editor: editors.get(session.editorId), session };
322
+ }
323
+ const editor = selectEditor(clientId);
324
+ const conflict = sessionForEditor(editor.id);
325
+ if (conflict) throw codedError("EDITOR_BUSY", `Editor ${editor.id} is assigned to ${conflict.owner.name} (${conflict.purpose}). Begin an edit session on another editor.`, { session: publicSession(conflict) });
326
+ const now = Date.now();
327
+ session = {
328
+ id: randomUUID(), editorId: editor.id, projectId: null, purpose: "Implicit single-agent edit",
329
+ owner: publicClient(client), creatorClientId: client.id, lastClientId: client.id,
330
+ implicit: true, createdAt: now, lastSeen: now,
331
+ };
332
+ claimProject(session, projectId);
333
+ editSessions.set(session.id, session);
334
+ client.implicitSessionId = session.id;
335
+ recordAudit({ action: "edit_session.begin", client, session, message: "implicit" });
336
+ broadcastEditSessions();
337
+ return { client, editor, session };
338
+ }
339
+
340
+ function callBrowser(clientId, toolName, operation, label, { editSessionId = null, mutating = false } = {}) {
341
+ const projectId = operation?.projectId || null;
342
+ const { client, editor, session } = resolveBrowserTarget(clientId, { editSessionId, mutating, projectId });
343
+ if (mutating && session && !session.implicit && !session.projectId && toolName !== "create_project") {
344
+ throw codedError("PROJECT_ID_REQUIRED", "This edit session is not bound to a project yet. Pass projectId, or create a project first so the daemon can bind it atomically.");
345
+ }
346
+ const requestId = randomUUID();
347
+ recordAudit({ action: "tool.call", client, session, editorId: editor.id, projectId, toolName, status: "started" });
348
+ return new Promise((resolve, reject) => {
349
+ const timer = setTimeout(() => {
350
+ inflight.delete(requestId);
351
+ recordAudit({ action: "tool.result", client, session, editorId: editor.id, projectId, toolName, status: "error", message: "Browser timeout" });
352
+ reject(codedError("BROWSER_TIMEOUT", "The browser did not answer within 90 seconds."));
353
+ }, COMMAND_TIMEOUT_MS);
354
+ inflight.set(requestId, { resolve, reject, timer, editorId: editor.id, client, session, projectId, toolName });
355
+ queueEditorEvent(editor, { kind: "command", requestId, toolName, operation, label, editSessionId: session?.id || null, agent: publicClient(client) });
356
+ });
357
+ }
358
+
359
+ function detectedMime(buffer, filename) {
360
+ if (buffer.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) return "image/png";
361
+ if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return "image/jpeg";
362
+ if (["GIF87a", "GIF89a"].includes(buffer.subarray(0, 6).toString("ascii"))) return "image/gif";
363
+ if (buffer.subarray(0, 4).toString("ascii") === "RIFF" && buffer.subarray(8, 12).toString("ascii") === "WEBP") return "image/webp";
364
+ const header = buffer.subarray(0, 64).toString("ascii");
365
+ if (/ftyp(?:avif|avis)/.test(header)) return "image/avif";
366
+ const text = buffer.subarray(0, 1024).toString("utf8").trimStart();
367
+ if (/^(?:<\?xml[^>]*>\s*)?<svg[\s>]/i.test(text)) return "image/svg+xml";
368
+ const extension = extname(filename).toLowerCase();
369
+ if (extension === ".jpg" || extension === ".jpeg") return "image/jpeg";
370
+ return null;
371
+ }
372
+
373
+ async function prepareMedia(filePath) {
374
+ const metadata = await stat(filePath);
375
+ if (!metadata.isFile()) throw new Error("Image path must point to a regular file.");
376
+ if (metadata.size > MAX_MEDIA_BYTES) throw new Error("Image is larger than the 25 MB local-transfer limit.");
377
+ const buffer = await readFile(filePath);
378
+ const mimeType = detectedMime(buffer, filePath);
379
+ if (!mimeType) throw new Error("Unsupported image. Use PNG, JPEG, WebP, GIF, SVG, or AVIF.");
380
+ const id = randomUUID();
381
+ media.set(id, { id, buffer, mimeType, filename: basename(filePath), expiresAt: Date.now() + MEDIA_TTL_MS });
382
+ return { mediaId: id, filename: basename(filePath), mimeType, size: buffer.length };
383
+ }
384
+
385
+ async function writeExport(filePath, data, overwrite) {
386
+ const buffer = Buffer.from(data, "base64");
387
+ const handle = await open(filePath, overwrite ? "w" : "wx", 0o600).catch((error) => {
388
+ if (error.code === "EEXIST") throw new Error(`Export already exists: ${filePath}. Set overwrite=true only when intended.`);
389
+ throw error;
390
+ });
391
+ try { await handle.writeFile(buffer); } finally { await handle.close(); }
392
+ return { path: filePath, bytes: buffer.length };
393
+ }
394
+
395
+ async function handleInternalCall(body) {
396
+ const client = clients.get(body.clientId);
397
+ if (!client) throw new Error("MCP client session is not registered.");
398
+ client.lastSeen = Date.now();
399
+ if (body.action === "list_editors") {
400
+ const connected = activeEditors();
401
+ const selectedEditorId = connected.find((editor) => editor.id === client.selectedEditorId)?.id
402
+ || connected.find((editor) => editor.id === focusedEditorId)?.id
403
+ || (connected.length === 1 ? connected[0].id : null);
404
+ if (selectedEditorId) client.selectedEditorId = selectedEditorId;
405
+ return {
406
+ selectedEditorId,
407
+ editors: connected.map((editor) => {
408
+ const assigned = sessionForEditor(editor.id);
409
+ return { id: editor.id, selected: editor.id === selectedEditorId, focused: editor.id === focusedEditorId, pageUrl: editor.pageUrl, state: editor.state, editSession: assigned ? publicSession(assigned) : null };
410
+ }),
411
+ editSessions: activeEditSessions().map(publicSession),
412
+ };
413
+ }
414
+ if (body.action === "select_editor") {
415
+ const editor = activeEditors().find((item) => item.id === body.editorId);
416
+ if (!editor) throw new Error(`Editor is not connected: ${body.editorId}`);
417
+ client.selectedEditorId = editor.id;
418
+ return { editorId: editor.id, pageUrl: editor.pageUrl, state: editor.state };
419
+ }
420
+ if (body.action === "begin_edit_session") return publicSession(beginEditSession(client, body));
421
+ if (body.action === "end_edit_session") {
422
+ const session = editSessions.get(body.editSessionId);
423
+ if (!session) return { released: false, editSessionId: body.editSessionId };
424
+ releaseEditSession(session.id, "released by agent");
425
+ return { released: true, editSessionId: session.id, editorId: session.editorId, projectId: session.projectId || null };
426
+ }
427
+ if (body.action === "list_edit_sessions") return { editSessions: activeEditSessions().map(publicSession) };
428
+ if (body.action === "list_recent_operations") {
429
+ const limit = Math.max(1, Math.min(200, Number(body.limit) || 50));
430
+ const events = auditEvents.filter((event) => (!body.projectId || event.projectId === body.projectId) && (!body.status || event.status === body.status));
431
+ return { events: events.slice(-limit).reverse(), localLogPath: AUDIT_LOG_PATH };
432
+ }
433
+ if (body.action === "prepare_media") return prepareMedia(body.path);
434
+ if (body.action === "write_export") return writeExport(body.path, body.data, Boolean(body.overwrite));
435
+ if (body.action === "notify") {
436
+ const { editor } = resolveBrowserTarget(body.clientId, { editSessionId: body.editSessionId, mutating: false, projectId: null });
437
+ queueEditorEvent(editor, { kind: "system", type: "notification", message: body.message, tone: body.tone, agent: publicClient(client) });
438
+ return { shown: true, editorId: editor.id };
439
+ }
440
+ if (body.action === "browser") return callBrowser(body.clientId, body.toolName, body.operation, body.label, { editSessionId: body.editSessionId, mutating: Boolean(body.mutating) });
441
+ if (body.action === "batch") {
442
+ const results = [];
443
+ for (const item of body.items) results.push(await callBrowser(body.clientId, item.toolName || "apply_operations", item.operation, item.label, { editSessionId: body.editSessionId, mutating: true }));
444
+ return { applied: results.length, results };
445
+ }
446
+ throw new Error(`Unknown internal action: ${body.action}`);
447
+ }
448
+
449
+ const server = createServer(async (request, response) => {
450
+ const host = request.headers.host || "";
451
+ if (![`${BRIDGE_HOST}:${BRIDGE_PORT}`, `localhost:${BRIDGE_PORT}`].includes(host)) return sendJson(response, 421, { error: "Invalid Host header." });
452
+ const url = new URL(request.url || "/", BRIDGE_URL);
453
+ const origin = request.headers.origin;
454
+ const cors = browserCors(origin);
455
+
456
+ if (request.method === "OPTIONS") {
457
+ if (!cors) return sendJson(response, 403, { error: "Origin not allowed." });
458
+ response.writeHead(204, cors);
459
+ response.end();
460
+ return;
461
+ }
462
+ if (url.pathname === "/health" && request.method === "GET") {
463
+ if (origin && !cors) return sendJson(response, 403, { error: "Origin not allowed." });
464
+ return sendJson(response, 200, { ok: true, service: PACKAGE_NAME, version: PACKAGE_VERSION, protocolVersion: PROTOCOL_VERSION, editors: activeEditors().length, agents: activeClients().length }, cors || {});
465
+ }
466
+
467
+ try {
468
+ if (url.pathname.startsWith("/internal/")) {
469
+ if (!requireInternal(request, response)) return;
470
+ if (url.pathname === "/internal/health" && request.method === "GET") return sendJson(response, 200, { ok: true, pid: process.pid, version: PACKAGE_VERSION, protocolVersion: PROTOCOL_VERSION });
471
+ if (url.pathname === "/internal/shutdown" && request.method === "POST") {
472
+ sendJson(response, 202, { ok: true, pid: process.pid });
473
+ setImmediate(() => void shutdown());
474
+ return;
475
+ }
476
+ const body = await readJson(request);
477
+ if (url.pathname === "/internal/client/connect" && request.method === "POST") {
478
+ const existing = clients.get(body.clientId) || { id: body.clientId };
479
+ Object.assign(existing, { name: body.name || existing.name || "MCP agent", version: body.version || existing.version || null, lastSeen: Date.now() });
480
+ clients.set(existing.id, existing);
481
+ broadcastAgents();
482
+ return sendJson(response, 200, { ok: true, client: publicClient(existing) });
483
+ }
484
+ if (url.pathname === "/internal/client/disconnect" && request.method === "POST") {
485
+ const departing = clients.get(body.clientId);
486
+ if (departing?.implicitSessionId) releaseEditSession(departing.implicitSessionId, "implicit client disconnected");
487
+ clients.delete(body.clientId);
488
+ broadcastAgents();
489
+ return sendJson(response, 200, { ok: true });
490
+ }
491
+ if (url.pathname === "/internal/client/heartbeat" && request.method === "POST") {
492
+ const client = clients.get(body.clientId);
493
+ if (client) {
494
+ client.lastSeen = Date.now();
495
+ const session = client.implicitSessionId && editSessions.get(client.implicitSessionId);
496
+ if (session) session.lastSeen = Date.now();
497
+ }
498
+ return sendJson(response, 200, { ok: Boolean(client) });
499
+ }
500
+ if (url.pathname === "/internal/call" && request.method === "POST") return sendJson(response, 200, { ok: true, result: await handleInternalCall(body) });
501
+ return sendJson(response, 404, { error: "Internal endpoint not found." });
502
+ }
503
+
504
+ if (!cors) return sendJson(response, 403, { error: "Origin not allowed." });
505
+ if (url.pathname === "/connect" && request.method === "POST") {
506
+ const body = await readJson(request);
507
+ if (!body.editorId || typeof body.editorId !== "string") return sendJson(response, 400, { error: "editorId is required." }, cors);
508
+ if (body.protocolVersion !== PROTOCOL_VERSION) return sendJson(response, 409, { error: `Protocol mismatch. Browser=${body.protocolVersion}; companion=${PROTOCOL_VERSION}.`, protocolVersion: PROTOCOL_VERSION }, cors);
509
+ const previous = editors.get(body.editorId);
510
+ if (previous) {
511
+ endEditorPoll(previous);
512
+ for (const [requestId, pending] of inflight) {
513
+ if (pending.editorId !== body.editorId) continue;
514
+ inflight.delete(requestId);
515
+ clearTimeout(pending.timer);
516
+ pending.reject(codedError("EDITOR_RELOADED", "The assigned browser tab reloaded during this operation. Inspect the editor and retry."));
517
+ }
518
+ }
519
+ const editor = {
520
+ id: body.editorId, queue: [], poll: null, pageUrl: body.pageUrl,
521
+ pollTimer: null, state: body.state, lastSeen: Date.now(), cors, sessionToken: randomBytes(32).toString("base64url"),
522
+ };
523
+ editors.set(editor.id, editor);
524
+ if (body.hasFocus && body.visibilityState === "visible") focusedEditorId = editor.id;
525
+ log(`Editor connected (${editor.id.slice(0, 8)})`);
526
+ return sendJson(response, 200, { ok: true, editorId: editor.id, sessionToken: editor.sessionToken, protocolVersion: PROTOCOL_VERSION, version: PACKAGE_VERSION, agents: activeClients().map(publicClient), editSessions: activeEditSessions().map(publicSession) }, cors);
527
+ }
528
+ if (url.pathname === "/activate" && request.method === "POST") {
529
+ const body = await readJson(request);
530
+ const editor = requireEditor(request, response, body.editorId, cors);
531
+ if (!editor) return;
532
+ focusedEditorId = editor.id;
533
+ return sendJson(response, 200, { ok: true, editorId: editor.id }, cors);
534
+ }
535
+ if (url.pathname === "/heartbeat" && request.method === "POST") {
536
+ const body = await readJson(request);
537
+ const editor = requireEditor(request, response, body.editorId, cors);
538
+ if (!editor) return;
539
+ return sendJson(response, 200, { ok: true, editorId: editor.id }, cors);
540
+ }
541
+ if (url.pathname === "/disconnect" && request.method === "POST") {
542
+ const body = await readJson(request);
543
+ const editor = requireEditor(request, response, body.editorId, cors);
544
+ if (!editor) return;
545
+ disconnectEditor(editor.id);
546
+ return sendJson(response, 200, { ok: true, editorId: editor.id }, cors);
547
+ }
548
+ if (url.pathname === "/events" && request.method === "GET") {
549
+ const editor = requireEditor(request, response, url.searchParams.get("editorId"), cors);
550
+ if (!editor) return;
551
+ const requestedWait = Number(url.searchParams.get("wait"));
552
+ const waitMs = url.searchParams.has("wait") && Number.isFinite(requestedWait)
553
+ ? Math.min(MAX_EVENT_POLL_TIMEOUT_MS, Math.max(0, requestedWait))
554
+ : EVENT_POLL_TIMEOUT_MS;
555
+ editor.cors = cors;
556
+ if (editor.poll) endEditorPoll(editor);
557
+ editor.poll = response;
558
+ response.once("close", () => {
559
+ if (editor.poll !== response) return;
560
+ editor.poll = null;
561
+ clearTimeout(editor.pollTimer);
562
+ editor.pollTimer = null;
563
+ editor.lastSeen = Date.now();
564
+ });
565
+ deliverNext(editor);
566
+ if (editor.poll && waitMs === 0) {
567
+ endEditorPoll(editor);
568
+ } else if (editor.poll) editor.pollTimer = setTimeout(() => {
569
+ if (editor.poll !== response) return;
570
+ endEditorPoll(editor);
571
+ }, waitMs);
572
+ editor.pollTimer?.unref();
573
+ return;
574
+ }
575
+ if (url.pathname === "/result" && request.method === "POST") {
576
+ const body = await readJson(request);
577
+ const editor = requireEditor(request, response, body.editorId, cors);
578
+ if (!editor) return;
579
+ const pending = inflight.get(body.requestId);
580
+ if (!pending || pending.editorId !== editor.id) return sendJson(response, 404, { error: "Unknown request." }, cors);
581
+ inflight.delete(body.requestId);
582
+ clearTimeout(pending.timer);
583
+ if (body.state) editor.state = body.state;
584
+ if (body.ok) {
585
+ try {
586
+ if (pending.session && body.result?.projectId) claimProject(pending.session, body.result.projectId);
587
+ } catch (error) {
588
+ recordAudit({ action: "tool.result", client: pending.client, session: pending.session, editorId: editor.id, projectId: body.result?.projectId, toolName: pending.toolName, status: "error", message: error.message });
589
+ pending.reject(error);
590
+ return sendJson(response, 200, { ok: true, accepted: false }, cors);
591
+ }
592
+ if (pending.session) pending.session.lastSeen = Date.now();
593
+ recordAudit({ action: "tool.result", client: pending.client, session: pending.session, editorId: editor.id, projectId: body.result?.projectId || pending.projectId, toolName: pending.toolName, status: "ok", revision: body.result?.revision });
594
+ pending.resolve(body.result);
595
+ } else {
596
+ recordAudit({ action: "tool.result", client: pending.client, session: pending.session, editorId: editor.id, projectId: pending.projectId, toolName: pending.toolName, status: "error", message: body.error || "Browser operation failed" });
597
+ pending.reject(new Error(body.error || "Browser operation failed."));
598
+ }
599
+ return sendJson(response, 200, { ok: true }, cors);
600
+ }
601
+ if (url.pathname.startsWith("/media/") && request.method === "GET") {
602
+ const editor = requireEditor(request, response, url.searchParams.get("editorId"), cors);
603
+ if (!editor) return;
604
+ const id = decodeURIComponent(url.pathname.slice("/media/".length));
605
+ const item = media.get(id);
606
+ if (!item || item.expiresAt < Date.now()) return sendJson(response, 404, { error: "Local image transfer expired." }, cors);
607
+ media.delete(id);
608
+ response.writeHead(200, { ...cors, "Content-Type": item.mimeType, "Content-Length": item.buffer.length, "X-CarouselBot-Filename": encodeURIComponent(item.filename), "X-Slide-Studio-Filename": encodeURIComponent(item.filename) });
609
+ response.end(item.buffer);
610
+ return;
611
+ }
612
+ return sendJson(response, 404, { error: "Not found." }, cors);
613
+ } catch (error) {
614
+ const headers = cors || {};
615
+ const statusCode = error.code === "ENOENT" ? 404 : error.code === "EACCES" ? 403 : 400;
616
+ return sendJson(response, statusCode, { error: error.message }, headers);
617
+ }
618
+ });
619
+
620
+ async function acquireDaemonLock() {
621
+ await mkdir(STATE_DIRECTORY, { recursive: true, mode: 0o700 });
622
+ try {
623
+ lockHandle = await open(DAEMON_LOCK_PATH, "wx", 0o600);
624
+ await lockHandle.writeFile(String(process.pid));
625
+ } catch (error) {
626
+ if (error.code !== "EEXIST") throw error;
627
+ try {
628
+ const lockPid = Number(await readFile(DAEMON_LOCK_PATH, "utf8"));
629
+ if (!Number.isInteger(lockPid) || lockPid <= 0) throw Object.assign(new Error("Invalid daemon lock."), { code: "ESTALE" });
630
+ process.kill(lockPid, 0);
631
+ const running = new Error(`CarouselBot daemon is already running or starting (pid ${lockPid}).`);
632
+ running.code = "EALREADY";
633
+ throw running;
634
+ } catch (checkError) {
635
+ if (checkError.code === "EALREADY") throw checkError;
636
+ if (!["ESRCH", "ENOENT", "ESTALE"].includes(checkError.code)) throw checkError;
637
+ await unlink(DAEMON_LOCK_PATH).catch(() => {});
638
+ lockHandle = await open(DAEMON_LOCK_PATH, "wx", 0o600);
639
+ await lockHandle.writeFile(String(process.pid));
640
+ }
641
+ }
642
+ }
643
+
644
+ async function writeDaemonState() {
645
+ const temporary = `${DAEMON_STATE_PATH}.${process.pid}.tmp`;
646
+ await writeFile(temporary, JSON.stringify({ pid: process.pid, port: BRIDGE_PORT, secret: daemonSecret, version: PACKAGE_VERSION, protocolVersion: PROTOCOL_VERSION }), { mode: 0o600 });
647
+ await rename(temporary, DAEMON_STATE_PATH);
648
+ }
649
+
650
+ async function cleanup() {
651
+ for (const editor of editors.values()) endEditorPoll(editor);
652
+ for (const pending of inflight.values()) { clearTimeout(pending.timer); pending.reject(new Error("Local companion is shutting down.")); }
653
+ server.closeAllConnections?.();
654
+ await new Promise((resolve) => server.close(resolve));
655
+ const state = await readFile(DAEMON_STATE_PATH, "utf8").then(JSON.parse).catch(() => null);
656
+ if (state?.pid === process.pid) await unlink(DAEMON_STATE_PATH).catch(() => {});
657
+ await lockHandle?.close().catch(() => {});
658
+ await unlink(DAEMON_LOCK_PATH).catch(() => {});
659
+ }
660
+
661
+ setInterval(() => {
662
+ const now = Date.now();
663
+ for (const [id, item] of media) if (item.expiresAt < now) media.delete(id);
664
+ for (const session of editSessions.values()) if (session.lastSeen < now - EDIT_SESSION_TTL_MS) releaseEditSession(session.id, "lease expired");
665
+ let clientsChanged = false;
666
+ for (const [id, client] of clients) if (client.lastSeen < now - CLIENT_TTL_MS) {
667
+ if (client.implicitSessionId) releaseEditSession(client.implicitSessionId, "implicit client expired");
668
+ clients.delete(id);
669
+ clientsChanged = true;
670
+ }
671
+ for (const [id, editor] of editors) {
672
+ if (
673
+ editor.lastSeen >= now - EDITOR_TTL_MS
674
+ || (editor.poll && !editor.poll.destroyed && !editor.poll.writableEnded)
675
+ || editorHasInflightCommand(id)
676
+ ) continue;
677
+ disconnectEditor(id, "Browser editor connection expired.");
678
+ }
679
+ if (clientsChanged) broadcastAgents();
680
+ if (activeClients().length || activeEditors().length) idleSince = null;
681
+ else if (!idleSince) idleSince = now;
682
+ else if (now - idleSince > 10 * 60_000) void shutdown();
683
+ }, 15_000).unref();
684
+
685
+ async function main() {
686
+ await acquireDaemonLock();
687
+ const previousAudit = await readFile(AUDIT_LOG_PATH, "utf8").catch(() => "");
688
+ for (const line of previousAudit.trim().split("\n").slice(-MAX_AUDIT_EVENTS)) {
689
+ try { auditEvents.push(JSON.parse(line)); } catch { /* Ignore an interrupted final line. */ }
690
+ }
691
+ const auditMetadata = await stat(AUDIT_LOG_PATH).catch(() => null);
692
+ if (auditMetadata?.size > MAX_AUDIT_BYTES) await rename(AUDIT_LOG_PATH, `${AUDIT_LOG_PATH}.previous`).catch(() => {});
693
+ server.on("error", (error) => { log(`Bridge failed: ${error.message}`); process.exitCode = 1; });
694
+ await new Promise((resolve, reject) => {
695
+ server.once("error", reject);
696
+ server.listen(BRIDGE_PORT, BRIDGE_HOST, resolve);
697
+ });
698
+ await writeDaemonState();
699
+ log(`Listening on ${BRIDGE_URL}`);
700
+ }
701
+
702
+ let shuttingDown = false;
703
+ async function shutdown() {
704
+ if (shuttingDown) return;
705
+ shuttingDown = true;
706
+ await cleanup().catch((error) => log(`Cleanup failed: ${error.message}`));
707
+ process.exit();
708
+ }
709
+ process.on("SIGINT", shutdown);
710
+ process.on("SIGTERM", shutdown);
711
+ process.on("SIGHUP", shutdown);
712
+
713
+ main().catch(async (error) => {
714
+ log(error.message);
715
+ await lockHandle?.close().catch(() => {});
716
+ process.exit(1);
717
+ });