mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-18 14:32:06 +08:00
884 lines
30 KiB
TypeScript
884 lines
30 KiB
TypeScript
import { createRequire } from 'node:module';
|
|
import { randomBytes, randomUUID } from 'node:crypto';
|
|
import { spawn } from 'node:child_process';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
|
|
import { appConfigDb } from '@/modules/database/repositories/app-config.js';
|
|
import { providerMcpService } from '@/modules/providers/services/mcp.service.js';
|
|
import { getModuleDir } from '@/utils/runtime-paths.js';
|
|
import {
|
|
executor,
|
|
captureScreenshot as captureScreenshotRuntime,
|
|
getRuntimeReadiness as getExecutorReadiness,
|
|
type Point,
|
|
type ClickButton,
|
|
type ScrollDirection,
|
|
} from '@/modules/computer-use/computer-executor.js';
|
|
import { desktopAgentRelay } from '@/modules/computer-use/desktop-agent-relay.service.js';
|
|
|
|
const __dirname = getModuleDir(import.meta.url);
|
|
const IS_PLATFORM = process.env.VITE_IS_PLATFORM === 'true';
|
|
const MAX_SESSIONS_PER_OWNER = Number.parseInt(process.env.CLOUDCLI_COMPUTER_USE_MAX_SESSIONS_PER_OWNER || '1', 10);
|
|
const SESSION_TTL_MS = Number.parseInt(process.env.CLOUDCLI_COMPUTER_USE_SESSION_TTL_MS || String(30 * 60 * 1000), 10);
|
|
const COMPUTER_USE_SETTINGS_KEY = 'computer_use_settings';
|
|
const COMPUTER_USE_MCP_TOKEN_KEY = 'computer_use_mcp_token';
|
|
|
|
type ComputerUseRuntime = 'cloud' | 'local';
|
|
type ComputerUseSessionStatus = 'ready' | 'stopped' | 'unavailable';
|
|
|
|
type ComputerUseSession = {
|
|
id: string;
|
|
ownerId: string;
|
|
createdBy: 'user' | 'agent';
|
|
runtime: ComputerUseRuntime;
|
|
status: ComputerUseSessionStatus;
|
|
screenshotDataUrl: string | null;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
lastAction: string | null;
|
|
message: string | null;
|
|
/** Per-session consent: agents may act only while this is true. */
|
|
agentAccessEnabled: boolean;
|
|
/** Size of the captured screenshot in pixels — the coordinate space agents/users use. */
|
|
displaySize: {
|
|
width: number;
|
|
height: number;
|
|
} | null;
|
|
cursor: {
|
|
x: number;
|
|
y: number;
|
|
actor: 'agent' | 'user';
|
|
} | null;
|
|
};
|
|
|
|
type PublicComputerUseSession = Omit<ComputerUseSession, 'ownerId'>;
|
|
|
|
type ComputerUseOwner = {
|
|
id: string | number;
|
|
};
|
|
|
|
type ComputerUseSettings = {
|
|
enabled: boolean;
|
|
agentToolsEnabled: boolean;
|
|
};
|
|
|
|
type RuntimeReadiness = {
|
|
nut: any | null;
|
|
screenshot: any | null;
|
|
nutInstalled: boolean;
|
|
screenshotInstalled: boolean;
|
|
installInProgress: boolean;
|
|
installMessage: string | null;
|
|
};
|
|
|
|
const sessions = new Map<string, ComputerUseSession>();
|
|
let installPromise: Promise<{ success: boolean; message: string }> | null = null;
|
|
let lastInstallMessage: string | null = null;
|
|
|
|
const DEFAULT_SETTINGS: ComputerUseSettings = {
|
|
enabled: false,
|
|
agentToolsEnabled: false,
|
|
};
|
|
const AGENT_OWNER_ID = 'agent';
|
|
const MCP_SERVER_NAME = 'cloudcli-computer-use';
|
|
const MCP_PROVIDERS = ['claude', 'codex', 'cursor', 'gemini', 'opencode'];
|
|
|
|
function getRuntime(): ComputerUseRuntime {
|
|
return IS_PLATFORM ? 'cloud' : 'local';
|
|
}
|
|
|
|
function readSettings(): ComputerUseSettings {
|
|
try {
|
|
const raw = appConfigDb.get(COMPUTER_USE_SETTINGS_KEY);
|
|
if (!raw) {
|
|
return DEFAULT_SETTINGS;
|
|
}
|
|
|
|
const parsed = JSON.parse(raw) as Partial<ComputerUseSettings>;
|
|
return {
|
|
enabled: parsed.enabled === true,
|
|
agentToolsEnabled: parsed.agentToolsEnabled === true,
|
|
};
|
|
} catch (error: any) {
|
|
console.warn('[Computer Use] Failed to read settings:', error?.message || error);
|
|
return DEFAULT_SETTINGS;
|
|
}
|
|
}
|
|
|
|
function writeSettings(settings: ComputerUseSettings): ComputerUseSettings {
|
|
const normalized = {
|
|
enabled: settings.enabled === true,
|
|
agentToolsEnabled: settings.agentToolsEnabled === true,
|
|
};
|
|
|
|
appConfigDb.set(COMPUTER_USE_SETTINGS_KEY, JSON.stringify(normalized));
|
|
return normalized;
|
|
}
|
|
|
|
function getOrCreateMcpToken(): string {
|
|
const existing = appConfigDb.get(COMPUTER_USE_MCP_TOKEN_KEY);
|
|
if (existing) {
|
|
return existing;
|
|
}
|
|
const token = randomBytes(32).toString('hex');
|
|
appConfigDb.set(COMPUTER_USE_MCP_TOKEN_KEY, token);
|
|
return token;
|
|
}
|
|
|
|
function getSetupMessage(settings: ComputerUseSettings, readiness: RuntimeReadiness): string {
|
|
if (getRuntime() === 'cloud') {
|
|
return 'Cloud Computer Use requires a linked CloudCLI Desktop Agent on the user machine.';
|
|
}
|
|
if (!settings.enabled) {
|
|
return 'Computer Use is disabled in settings.';
|
|
}
|
|
if (!readiness.nutInstalled || !readiness.screenshotInstalled) {
|
|
return 'Install the desktop control runtime to capture the screen and drive the mouse and keyboard.';
|
|
}
|
|
return readiness.installMessage || 'Computer Use runtime is not ready.';
|
|
}
|
|
|
|
function getMcpCommand(): { command: string; args: string[] } {
|
|
const serverDir = path.resolve(__dirname, '..', '..');
|
|
const mcpScriptPath = path.join(serverDir, 'computer-use-mcp.js');
|
|
if (fs.existsSync(mcpScriptPath)) {
|
|
return {
|
|
command: process.execPath,
|
|
args: [mcpScriptPath],
|
|
};
|
|
}
|
|
|
|
return {
|
|
command: 'cloudcli',
|
|
args: ['computer-use-mcp'],
|
|
};
|
|
}
|
|
|
|
function getMcpApiUrl(): string {
|
|
const port = process.env.SERVER_PORT || process.env.PORT || '3001';
|
|
return `http://127.0.0.1:${port}/api/computer-use-mcp`;
|
|
}
|
|
|
|
function getRuntimeReadiness(): RuntimeReadiness {
|
|
const base = getExecutorReadiness();
|
|
return {
|
|
...base,
|
|
installInProgress: Boolean(installPromise),
|
|
installMessage: lastInstallMessage,
|
|
};
|
|
}
|
|
|
|
function runCommand(command: string, args: string[]): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn(command, args, {
|
|
cwd: process.cwd(),
|
|
env: process.env,
|
|
shell: false,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
const output: string[] = [];
|
|
|
|
child.stdout.on('data', (chunk) => output.push(String(chunk)));
|
|
child.stderr.on('data', (chunk) => output.push(String(chunk)));
|
|
child.on('error', reject);
|
|
child.on('close', (code) => {
|
|
if (code === 0) {
|
|
resolve();
|
|
return;
|
|
}
|
|
|
|
reject(new Error(output.join('').trim() || `${command} ${args.join(' ')} exited with code ${code}`));
|
|
});
|
|
});
|
|
}
|
|
|
|
function formatInstallError(error: unknown): string {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
if (process.platform === 'linux' && /libxtst|x11|xtst|libpng|imagemagick|scrot/i.test(message)) {
|
|
return [
|
|
'Installing the desktop control runtime needs system packages.',
|
|
'On Debian/Ubuntu run: sudo apt-get install -y libxtst-dev libpng-dev imagemagick',
|
|
'then try again.',
|
|
].join(' ');
|
|
}
|
|
return message || 'Failed to install the Computer Use runtime.';
|
|
}
|
|
|
|
function isPackagedElectronNodeRuntime(): boolean {
|
|
return process.env.ELECTRON_RUN_AS_NODE === '1' && Boolean(process.versions.electron);
|
|
}
|
|
|
|
async function installRuntime(): Promise<{ success: boolean; message: string }> {
|
|
if (installPromise) {
|
|
return installPromise;
|
|
}
|
|
|
|
const readiness = getExecutorReadiness();
|
|
if (readiness.nutInstalled && readiness.screenshotInstalled) {
|
|
lastInstallMessage = 'Computer Use runtime is available.';
|
|
return { success: true, message: lastInstallMessage };
|
|
}
|
|
|
|
if (isPackagedElectronNodeRuntime()) {
|
|
lastInstallMessage = 'Computer Use runtime was not bundled with this desktop build.';
|
|
return { success: false, message: lastInstallMessage };
|
|
}
|
|
|
|
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
installPromise = (async () => {
|
|
try {
|
|
lastInstallMessage = 'Installing desktop control runtime…';
|
|
await runCommand(npmCommand, [
|
|
'install',
|
|
'--no-save',
|
|
'--no-package-lock',
|
|
'@nut-tree-fork/nut-js',
|
|
'screenshot-desktop',
|
|
]);
|
|
|
|
lastInstallMessage = 'Computer Use runtime installed.';
|
|
return { success: true, message: lastInstallMessage };
|
|
} catch (error) {
|
|
lastInstallMessage = formatInstallError(error);
|
|
return { success: false, message: lastInstallMessage };
|
|
}
|
|
})();
|
|
|
|
try {
|
|
return await installPromise;
|
|
} finally {
|
|
installPromise = null;
|
|
}
|
|
}
|
|
|
|
function getOwnerId(owner: ComputerUseOwner): string {
|
|
if (owner.id === undefined || owner.id === null || String(owner.id).trim() === '') {
|
|
throw new Error('Authenticated user is required.');
|
|
}
|
|
|
|
return String(owner.id);
|
|
}
|
|
|
|
function publicSession(session: ComputerUseSession): PublicComputerUseSession {
|
|
const { ownerId: _ownerId, ...publicFields } = session;
|
|
return publicFields;
|
|
}
|
|
|
|
function ownerSessions(ownerId: string): ComputerUseSession[] {
|
|
return [...sessions.values()].filter((session) => session.ownerId === ownerId);
|
|
}
|
|
|
|
function canAccessSession(ownerId: string, session: ComputerUseSession): boolean {
|
|
return session.ownerId === ownerId || session.ownerId === AGENT_OWNER_ID;
|
|
}
|
|
|
|
async function expireStaleSessions(now = Date.now()): Promise<void> {
|
|
for (const session of sessions.values()) {
|
|
if (session.status !== 'ready') {
|
|
continue;
|
|
}
|
|
|
|
const updatedAt = Date.parse(session.updatedAt);
|
|
if (!Number.isFinite(updatedAt) || now - updatedAt <= SESSION_TTL_MS) {
|
|
continue;
|
|
}
|
|
|
|
session.status = 'stopped';
|
|
session.agentAccessEnabled = false;
|
|
session.updatedAt = new Date(now).toISOString();
|
|
session.lastAction = 'expire';
|
|
session.message = 'Computer Use session expired after inactivity.';
|
|
}
|
|
}
|
|
|
|
// --- Action layer: local executor (OSS) or cloud relay to the desktop agent --
|
|
//
|
|
// Every desktop interaction goes through `performAction` / `getCursorPosition`.
|
|
// In local mode it drives the in-process nut-js executor (computer-executor.ts);
|
|
// in cloud mode it forwards the action to the linked desktop agent over
|
|
// `desktopAgentRelay` and applies the returned screenshot. The local server
|
|
// itself never touches the OS in cloud mode.
|
|
|
|
/** One desktop interaction expressed in screenshot-pixel coordinate space. */
|
|
export type ComputerAction =
|
|
| { type: 'screenshot' }
|
|
| { type: 'mouse_move'; point: Point }
|
|
| { type: 'click'; button: ClickButton; point?: Point; double?: boolean }
|
|
| { type: 'drag'; from: Point; to: Point; button?: ClickButton }
|
|
| { type: 'type'; text: string }
|
|
| { type: 'key'; key: string }
|
|
| { type: 'scroll'; direction: ScrollDirection; amount?: number; point?: Point }
|
|
| { type: 'wait'; ms?: number };
|
|
|
|
/** Shape the desktop agent returns for any relayed action. */
|
|
type RelayResult = {
|
|
screenshotDataUrl?: string | null;
|
|
displaySize?: { width: number; height: number } | null;
|
|
cursor?: { x: number; y: number } | null;
|
|
position?: Point | null;
|
|
};
|
|
|
|
function applyRelayResult(session: ComputerUseSession, result: RelayResult): void {
|
|
if (typeof result.screenshotDataUrl === 'string') {
|
|
session.screenshotDataUrl = result.screenshotDataUrl;
|
|
}
|
|
if (result.displaySize) {
|
|
session.displaySize = result.displaySize;
|
|
}
|
|
if (result.cursor) {
|
|
session.cursor = { x: result.cursor.x, y: result.cursor.y, actor: session.cursor?.actor ?? 'agent' };
|
|
}
|
|
session.updatedAt = new Date().toISOString();
|
|
}
|
|
|
|
async function refreshScreenshot(session: ComputerUseSession): Promise<void> {
|
|
if (getRuntime() === 'cloud') {
|
|
const result = (await desktopAgentRelay.relay('screenshot', { sessionId: session.id })) as RelayResult;
|
|
applyRelayResult(session, result);
|
|
return;
|
|
}
|
|
const { dataUrl, size } = await captureScreenshotRuntime();
|
|
session.screenshotDataUrl = dataUrl;
|
|
if (size) {
|
|
session.displaySize = size;
|
|
}
|
|
session.updatedAt = new Date().toISOString();
|
|
}
|
|
|
|
/** Runs one action and refreshes the session screenshot afterwards. */
|
|
async function performAction(session: ComputerUseSession, action: ComputerAction): Promise<void> {
|
|
if (getRuntime() === 'cloud') {
|
|
const result = (await desktopAgentRelay.relay(action.type, {
|
|
...action,
|
|
sessionId: session.id,
|
|
displaySize: session.displaySize,
|
|
})) as RelayResult;
|
|
applyRelayResult(session, result);
|
|
return;
|
|
}
|
|
|
|
switch (action.type) {
|
|
case 'screenshot':
|
|
break;
|
|
case 'mouse_move':
|
|
await executor.moveTo(session, action.point);
|
|
break;
|
|
case 'click':
|
|
await executor.click(session, action.button, action.point, action.double === true);
|
|
break;
|
|
case 'drag':
|
|
await executor.drag(session, action.from, action.to, action.button ?? 'left');
|
|
break;
|
|
case 'type':
|
|
await executor.type(action.text);
|
|
break;
|
|
case 'key':
|
|
await executor.pressChord(action.key);
|
|
break;
|
|
case 'scroll':
|
|
await executor.scroll(session, action.direction, action.amount ?? 3, action.point);
|
|
break;
|
|
case 'wait':
|
|
await new Promise((resolve) => setTimeout(resolve, Math.max(0, Math.min(action.ms ?? 1000, 10_000))));
|
|
break;
|
|
}
|
|
await refreshScreenshot(session);
|
|
}
|
|
|
|
/** Reads the current cursor position in screenshot-pixel space. */
|
|
async function getCursorPosition(session: ComputerUseSession): Promise<Point> {
|
|
if (getRuntime() === 'cloud') {
|
|
const result = (await desktopAgentRelay.relay('cursor_position', {
|
|
sessionId: session.id,
|
|
displaySize: session.displaySize,
|
|
})) as RelayResult;
|
|
applyRelayResult(session, result);
|
|
if (result.position) {
|
|
return result.position;
|
|
}
|
|
return session.cursor ? { x: session.cursor.x, y: session.cursor.y } : { x: 0, y: 0 };
|
|
}
|
|
return executor.cursorPosition(session);
|
|
}
|
|
|
|
function assertReady(session: ComputerUseSession): void {
|
|
if (session.status !== 'ready') {
|
|
throw new Error(session.message || 'Computer Use session is not available.');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Whether agent tools may operate right now. Cloud mode depends purely on a
|
|
* connected desktop agent; local mode depends on the two opt-in settings.
|
|
*/
|
|
function agentToolsAvailable(): boolean {
|
|
if (getRuntime() === 'cloud') {
|
|
return desktopAgentRelay.isConnected();
|
|
}
|
|
const settings = readSettings();
|
|
return settings.enabled && settings.agentToolsEnabled;
|
|
}
|
|
|
|
function assertAgentToolsAvailable(): void {
|
|
if (agentToolsAvailable()) {
|
|
return;
|
|
}
|
|
throw new Error(
|
|
getRuntime() === 'cloud'
|
|
? 'No desktop agent is connected. Open the CloudCLI desktop app with Computer Use enabled.'
|
|
: 'Computer Use agent tools are disabled.'
|
|
);
|
|
}
|
|
|
|
export const computerUseService = {
|
|
async getSettings() {
|
|
return readSettings();
|
|
},
|
|
|
|
async updateSettings(settings: Partial<ComputerUseSettings>) {
|
|
const current = readSettings();
|
|
const nextSettings = {
|
|
...current,
|
|
enabled: typeof settings.enabled === 'boolean' ? settings.enabled : current.enabled,
|
|
agentToolsEnabled: typeof settings.agentToolsEnabled === 'boolean'
|
|
? settings.agentToolsEnabled
|
|
: current.agentToolsEnabled,
|
|
};
|
|
if (!nextSettings.enabled) {
|
|
nextSettings.agentToolsEnabled = false;
|
|
}
|
|
|
|
const next = writeSettings(nextSettings);
|
|
if (next.agentToolsEnabled) {
|
|
await this.registerAgentMcp();
|
|
} else if (current.agentToolsEnabled) {
|
|
await this.unregisterAgentMcp();
|
|
}
|
|
return next;
|
|
},
|
|
|
|
async getStatus() {
|
|
const settings = readSettings();
|
|
const readiness = getRuntimeReadiness();
|
|
const isCloud = getRuntime() === 'cloud';
|
|
const runtimeReady = readiness.nutInstalled && readiness.screenshotInstalled;
|
|
// Cloud availability is purely a function of a connected desktop agent; the
|
|
// hosted server has no screen of its own. Local availability needs the
|
|
// in-process nut-js runtime installed and the feature enabled.
|
|
const desktopAgentConnected = desktopAgentRelay.isConnected();
|
|
const available = isCloud
|
|
? desktopAgentConnected
|
|
: settings.enabled && runtimeReady;
|
|
|
|
return {
|
|
enabled: isCloud ? true : settings.enabled,
|
|
runtime: getRuntime(),
|
|
available,
|
|
requiresDesktopBridge: isCloud,
|
|
desktopAgentConnected,
|
|
nutInstalled: readiness.nutInstalled,
|
|
screenshotInstalled: readiness.screenshotInstalled,
|
|
installInProgress: readiness.installInProgress,
|
|
sessionCount: sessions.size,
|
|
agentToolsEnabled: isCloud ? desktopAgentConnected : settings.agentToolsEnabled,
|
|
mcpRecommended: !settings.agentToolsEnabled,
|
|
message: available ? 'Computer Use runtime is available.' : getSetupMessage(settings, readiness),
|
|
};
|
|
},
|
|
|
|
async registerAgentMcp() {
|
|
const { command, args } = getMcpCommand();
|
|
const results = await providerMcpService.addMcpServerToAllProviders({
|
|
name: MCP_SERVER_NAME,
|
|
scope: 'user',
|
|
transport: 'stdio',
|
|
command,
|
|
args,
|
|
env: {
|
|
CLOUDCLI_COMPUTER_USE_MCP_TOKEN: getOrCreateMcpToken(),
|
|
CLOUDCLI_COMPUTER_USE_API_URL: getMcpApiUrl(),
|
|
},
|
|
});
|
|
return { name: MCP_SERVER_NAME, command, args, results };
|
|
},
|
|
|
|
getMcpToken() {
|
|
return getOrCreateMcpToken();
|
|
},
|
|
|
|
async unregisterAgentMcp() {
|
|
const results = await Promise.all(MCP_PROVIDERS.map(async (provider) => {
|
|
try {
|
|
const result = await providerMcpService.removeProviderMcpServer(provider, {
|
|
name: MCP_SERVER_NAME,
|
|
scope: 'user',
|
|
});
|
|
return { provider, removed: result.removed };
|
|
} catch (error) {
|
|
return {
|
|
provider,
|
|
removed: false,
|
|
error: error instanceof Error ? error.message : 'Unknown error',
|
|
};
|
|
}
|
|
}));
|
|
return { name: MCP_SERVER_NAME, results };
|
|
},
|
|
|
|
async installRuntime() {
|
|
const result = await installRuntime();
|
|
return {
|
|
...result,
|
|
status: await this.getStatus(),
|
|
};
|
|
},
|
|
|
|
async listSessions(owner: ComputerUseOwner) {
|
|
const ownerId = getOwnerId(owner);
|
|
await expireStaleSessions();
|
|
return [...sessions.values()]
|
|
.filter((session) => canAccessSession(ownerId, session))
|
|
.map(publicSession);
|
|
},
|
|
|
|
async createSession(owner: ComputerUseOwner, options?: { createdBy?: 'user' | 'agent' }) {
|
|
const ownerId = getOwnerId(owner);
|
|
await expireStaleSessions();
|
|
const createdBy = options?.createdBy ?? 'user';
|
|
|
|
const now = new Date().toISOString();
|
|
const session: ComputerUseSession = {
|
|
id: randomUUID(),
|
|
ownerId,
|
|
createdBy,
|
|
runtime: getRuntime(),
|
|
status: 'unavailable',
|
|
screenshotDataUrl: null,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
lastAction: 'create',
|
|
// Consent is always OFF at creation — the user must explicitly grant control,
|
|
// even for agent-initiated sessions controlling the full desktop.
|
|
agentAccessEnabled: false,
|
|
displaySize: null,
|
|
message: null,
|
|
cursor: null,
|
|
};
|
|
|
|
const activeOwnerSessions = ownerSessions(ownerId).filter((item) => item.status === 'ready');
|
|
if (activeOwnerSessions.length >= MAX_SESSIONS_PER_OWNER) {
|
|
throw new Error(`Computer Use is limited to ${MAX_SESSIONS_PER_OWNER} active session(s).`);
|
|
}
|
|
|
|
const settings = readSettings();
|
|
const readiness = getRuntimeReadiness();
|
|
const isCloud = getRuntime() === 'cloud';
|
|
const runtimeReady = readiness.nutInstalled && readiness.screenshotInstalled;
|
|
const ready = isCloud
|
|
? desktopAgentRelay.isConnected()
|
|
: settings.enabled && runtimeReady;
|
|
|
|
if (!ready) {
|
|
session.message = getSetupMessage(settings, readiness);
|
|
sessions.set(session.id, session);
|
|
return publicSession(session);
|
|
}
|
|
|
|
// In cloud mode the linked desktop agent is the consent authority and prompts
|
|
// the user per its own consent mode, so the relay is allowed to act. In local
|
|
// mode the user must still grant control from the panel.
|
|
if (isCloud) {
|
|
session.agentAccessEnabled = true;
|
|
}
|
|
|
|
session.status = 'ready';
|
|
session.message = isCloud
|
|
? 'Computer Use session is ready on the linked desktop.'
|
|
: 'Computer Use session is ready. Grant control to let agents act.';
|
|
sessions.set(session.id, session);
|
|
try {
|
|
await refreshScreenshot(session);
|
|
} catch (error) {
|
|
session.status = 'unavailable';
|
|
session.message = error instanceof Error ? error.message : 'Failed to capture the screen.';
|
|
}
|
|
return publicSession(session);
|
|
},
|
|
|
|
async grantAgentAccess(owner: ComputerUseOwner, sessionId: string) {
|
|
const ownerId = getOwnerId(owner);
|
|
const session = sessions.get(sessionId);
|
|
if (!session || !canAccessSession(ownerId, session)) {
|
|
throw new Error('Computer Use session not found.');
|
|
}
|
|
session.agentAccessEnabled = true;
|
|
session.updatedAt = new Date().toISOString();
|
|
session.lastAction = 'consent:grant';
|
|
return publicSession(session);
|
|
},
|
|
|
|
async revokeAgentAccess(owner: ComputerUseOwner, sessionId: string) {
|
|
const ownerId = getOwnerId(owner);
|
|
const session = sessions.get(sessionId);
|
|
if (!session || !canAccessSession(ownerId, session)) {
|
|
throw new Error('Computer Use session not found.');
|
|
}
|
|
session.agentAccessEnabled = false;
|
|
session.updatedAt = new Date().toISOString();
|
|
session.lastAction = 'consent:revoke';
|
|
return publicSession(session);
|
|
},
|
|
|
|
async stopSession(owner: ComputerUseOwner, sessionId: string) {
|
|
const ownerId = getOwnerId(owner);
|
|
const session = sessions.get(sessionId);
|
|
if (!session || !canAccessSession(ownerId, session)) {
|
|
return { stopped: false };
|
|
}
|
|
|
|
session.status = 'stopped';
|
|
session.agentAccessEnabled = false;
|
|
session.updatedAt = new Date().toISOString();
|
|
session.lastAction = 'stop';
|
|
session.message = 'Computer Use session stopped. Agent control is revoked.';
|
|
if (getRuntime() === 'cloud' && desktopAgentRelay.isConnected()) {
|
|
// Best-effort: tell the desktop agent to forget this session's consent.
|
|
void desktopAgentRelay.relay('stop_session', { sessionId }).catch(() => undefined);
|
|
}
|
|
return { stopped: true, session: publicSession(session) };
|
|
},
|
|
|
|
async deleteSession(owner: ComputerUseOwner, sessionId: string) {
|
|
const ownerId = getOwnerId(owner);
|
|
const session = sessions.get(sessionId);
|
|
if (!session || !canAccessSession(ownerId, session)) {
|
|
return { deleted: false };
|
|
}
|
|
|
|
sessions.delete(sessionId);
|
|
return { deleted: true, sessionId };
|
|
},
|
|
|
|
// --- User-initiated actions (from the panel) -------------------------------
|
|
|
|
async userScreenshot(owner: ComputerUseOwner, sessionId: string) {
|
|
const ownerId = getOwnerId(owner);
|
|
const session = sessions.get(sessionId);
|
|
if (!session || !canAccessSession(ownerId, session)) {
|
|
throw new Error('Computer Use session not found.');
|
|
}
|
|
assertReady(session);
|
|
await refreshScreenshot(session);
|
|
session.lastAction = 'screenshot';
|
|
return publicSession(session);
|
|
},
|
|
|
|
async userClick(owner: ComputerUseOwner, sessionId: string, input: { x: number; y: number; button?: ClickButton; double?: boolean }) {
|
|
const ownerId = getOwnerId(owner);
|
|
const session = sessions.get(sessionId);
|
|
if (!session || !canAccessSession(ownerId, session)) {
|
|
throw new Error('Computer Use session not found.');
|
|
}
|
|
assertReady(session);
|
|
await performAction(session, {
|
|
type: 'click',
|
|
button: input.button || 'left',
|
|
point: { x: input.x, y: input.y },
|
|
double: input.double === true,
|
|
});
|
|
session.cursor = { x: input.x, y: input.y, actor: 'user' };
|
|
session.lastAction = input.double ? 'double_click' : 'click';
|
|
return publicSession(session);
|
|
},
|
|
|
|
async userType(owner: ComputerUseOwner, sessionId: string, text: string) {
|
|
const ownerId = getOwnerId(owner);
|
|
const session = sessions.get(sessionId);
|
|
if (!session || !canAccessSession(ownerId, session)) {
|
|
throw new Error('Computer Use session not found.');
|
|
}
|
|
assertReady(session);
|
|
await performAction(session, { type: 'type', text });
|
|
session.lastAction = 'type';
|
|
return publicSession(session);
|
|
},
|
|
|
|
async userPressKey(owner: ComputerUseOwner, sessionId: string, key: string) {
|
|
const ownerId = getOwnerId(owner);
|
|
const session = sessions.get(sessionId);
|
|
if (!session || !canAccessSession(ownerId, session)) {
|
|
throw new Error('Computer Use session not found.');
|
|
}
|
|
assertReady(session);
|
|
await performAction(session, { type: 'key', key });
|
|
session.lastAction = `key:${key}`;
|
|
return publicSession(session);
|
|
},
|
|
|
|
// --- Agent-initiated actions (via MCP) ------------------------------------
|
|
|
|
async createAgentSession() {
|
|
assertAgentToolsAvailable();
|
|
return this.createSession({ id: AGENT_OWNER_ID }, { createdBy: 'agent' });
|
|
},
|
|
|
|
async listAgentSessions() {
|
|
if (!agentToolsAvailable()) {
|
|
return [];
|
|
}
|
|
await expireStaleSessions();
|
|
return [...sessions.values()].map(publicSession);
|
|
},
|
|
|
|
/**
|
|
* Resolves a session the agent is allowed to act on. In local mode this
|
|
* enforces the in-process per-session consent flag. In cloud mode the linked
|
|
* desktop agent is the consent authority (it prompts the user per its own
|
|
* consent mode), so this only requires the relay to be connected.
|
|
*/
|
|
async getConsentedSession(sessionId: string): Promise<ComputerUseSession> {
|
|
assertAgentToolsAvailable();
|
|
const session = sessions.get(sessionId);
|
|
if (!session) {
|
|
throw new Error('Computer Use session not found.');
|
|
}
|
|
if (getRuntime() !== 'cloud' && !session.agentAccessEnabled) {
|
|
throw new Error('Computer Use session is awaiting user consent. Ask the user to grant control in the Computer panel.');
|
|
}
|
|
assertReady(session);
|
|
return session;
|
|
},
|
|
|
|
async agentScreenshot(sessionId: string) {
|
|
const session = await this.getConsentedSession(sessionId);
|
|
await refreshScreenshot(session);
|
|
session.lastAction = 'screenshot';
|
|
return publicSession(session);
|
|
},
|
|
|
|
async agentCursorPosition(sessionId: string) {
|
|
const session = await this.getConsentedSession(sessionId);
|
|
const point = await getCursorPosition(session);
|
|
session.cursor = { ...point, actor: 'agent' };
|
|
session.lastAction = 'cursor_position';
|
|
return { session: publicSession(session), position: point };
|
|
},
|
|
|
|
async agentMouseMove(sessionId: string, point: Point) {
|
|
const session = await this.getConsentedSession(sessionId);
|
|
await performAction(session, { type: 'mouse_move', point });
|
|
session.cursor = { ...point, actor: 'agent' };
|
|
session.lastAction = 'mouse_move';
|
|
return publicSession(session);
|
|
},
|
|
|
|
async agentClick(sessionId: string, button: ClickButton, point?: Point, doubleClick = false) {
|
|
const session = await this.getConsentedSession(sessionId);
|
|
await performAction(session, { type: 'click', button, point, double: doubleClick });
|
|
if (point) {
|
|
session.cursor = { ...point, actor: 'agent' };
|
|
}
|
|
session.lastAction = doubleClick ? 'double_click' : `${button}_click`;
|
|
return publicSession(session);
|
|
},
|
|
|
|
async agentDrag(sessionId: string, from: Point, to: Point, button: ClickButton = 'left') {
|
|
const session = await this.getConsentedSession(sessionId);
|
|
await performAction(session, { type: 'drag', from, to, button });
|
|
session.cursor = { ...to, actor: 'agent' };
|
|
session.lastAction = 'left_click_drag';
|
|
return publicSession(session);
|
|
},
|
|
|
|
async agentType(sessionId: string, text: string) {
|
|
const session = await this.getConsentedSession(sessionId);
|
|
await performAction(session, { type: 'type', text });
|
|
session.lastAction = 'type';
|
|
return publicSession(session);
|
|
},
|
|
|
|
async agentKey(sessionId: string, key: string) {
|
|
const session = await this.getConsentedSession(sessionId);
|
|
await performAction(session, { type: 'key', key });
|
|
session.lastAction = `key:${key}`;
|
|
return publicSession(session);
|
|
},
|
|
|
|
async agentScroll(sessionId: string, input: { direction: ScrollDirection; amount?: number; x?: number; y?: number }) {
|
|
const session = await this.getConsentedSession(sessionId);
|
|
const point = typeof input.x === 'number' && typeof input.y === 'number' ? { x: input.x, y: input.y } : undefined;
|
|
await performAction(session, { type: 'scroll', direction: input.direction, amount: input.amount, point });
|
|
if (point) {
|
|
session.cursor = { ...point, actor: 'agent' };
|
|
}
|
|
session.lastAction = `scroll:${input.direction}`;
|
|
return publicSession(session);
|
|
},
|
|
|
|
async agentWait(sessionId: string, timeoutMs?: number) {
|
|
const session = await this.getConsentedSession(sessionId);
|
|
await performAction(session, { type: 'wait', ms: timeoutMs });
|
|
session.lastAction = 'wait';
|
|
return publicSession(session);
|
|
},
|
|
|
|
async agentStopSession(sessionId: string) {
|
|
assertAgentToolsAvailable();
|
|
return this.stopSession({ id: AGENT_OWNER_ID }, sessionId);
|
|
},
|
|
|
|
/**
|
|
* Cloud only: when a desktop agent links to this hosted environment, expose
|
|
* the computer_* MCP tools to every provider so the running agent can use
|
|
* them. Mirrors `registerAgentMcp` but is driven by relay connectivity rather
|
|
* than a settings toggle.
|
|
*/
|
|
async onDesktopAgentConnected() {
|
|
if (getRuntime() !== 'cloud') {
|
|
return;
|
|
}
|
|
try {
|
|
await this.registerAgentMcp();
|
|
} catch (error) {
|
|
console.warn('[Computer Use] Failed to register MCP for linked desktop agent:', error instanceof Error ? error.message : error);
|
|
}
|
|
},
|
|
|
|
/** Cloud only: tear down sessions when the last desktop agent disconnects. */
|
|
async onDesktopAgentDisconnected() {
|
|
if (getRuntime() !== 'cloud' || desktopAgentRelay.isConnected()) {
|
|
return;
|
|
}
|
|
for (const session of sessions.values()) {
|
|
if (session.status === 'ready') {
|
|
session.status = 'stopped';
|
|
session.agentAccessEnabled = false;
|
|
session.updatedAt = new Date().toISOString();
|
|
session.lastAction = 'agent-disconnected';
|
|
session.message = 'The linked desktop agent disconnected.';
|
|
}
|
|
}
|
|
},
|
|
|
|
async stopAllSessions() {
|
|
for (const session of sessions.values()) {
|
|
session.status = 'stopped';
|
|
session.agentAccessEnabled = false;
|
|
session.updatedAt = new Date().toISOString();
|
|
session.lastAction = 'shutdown';
|
|
session.message = 'Computer Use session stopped during server shutdown.';
|
|
}
|
|
},
|
|
};
|
|
|
|
// Drive cloud MCP exposure + session teardown off desktop-agent connectivity.
|
|
desktopAgentRelay.setHooks({
|
|
onFirstConnect: () => computerUseService.onDesktopAgentConnected(),
|
|
onLastDisconnect: () => computerUseService.onDesktopAgentDisconnected(),
|
|
});
|
|
|
|
process.once('beforeExit', () => {
|
|
void computerUseService.stopAllSessions();
|
|
});
|