Files
claudecodeui/electron/computerAgent.js
2026-06-17 19:01:15 +00:00

226 lines
6.5 KiB
JavaScript

import { spawn } from 'node:child_process';
import fs from 'node:fs/promises';
import path from 'node:path';
const IPC_PREFIX = '@@CUAGENT@@';
function getDesktopPath() {
const currentPath = process.env.PATH || '';
const commonPaths = process.platform === 'win32'
? []
: ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin', '/usr/sbin', '/sbin'];
return [...commonPaths, currentPath].filter(Boolean).join(path.delimiter);
}
function getNodeRuntime(isPackaged) {
if (isPackaged && process.versions.electron) {
return { command: process.execPath, env: { ELECTRON_RUN_AS_NODE: '1' } };
}
if (process.env.npm_node_execpath) {
return { command: process.env.npm_node_execpath, env: {} };
}
return { command: 'node', env: {} };
}
/** Converts an environment access URL (https://x) to its desktop-agent ws URL. */
function toAgentWsUrl(httpUrl) {
try {
const parsed = new URL(httpUrl);
parsed.protocol = parsed.protocol === 'http:' ? 'ws:' : 'wss:';
parsed.pathname = '/desktop-agent';
parsed.search = '';
parsed.hash = '';
return parsed.toString();
} catch {
return null;
}
}
/**
* Manages the standalone Computer Use desktop agent process. While the user has
* Computer Use enabled, this keeps an agent connected to every running cloud
* environment so hosted sessions can drive this machine. The local CloudCLI
* server is not involved.
*/
export class ComputerAgentController {
constructor({ appRoot, settingsPath, isPackaged = false, getRunningEnvironmentUrls, promptConsent, onChange }) {
this.appRoot = appRoot;
this.settingsPath = settingsPath;
this.isPackaged = isPackaged;
this.getRunningEnvironmentUrls = getRunningEnvironmentUrls;
this.promptConsent = promptConsent;
this.onChange = onChange;
this.settings = { enabled: false, consentMode: 'ask' };
this.child = null;
this.connectedUrls = new Set();
this.currentTargets = [];
this.stdoutBuffer = '';
}
getSettings() {
return { ...this.settings };
}
getState() {
return {
enabled: this.settings.enabled,
consentMode: this.settings.consentMode,
running: Boolean(this.child),
connectedCount: this.connectedUrls.size,
targetCount: this.currentTargets.length,
};
}
async loadSettings() {
try {
const raw = await fs.readFile(this.settingsPath, 'utf8');
const stored = JSON.parse(raw);
this.settings = {
enabled: Boolean(stored.enabled),
consentMode: stored.consentMode === 'auto' ? 'auto' : 'ask',
};
} catch {
this.settings = { enabled: false, consentMode: 'ask' };
}
return this.settings;
}
async saveSettings(next) {
this.settings = {
enabled: Boolean(next.enabled),
consentMode: next.consentMode === 'auto' ? 'auto' : 'ask',
};
await fs.mkdir(path.dirname(this.settingsPath), { recursive: true });
await fs.writeFile(this.settingsPath, JSON.stringify(this.settings, null, 2), 'utf8');
await this.sync();
this.onChange?.();
return this.settings;
}
/** Reconciles the agent process with the current settings + environments. */
async sync() {
const targets = this.settings.enabled ? (this.getRunningEnvironmentUrls?.() || []) : [];
const wsTargets = targets.map(toAgentWsUrl).filter(Boolean);
const sameTargets =
wsTargets.length === this.currentTargets.length &&
wsTargets.every((url) => this.currentTargets.includes(url));
if (!this.settings.enabled || wsTargets.length === 0) {
this.stop();
this.currentTargets = [];
return;
}
if (this.child && sameTargets) {
return; // already running with the right targets
}
this.currentTargets = wsTargets;
this.restart(wsTargets);
}
restart(wsTargets) {
this.stop();
const agentEntry = process.env.CLOUDCLI_COMPUTER_AGENT_ENTRY
|| path.join(this.appRoot, 'dist-server', 'server', 'computer-use-agent.js');
const runtime = getNodeRuntime(this.isPackaged);
this.child = spawn(runtime.command, [agentEntry], {
cwd: this.appRoot,
env: {
...process.env,
...runtime.env,
PATH: getDesktopPath(),
CLOUDCLI_DESKTOP_AGENT_URLS: wsTargets.join(','),
CLOUDCLI_COMPUTER_USE_CONSENT_MODE: this.settings.consentMode,
},
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true,
});
this.connectedUrls = new Set();
this.child.once('error', (error) => {
console.error('[ComputerAgent] failed to start:', error.message);
this.child = null;
this.onChange?.();
});
this.child.stdout?.on('data', (chunk) => this.handleStdout(String(chunk)));
this.child.stderr?.on('data', (chunk) => {
for (const line of String(chunk).split(/\r?\n/)) {
if (line.trim()) console.error('[ComputerAgent]', line);
}
});
this.child.once('exit', (code) => {
console.log(`[ComputerAgent] exited (code ${code ?? 'null'})`);
this.child = null;
this.connectedUrls = new Set();
this.onChange?.();
});
this.onChange?.();
}
handleStdout(chunk) {
this.stdoutBuffer += chunk;
const lines = this.stdoutBuffer.split('\n');
this.stdoutBuffer = lines.pop() || '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed.startsWith(IPC_PREFIX)) {
if (trimmed) console.log('[ComputerAgent]', trimmed);
continue;
}
let payload;
try {
payload = JSON.parse(trimmed.slice(IPC_PREFIX.length).trim());
} catch {
continue;
}
void this.handleAgentEvent(payload);
}
}
async handleAgentEvent(payload) {
switch (payload.type) {
case 'connected':
this.connectedUrls.add(payload.url);
this.onChange?.();
break;
case 'disconnected':
this.connectedUrls.delete(payload.url);
this.onChange?.();
break;
case 'consent-request': {
const allow = await this.promptConsent?.(payload.sessionId);
this.sendToChild({ type: 'consent-response', sessionId: payload.sessionId, allow: Boolean(allow) });
break;
}
default:
break;
}
}
sendToChild(message) {
if (this.child?.stdin?.writable) {
this.child.stdin.write(`${IPC_PREFIX} ${JSON.stringify(message)}\n`);
}
}
revokeSession(sessionId) {
this.sendToChild({ type: 'revoke-session', sessionId });
}
stop() {
if (!this.child) return;
const child = this.child;
this.child = null;
this.connectedUrls = new Set();
try { child.kill('SIGTERM'); } catch { /* noop */ }
}
}