UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

221 lines • 7.75 kB
/** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { spawn } from 'node:child_process'; import fs from 'node:fs'; import net from 'node:net'; import { PipeTransport } from '../third_party/index.js'; import { getTempFilePath } from '../utils/files.js'; import { logger, puppeteerLogger } from '../utils/logger.js'; import { DAEMON_SCRIPT_PATH, getSocketPath, getPidFilePath, isDaemonRunning, } from './utils.js'; const FILE_TIMEOUT = 10_000; const READY_CHECK_INTERVAL = 100; const READY_CHECK_COMMAND_TIMEOUT = 1_000; /** * Waits for a file to be created and populated (removed = false) or removed (removed = true). */ function waitForFile(filePath, removed = false) { return new Promise((resolve, reject) => { const check = () => { const exists = fs.existsSync(filePath); if (removed) { return !exists; } if (!exists) { return false; } try { return fs.statSync(filePath).size > 0; } catch { return false; } }; if (check()) { resolve(); return; } const timer = setTimeout(() => { fs.unwatchFile(filePath); reject(new Error(`Timeout: file ${filePath} ${removed ? 'not removed' : 'not found'} within ${FILE_TIMEOUT}ms`)); }, FILE_TIMEOUT); fs.watchFile(filePath, { interval: 500 }, () => { if (check()) { clearTimeout(timer); fs.unwatchFile(filePath); resolve(); } }); }); } function delay(ms) { return new Promise(resolve => { setTimeout(resolve, ms); }); } async function waitForDaemonReady(sessionId) { const deadline = Date.now() + FILE_TIMEOUT; let lastError; while (Date.now() < deadline) { try { const response = await sendCommand({ method: 'status' }, sessionId, READY_CHECK_COMMAND_TIMEOUT); if (response.success) { return; } lastError = new Error(String(response.error)); } catch (error) { lastError = error; } const timeLeft = deadline - Date.now(); if (timeLeft > 0) { await delay(Math.min(READY_CHECK_INTERVAL, timeLeft)); } } throw new Error(`Timeout: daemon not ready within ${FILE_TIMEOUT}ms`, lastError === undefined ? undefined : { cause: lastError }); } export async function startDaemon(mcpArgs = [], sessionId) { if (isDaemonRunning(sessionId)) { logger?.('Daemon is already running'); await waitForDaemonReady(sessionId); return; } const pidFilePath = getPidFilePath(sessionId); if (fs.existsSync(pidFilePath)) { fs.unlinkSync(pidFilePath); } logger?.('Starting daemon...', ...mcpArgs); const child = spawn(process.execPath, [DAEMON_SCRIPT_PATH, ...mcpArgs], { detached: true, stdio: 'ignore', env: { ...process.env, CHROME_DEVTOOLS_MCP_SESSION_ID: sessionId }, cwd: process.cwd(), windowsHide: true, }); child.unref(); await waitForFile(pidFilePath); await waitForDaemonReady(sessionId); } const SEND_COMMAND_TIMEOUT = 60_000; // ms /** * `sendCommand` opens a socket connection sends a single command and disconnects. */ export async function sendCommand(command, sessionId, timeout = SEND_COMMAND_TIMEOUT) { // Before connecting and sending, verify the daemon is still alive. if (!isDaemonRunning(sessionId)) { throw new Error('Daemon is not running.'); } const socketPath = getSocketPath(sessionId); const socket = net.createConnection({ path: socketPath, }); return new Promise((resolve, reject) => { const timer = setTimeout(() => { socket.destroy(); reject(new Error('Timeout waiting for daemon response')); }, timeout); const transport = new PipeTransport(socket, socket, puppeteerLogger); transport.onmessage = async (message) => { clearTimeout(timer); logger?.('onmessage', message); resolve(JSON.parse(message)); }; socket.on('error', error => { clearTimeout(timer); logger?.('Socket error:', error); reject(error); }); socket.on('close', () => { clearTimeout(timer); logger?.('Socket closed:'); reject(new Error('Socket closed')); }); logger?.('Sending message', command); transport.send(JSON.stringify(command)); }); } export async function stopDaemon(sessionId) { if (!isDaemonRunning(sessionId)) { logger?.('Daemon is not running'); return; } const pidFilePath = getPidFilePath(sessionId); await sendCommand({ method: 'stop' }, sessionId); await waitForFile(pidFilePath, /*removed=*/ true); } export async function verifyDaemonVersion(sessionId, cliVersion) { if (!isDaemonRunning(sessionId)) { return undefined; } try { const response = await sendCommand({ method: 'status' }, sessionId); if (response.success) { const data = JSON.parse(response.result); if (data?.version && data.version !== cliVersion) { return `Warning: Daemon server version (${data.version}) does not match CLI version (${cliVersion}). Run 'chrome-devtools start' to update and restart the daemon.`; } } } catch { // Suppress communication failures during check; command execution handles unreachable daemon errors. } return undefined; } export async function handleResponse(response, format) { if (response.isError) { if (format === 'md') { const chunks = []; for (const content of response.content) { if (content.type === 'text') { chunks.push(content.text); } } return chunks.join(' '); } return JSON.stringify(response.content); } const chunks = []; const images = []; for (const content of response.content) { if (content.type === 'text') { chunks.push(content.text); } else if (content.type === 'image') { const imageData = content.data; const mimeType = content.mimeType; let extension = '.png'; switch (mimeType) { case 'image/jpg': case 'image/jpeg': extension = '.jpeg'; break; case 'image/webp': extension = '.webp'; break; } const data = Buffer.from(imageData, 'base64'); const name = crypto.randomUUID(); const filepath = await getTempFilePath(`${name}${extension}`); fs.writeFileSync(filepath, data); images.push({ filePath: filepath, mimeType }); chunks.push(`Saved to ${filepath}.`); } else { throw new Error('Not supported response content type'); } } if (format === 'json') { if (response.structuredContent) { const structuredContent = { ...response.structuredContent, ...(images.length ? { images } : {}), }; return JSON.stringify(structuredContent); } // Fall-through to text for backward compatibility. } return format === 'md' ? chunks.join(' ') : JSON.stringify(chunks); } //# sourceMappingURL=client.js.map