@wonderwhy-er/desktop-commander 0.2.29-alpha.0 → 0.2.29-alpha.1

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/index.js CHANGED
@@ -7,6 +7,7 @@ import { runSetup } from './npm-scripts/setup.js';
7
7
  import { runUninstall } from './npm-scripts/uninstall.js';
8
8
  import { capture } from './utils/capture.js';
9
9
  import { logToStderr, logger } from './utils/logger.js';
10
+ import { runRemote } from './npm-scripts/remote.js';
10
11
  // Store messages to defer until after initialization
11
12
  const deferredMessages = [];
12
13
  function deferLog(level, message) {
@@ -24,6 +25,15 @@ async function runServer() {
24
25
  await runUninstall();
25
26
  return;
26
27
  }
28
+ if (process.argv[2] === 'remote') {
29
+ await runRemote();
30
+ return;
31
+ }
32
+ // Check if first argument is "remote"
33
+ if (process.argv[2] === 'remote') {
34
+ await runRemote();
35
+ return;
36
+ }
27
37
  // Parse command line arguments for onboarding control
28
38
  const DISABLE_ONBOARDING = process.argv.includes('--no-onboarding');
29
39
  if (DISABLE_ONBOARDING) {
@@ -0,0 +1 @@
1
+ export declare function runRemote(): Promise<void>;
@@ -0,0 +1,6 @@
1
+ import { MCPDevice } from '../remote-device/device.js';
2
+ export async function runRemote() {
3
+ const persistSession = process.argv.includes('--persist-session');
4
+ const device = new MCPDevice({ persistSession });
5
+ await device.start();
6
+ }
@@ -0,0 +1,143 @@
1
+ interface McpConfig {
2
+ command: string;
3
+ args: string[];
4
+ cwd?: string;
5
+ env?: Record<string, string>;
6
+ }
7
+ export declare class DesktopCommanderIntegration {
8
+ private mcpClient;
9
+ private mcpTransport;
10
+ private isReady;
11
+ initialize(): Promise<void>;
12
+ resolveMcpConfig(): Promise<McpConfig | null>;
13
+ executeTool(toolName: string, args: any): Promise<{
14
+ [x: string]: unknown;
15
+ content: ({
16
+ type: "text";
17
+ text: string;
18
+ annotations?: {
19
+ audience?: ("user" | "assistant")[] | undefined;
20
+ priority?: number | undefined;
21
+ lastModified?: string | undefined;
22
+ } | undefined;
23
+ _meta?: Record<string, unknown> | undefined;
24
+ } | {
25
+ type: "image";
26
+ data: string;
27
+ mimeType: string;
28
+ annotations?: {
29
+ audience?: ("user" | "assistant")[] | undefined;
30
+ priority?: number | undefined;
31
+ lastModified?: string | undefined;
32
+ } | undefined;
33
+ _meta?: Record<string, unknown> | undefined;
34
+ } | {
35
+ type: "audio";
36
+ data: string;
37
+ mimeType: string;
38
+ annotations?: {
39
+ audience?: ("user" | "assistant")[] | undefined;
40
+ priority?: number | undefined;
41
+ lastModified?: string | undefined;
42
+ } | undefined;
43
+ _meta?: Record<string, unknown> | undefined;
44
+ } | {
45
+ type: "resource";
46
+ resource: {
47
+ uri: string;
48
+ text: string;
49
+ mimeType?: string | undefined;
50
+ _meta?: Record<string, unknown> | undefined;
51
+ } | {
52
+ uri: string;
53
+ blob: string;
54
+ mimeType?: string | undefined;
55
+ _meta?: Record<string, unknown> | undefined;
56
+ };
57
+ annotations?: {
58
+ audience?: ("user" | "assistant")[] | undefined;
59
+ priority?: number | undefined;
60
+ lastModified?: string | undefined;
61
+ } | undefined;
62
+ _meta?: Record<string, unknown> | undefined;
63
+ } | {
64
+ uri: string;
65
+ name: string;
66
+ type: "resource_link";
67
+ description?: string | undefined;
68
+ mimeType?: string | undefined;
69
+ annotations?: {
70
+ audience?: ("user" | "assistant")[] | undefined;
71
+ priority?: number | undefined;
72
+ lastModified?: string | undefined;
73
+ } | undefined;
74
+ _meta?: {
75
+ [x: string]: unknown;
76
+ } | undefined;
77
+ icons?: {
78
+ src: string;
79
+ mimeType?: string | undefined;
80
+ sizes?: string[] | undefined;
81
+ theme?: "light" | "dark" | undefined;
82
+ }[] | undefined;
83
+ title?: string | undefined;
84
+ })[];
85
+ _meta?: {
86
+ [x: string]: unknown;
87
+ progressToken?: string | number | undefined;
88
+ "io.modelcontextprotocol/related-task"?: {
89
+ taskId: string;
90
+ } | undefined;
91
+ } | undefined;
92
+ structuredContent?: Record<string, unknown> | undefined;
93
+ isError?: boolean | undefined;
94
+ } | {
95
+ [x: string]: unknown;
96
+ toolResult: unknown;
97
+ _meta?: {
98
+ [x: string]: unknown;
99
+ progressToken?: string | number | undefined;
100
+ "io.modelcontextprotocol/related-task"?: {
101
+ taskId: string;
102
+ } | undefined;
103
+ } | undefined;
104
+ }>;
105
+ getCapabilities(): Promise<{
106
+ tools: {
107
+ inputSchema: {
108
+ [x: string]: unknown;
109
+ type: "object";
110
+ properties?: Record<string, object> | undefined;
111
+ required?: string[] | undefined;
112
+ };
113
+ name: string;
114
+ description?: string | undefined;
115
+ outputSchema?: {
116
+ [x: string]: unknown;
117
+ type: "object";
118
+ properties?: Record<string, object> | undefined;
119
+ required?: string[] | undefined;
120
+ } | undefined;
121
+ annotations?: {
122
+ title?: string | undefined;
123
+ readOnlyHint?: boolean | undefined;
124
+ destructiveHint?: boolean | undefined;
125
+ idempotentHint?: boolean | undefined;
126
+ openWorldHint?: boolean | undefined;
127
+ } | undefined;
128
+ execution?: {
129
+ taskSupport?: "optional" | "required" | "forbidden" | undefined;
130
+ } | undefined;
131
+ _meta?: Record<string, unknown> | undefined;
132
+ icons?: {
133
+ src: string;
134
+ mimeType?: string | undefined;
135
+ sizes?: string[] | undefined;
136
+ theme?: "light" | "dark" | undefined;
137
+ }[] | undefined;
138
+ title?: string | undefined;
139
+ }[];
140
+ }>;
141
+ shutdown(): Promise<void>;
142
+ }
143
+ export {};
@@ -0,0 +1,136 @@
1
+ import { spawn } from 'child_process';
2
+ import path from 'path';
3
+ import fs from 'fs/promises';
4
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
5
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
6
+ import { fileURLToPath } from 'url';
7
+ const __filename = fileURLToPath(import.meta.url);
8
+ const __dirname = path.dirname(__filename);
9
+ export class DesktopCommanderIntegration {
10
+ constructor() {
11
+ this.mcpClient = null;
12
+ this.mcpTransport = null;
13
+ this.isReady = false;
14
+ }
15
+ async initialize() {
16
+ const config = await this.resolveMcpConfig();
17
+ if (!config) {
18
+ throw new Error('Desktop Commander MCP not found. Please install it globally via `npm install -g @wonderwhy-er/desktop-commander` or build the local project.');
19
+ }
20
+ console.log(` - ⏳ Connecting to Local Desktop Commander MCP using: ${config.command} ${config.args.join(' ')}`);
21
+ try {
22
+ this.mcpTransport = new StdioClientTransport(config);
23
+ // Create MCP client
24
+ this.mcpClient = new Client({
25
+ name: "desktop-commander-client",
26
+ version: "1.0.0"
27
+ }, {
28
+ capabilities: {}
29
+ });
30
+ // Connect to Desktop Commander
31
+ await this.mcpClient.connect(this.mcpTransport);
32
+ this.isReady = true;
33
+ console.log(' - 🔌 Connected to Desktop Commander MCP');
34
+ }
35
+ catch (error) {
36
+ console.error(' - ❌ Failed to connect to Desktop Commander MCP:', error);
37
+ throw error;
38
+ }
39
+ }
40
+ async resolveMcpConfig() {
41
+ // Option 1: Development/Local Build
42
+ // Adjusting path resolution since we are now in src/remote-device and dist is in root/dist
43
+ // Original: path.resolve(__dirname, '../../dist/index.js')
44
+ const devPath = path.resolve(__dirname, '../../dist/index.js');
45
+ try {
46
+ await fs.access(devPath);
47
+ console.debug(' - 🔍 Found local MCP server at:', devPath);
48
+ return {
49
+ command: process.execPath, // Use the current node executable
50
+ args: [devPath],
51
+ cwd: path.dirname(devPath)
52
+ };
53
+ }
54
+ catch {
55
+ // Local file not found, continue...
56
+ }
57
+ // Option 2: Global Installation
58
+ const commandName = 'desktop-commander';
59
+ try {
60
+ await new Promise((resolve, reject) => {
61
+ // Use 'which' to check if the command exists in PATH
62
+ // We can't run it directly as it's an stdio MCP server that waits for input
63
+ const check = spawn('which', [commandName]);
64
+ check.on('error', reject);
65
+ check.on('close', (code) => code === 0 ? resolve() : reject(new Error('Command not found')));
66
+ });
67
+ console.debug(' - Found global desktop-commander CLI');
68
+ return {
69
+ command: commandName,
70
+ args: []
71
+ };
72
+ }
73
+ catch {
74
+ // Global command not found
75
+ }
76
+ return null;
77
+ }
78
+ async executeTool(toolName, args) {
79
+ if (!this.isReady || !this.mcpClient) {
80
+ throw new Error('DesktopIntegration not initialized');
81
+ }
82
+ // Proxy other tools to MCP server
83
+ try {
84
+ console.log(`Forwarding tool call ${toolName} to MCP server`);
85
+ const result = await this.mcpClient.callTool({
86
+ name: toolName,
87
+ arguments: args
88
+ });
89
+ return result;
90
+ }
91
+ catch (error) {
92
+ console.error(`Error executing tool ${toolName}:`, error);
93
+ throw error;
94
+ }
95
+ }
96
+ async getCapabilities() {
97
+ if (!this.mcpClient)
98
+ return { tools: [] };
99
+ try {
100
+ // List tools from MCP server
101
+ const mcpTools = await this.mcpClient.listTools();
102
+ // Merge tools
103
+ return {
104
+ tools: mcpTools.tools || []
105
+ };
106
+ }
107
+ catch (error) {
108
+ console.error('Error fetching capabilities:', error);
109
+ // Fallback to local tools
110
+ return {
111
+ tools: []
112
+ };
113
+ }
114
+ }
115
+ async shutdown() {
116
+ if (this.mcpClient) {
117
+ try {
118
+ await this.mcpClient.close();
119
+ }
120
+ catch (e) {
121
+ console.error('Error closing MCP client:', e);
122
+ }
123
+ this.mcpClient = null;
124
+ }
125
+ if (this.mcpTransport) {
126
+ try {
127
+ await this.mcpTransport.close();
128
+ }
129
+ catch (e) {
130
+ console.error('Error closing MCP transport:', e);
131
+ }
132
+ this.mcpTransport = null;
133
+ }
134
+ this.isReady = false;
135
+ }
136
+ }
@@ -0,0 +1,13 @@
1
+ interface AuthSession {
2
+ access_token: string;
3
+ refresh_token: string | null;
4
+ }
5
+ export declare class DeviceAuthenticator {
6
+ private baseServerUrl;
7
+ constructor(baseServerUrl: string);
8
+ authenticate(): Promise<AuthSession>;
9
+ private isDesktopEnvironment;
10
+ private authenticateDesktop;
11
+ private authenticateHeadless;
12
+ }
13
+ export {};
@@ -0,0 +1,160 @@
1
+ import express from 'express';
2
+ import { createServer } from 'http';
3
+ import open from 'open';
4
+ import readline from 'readline';
5
+ import fs from 'fs';
6
+ import path from 'path';
7
+ import { fileURLToPath } from 'url';
8
+ const __filename = fileURLToPath(import.meta.url);
9
+ const __dirname = path.dirname(__filename);
10
+ function escapeHtml(text) {
11
+ if (text === null || text === undefined)
12
+ return '';
13
+ return String(text)
14
+ .replace(/&/g, "&amp;")
15
+ .replace(/</g, "&lt;")
16
+ .replace(/>/g, "&gt;")
17
+ .replace(/"/g, "&quot;")
18
+ .replace(/'/g, "&#039;");
19
+ }
20
+ const CALLBACK_PORT = 8121;
21
+ export class DeviceAuthenticator {
22
+ constructor(baseServerUrl) {
23
+ this.baseServerUrl = baseServerUrl;
24
+ }
25
+ async authenticate() {
26
+ // Detect environment
27
+ const isDesktop = this.isDesktopEnvironment();
28
+ console.log(`🔐 Starting authentication (${isDesktop ? 'desktop' : 'headless'} mode)...`);
29
+ if (isDesktop) {
30
+ return this.authenticateDesktop();
31
+ }
32
+ else {
33
+ return this.authenticateHeadless();
34
+ }
35
+ }
36
+ isDesktopEnvironment() {
37
+ // Check if we're in a desktop environment
38
+ return process.platform === 'darwin' ||
39
+ process.platform === 'win32' ||
40
+ (process.platform === 'linux' && !!process.env.DISPLAY);
41
+ }
42
+ async authenticateDesktop() {
43
+ const app = express();
44
+ const callbackUrl = `http://localhost:${CALLBACK_PORT}/callback`;
45
+ return new Promise((resolve, reject) => {
46
+ let server;
47
+ // Setup callback handler
48
+ app.get('/callback', (req, res) => {
49
+ const { access_token, refresh_token, code, error, error_description } = req.query;
50
+ // Extract the actual token (could be in access_token or code parameter)
51
+ const token = access_token || code;
52
+ if (error) {
53
+ const safeError = escapeHtml(error);
54
+ const safeErrorDesc = escapeHtml(error_description || 'Unknown error');
55
+ res.send(`
56
+ <h2>Authentication Failed</h2>
57
+ <p>Error: ${safeError}</p>
58
+ <p>Description: ${safeErrorDesc}</p>
59
+ <p>You can close this window.</p>
60
+ `);
61
+ server.close();
62
+ reject(new Error(`${error}: ${error_description}`));
63
+ }
64
+ else if (token) {
65
+ const templatePath = path.join(__dirname, 'templates', 'auth-success.html');
66
+ const htmlContent = fs.readFileSync(templatePath, 'utf8');
67
+ res.send(htmlContent);
68
+ server.close();
69
+ console.log(' - ✅ Authentication successful, token received');
70
+ resolve({
71
+ access_token: token,
72
+ refresh_token: refresh_token || null
73
+ });
74
+ }
75
+ else {
76
+ console.log('❌ No token found in callback:', req.query);
77
+ const safeParams = escapeHtml(Object.keys(req.query).join(', '));
78
+ res.send(`
79
+ <h2>Authentication Failed</h2>
80
+ <p>No access token received</p>
81
+ <p>Received parameters: ${safeParams}</p>
82
+ <p>You can close this window.</p>
83
+ `);
84
+ server.close();
85
+ reject(new Error('No access token received'));
86
+ }
87
+ });
88
+ // Start callback server
89
+ server = createServer(app);
90
+ server.listen(CALLBACK_PORT, () => {
91
+ const authUrl = `${this.baseServerUrl}/?redirect_uri=${encodeURIComponent(callbackUrl)}&device=true`;
92
+ console.log(' - 🌐 Opening browser for authentication...');
93
+ console.log(` - If browser doesn't open, visit: ${authUrl}`);
94
+ // Open browser
95
+ open(authUrl).catch(() => {
96
+ console.log(' - Could not open browser automatically.');
97
+ console.log(` - Please visit: ${authUrl}`);
98
+ });
99
+ });
100
+ server.on('error', (err) => {
101
+ reject(new Error(`Failed to start callback server: ${err.message}`));
102
+ });
103
+ // Timeout after 5 minutes
104
+ setTimeout(() => {
105
+ if (server.listening) {
106
+ server.close();
107
+ reject(new Error(' - Authentication timeout - no response received'));
108
+ }
109
+ }, 5 * 60 * 1000);
110
+ });
111
+ }
112
+ async authenticateHeadless() {
113
+ console.log('\n🔗 Manual Authentication Required:');
114
+ console.log('─'.repeat(50));
115
+ console.log(`1. Open this URL in a browser: ${this.baseServerUrl}/`);
116
+ console.log('2. Complete the authentication process');
117
+ console.log('3. You will be redirected to a URL with parameters.');
118
+ console.log(' If using device mode, look for access_token and refresh_token.');
119
+ console.log('4. Copy the access_token (and refresh_token if available) and paste here.');
120
+ console.log(' Format: access_token OR {"access_token":"...", "refresh_token":"..."}');
121
+ console.log('─'.repeat(50));
122
+ const rl = readline.createInterface({
123
+ input: process.stdin,
124
+ output: process.stdout
125
+ });
126
+ return new Promise((resolve, reject) => {
127
+ rl.question('\n🔑 Enter Access Token or JSON: ', (input) => {
128
+ rl.close();
129
+ const trimmedInput = input.trim();
130
+ if (!trimmedInput) {
131
+ reject(new Error('Empty input provided'));
132
+ return;
133
+ }
134
+ try {
135
+ // Try parsing as JSON first
136
+ const json = JSON.parse(trimmedInput);
137
+ if (json.access_token) {
138
+ resolve({
139
+ access_token: json.access_token,
140
+ refresh_token: json.refresh_token || null
141
+ });
142
+ return;
143
+ }
144
+ }
145
+ catch (e) {
146
+ // Not JSON, treat as raw token
147
+ }
148
+ if (trimmedInput.length < 10) {
149
+ reject(new Error('Invalid token format (too short)'));
150
+ }
151
+ else {
152
+ resolve({
153
+ access_token: trimmedInput,
154
+ refresh_token: null
155
+ });
156
+ }
157
+ });
158
+ });
159
+ }
160
+ }
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env node
2
+ export interface MCPDeviceOptions {
3
+ persistSession?: boolean;
4
+ }
5
+ export declare class MCPDevice {
6
+ private baseServerUrl;
7
+ private remoteChannel;
8
+ private deviceId;
9
+ private user;
10
+ private isShuttingDown;
11
+ private configPath;
12
+ private persistSession;
13
+ private desktop;
14
+ constructor(options?: MCPDeviceOptions);
15
+ private setupShutdownHandlers;
16
+ start(): Promise<void>;
17
+ loadPersistedConfig(): Promise<any>;
18
+ savePersistedConfig(session: any): Promise<void>;
19
+ fetchSupabaseConfig(): Promise<{
20
+ supabaseUrl: any;
21
+ anonKey: any;
22
+ }>;
23
+ handleNewToolCall(payload: any): Promise<void>;
24
+ shutdown(): Promise<void>;
25
+ }
@@ -0,0 +1,275 @@
1
+ #!/usr/bin/env node
2
+ import { RemoteChannel } from './remote-channel.js';
3
+ import { DeviceAuthenticator } from './device-authenticator.js';
4
+ import { DesktopCommanderIntegration } from './desktop-commander-integration.js';
5
+ import { fileURLToPath } from 'url';
6
+ import os from 'os';
7
+ import fs from 'fs/promises';
8
+ import path from 'path';
9
+ export class MCPDevice {
10
+ constructor(options = {}) {
11
+ this.baseServerUrl = process.env.MCP_SERVER_URL || 'https://mcp.desktopcommander.app';
12
+ this.remoteChannel = new RemoteChannel();
13
+ this.deviceId = null;
14
+ this.user = null;
15
+ this.isShuttingDown = false;
16
+ this.configPath = path.join(os.homedir(), '.desktop-commander-device', 'device.json');
17
+ this.persistSession = options.persistSession || false;
18
+ // Initialize desktop integration
19
+ this.desktop = new DesktopCommanderIntegration();
20
+ // Graceful shutdown handlers (only set once)
21
+ this.setupShutdownHandlers();
22
+ }
23
+ setupShutdownHandlers() {
24
+ const handleShutdown = async (signal) => {
25
+ if (this.isShuttingDown) {
26
+ console.log(`\n${signal} received, but already shutting down...`);
27
+ return;
28
+ }
29
+ this.isShuttingDown = true;
30
+ console.log(`\n${signal} received, initiating graceful shutdown...`);
31
+ // Force exit after 3 seconds if graceful shutdown hangs
32
+ const forceExit = setTimeout(() => {
33
+ console.error('⚠️ Graceful shutdown timed out, forcing exit...');
34
+ process.exit(1);
35
+ }, 3000);
36
+ try {
37
+ await this.shutdown();
38
+ clearTimeout(forceExit);
39
+ process.exit(0);
40
+ }
41
+ catch (error) {
42
+ console.error('Error during shutdown:', error);
43
+ process.exit(1);
44
+ }
45
+ };
46
+ process.on('SIGINT', () => {
47
+ handleShutdown('SIGINT');
48
+ });
49
+ process.on('SIGTERM', () => {
50
+ handleShutdown('SIGTERM');
51
+ });
52
+ }
53
+ async start() {
54
+ try {
55
+ console.log('🚀 Starting MCP Device...');
56
+ if (process.env.DEBUG_MODE === 'true') {
57
+ console.log(` - 🐞 DEBUG_MODE`);
58
+ }
59
+ // Initialize desktop integration
60
+ await this.desktop.initialize();
61
+ console.log(`⏳ Connecting to Remote MCP ${this.baseServerUrl}`);
62
+ const { supabaseUrl, anonKey } = await this.fetchSupabaseConfig();
63
+ console.log(` - 🔌 Connected to Remote MCP`);
64
+ // Initialize Remote Channel
65
+ this.remoteChannel.initialize(supabaseUrl, anonKey);
66
+ // Load persisted configuration (deviceId, session)
67
+ let session = await this.loadPersistedConfig();
68
+ // 2. Set Session or Authenticate
69
+ if (session) {
70
+ const { error } = await this.remoteChannel.setSession(session);
71
+ if (error) {
72
+ console.log(' - ⚠️ Persisted session invalid:', error.message);
73
+ session = null;
74
+ }
75
+ else {
76
+ console.log(' - ✅ Session restored');
77
+ }
78
+ }
79
+ if (!session) {
80
+ console.log('\n🔐 Authenticating with Remote MCP server...');
81
+ const authenticator = new DeviceAuthenticator(this.baseServerUrl);
82
+ session = await authenticator.authenticate();
83
+ // Set session in Remote Channel
84
+ const { error } = await this.remoteChannel.setSession(session);
85
+ if (error)
86
+ throw error;
87
+ }
88
+ // 3. Setup Token Refresh Listener
89
+ this.remoteChannel.onAuthStateChange(async (event, newSession) => {
90
+ const eventMap = {
91
+ 'SIGNED_IN': '🔑 User signed in',
92
+ 'TOKEN_REFRESHED': '🔄 Token refreshed',
93
+ 'SIGNED_OUT': '⚠️ User signed out',
94
+ };
95
+ if (eventMap[event]) {
96
+ console.log(eventMap[event]);
97
+ }
98
+ });
99
+ // Force save the current session immediately to ensure it's persisted
100
+ const currentSessionStore = await this.remoteChannel.getSession();
101
+ await this.savePersistedConfig(currentSessionStore.data.session);
102
+ // Get user info
103
+ const { data: { user }, error: userError } = await this.remoteChannel.getUser();
104
+ if (userError)
105
+ throw userError;
106
+ this.user = user;
107
+ const deviceName = os.hostname();
108
+ // Register as device
109
+ this.deviceId = await this.remoteChannel.registerDevice(this.user.id, await this.desktop.getCapabilities(), this.deviceId, deviceName);
110
+ // Also save session again just in case (optional, but harmless)
111
+ const { data: { session: currentSession } } = await this.remoteChannel.getSession();
112
+ await this.savePersistedConfig(currentSession);
113
+ // Subscribe to tool calls
114
+ await this.remoteChannel.subscribe(this.user.id, (payload) => this.handleNewToolCall(payload));
115
+ console.log('✅ Device ready:');
116
+ console.log(` - Device ID: ${this.deviceId}`);
117
+ console.log(` - Device Name: ${deviceName}`);
118
+ // Keep process alive
119
+ this.remoteChannel.startHeartbeat(this.deviceId);
120
+ }
121
+ catch (error) {
122
+ console.error(' - ❌ Device startup failed:', error.message);
123
+ if (error.stack && process.env.DEBUG_MODE === 'true') {
124
+ console.error('Stack trace:', error.stack);
125
+ }
126
+ await this.shutdown();
127
+ process.exit(1);
128
+ }
129
+ }
130
+ async loadPersistedConfig() {
131
+ try {
132
+ const data = await fs.readFile(this.configPath, 'utf8');
133
+ const config = JSON.parse(data);
134
+ this.deviceId = config?.deviceId;
135
+ console.log('💾 Found persisted session for device ' + this.deviceId);
136
+ if (config.session) {
137
+ return config.session;
138
+ }
139
+ return null;
140
+ }
141
+ catch (error) {
142
+ if (error.code !== 'ENOENT') {
143
+ console.warn('⚠️ Failed to load config:', error.message);
144
+ }
145
+ return null;
146
+ }
147
+ finally {
148
+ // No need to ensure device ID here
149
+ }
150
+ }
151
+ async savePersistedConfig(session) {
152
+ try {
153
+ const config = {
154
+ deviceId: this.deviceId,
155
+ // Only save session if --persist-session flag is set
156
+ session: (session && this.persistSession) ? {
157
+ access_token: session.access_token,
158
+ refresh_token: session.refresh_token
159
+ } : null
160
+ };
161
+ // Ensure the config directory exists
162
+ await fs.mkdir(path.dirname(this.configPath), { recursive: true });
163
+ await fs.writeFile(this.configPath, JSON.stringify(config, null, 2), { mode: 0o600 });
164
+ // if (session) console.debug('💾 Session saved to device.json');
165
+ }
166
+ catch (error) {
167
+ console.error(' - ❌ Failed to save config:', error.message);
168
+ }
169
+ }
170
+ async fetchSupabaseConfig() {
171
+ // No auth header needed for this public endpoint
172
+ const response = await fetch(`${this.baseServerUrl}/api/mcp-info`);
173
+ if (!response.ok) {
174
+ throw new Error(`Failed to fetch Supabase config: ${response.statusText}`);
175
+ }
176
+ const config = await response.json();
177
+ return {
178
+ supabaseUrl: config.supabaseUrl,
179
+ anonKey: config.supabaseAnonKey
180
+ };
181
+ }
182
+ // Methods moved to RemoteChannel
183
+ async handleNewToolCall(payload) {
184
+ const toolCall = payload.new;
185
+ // Assuming database also renames agent_id to device_id, but user only said rename agent -> device everywhere but only inside src/remote-device
186
+ // If the database column is still agent_id, we need a mapping.
187
+ // However, the user said "literally all agent should be renamed to device everywhere", so we assume DB column is device_id.
188
+ const { id: call_id, tool_name, tool_args, device_id } = toolCall;
189
+ // Only process jobs for this device
190
+ if (device_id && device_id !== this.deviceId) {
191
+ return;
192
+ }
193
+ console.log(`🔧 Received tool call ${call_id}: ${tool_name} ${JSON.stringify(tool_args)}`);
194
+ try {
195
+ // Update call status to executing
196
+ await this.remoteChannel.markCallExecuting(call_id);
197
+ let result;
198
+ // Handle 'ping' tool specially
199
+ if (tool_name === 'ping') {
200
+ result = {
201
+ content: [{
202
+ type: 'text',
203
+ text: `pong ${new Date().toISOString()}`
204
+ }]
205
+ };
206
+ }
207
+ else if (tool_name === 'shutdown') {
208
+ result = {
209
+ content: [{
210
+ type: 'text',
211
+ text: `Shutdown initialized at ${new Date().toISOString()}`
212
+ }]
213
+ };
214
+ // Trigger shutdown after sending response
215
+ setTimeout(async () => {
216
+ console.log('🛑 Remote shutdown requested. Exiting...');
217
+ await this.shutdown();
218
+ process.exit(0);
219
+ }, 1000);
220
+ }
221
+ else {
222
+ // Execute other tools using desktop integration
223
+ result = await this.desktop.executeTool(tool_name, tool_args);
224
+ }
225
+ console.log(`✅ Tool call ${tool_name} completed:\r\n ${JSON.stringify(result)}`);
226
+ // Update database with result
227
+ await this.remoteChannel.updateCallResult(call_id, 'completed', result);
228
+ }
229
+ catch (error) {
230
+ console.error(`❌ Tool call ${tool_name} failed:`, error.message);
231
+ await this.remoteChannel.updateCallResult(call_id, 'failed', null, error.message);
232
+ }
233
+ }
234
+ // Moved to RemoteChannel
235
+ // Moved to RemoteChannel
236
+ async shutdown() {
237
+ if (this.isShuttingDown) {
238
+ return;
239
+ }
240
+ this.isShuttingDown = true;
241
+ console.log('\n🛑 Shutting down device...');
242
+ try {
243
+ // Remote shutdown
244
+ await this.remoteChannel.unsubscribe();
245
+ await this.remoteChannel.setOffline(this.deviceId);
246
+ // Shutdown desktop integration
247
+ await this.desktop.shutdown();
248
+ console.log('✓ Device shutdown complete');
249
+ }
250
+ catch (error) {
251
+ console.error('Shutdown error:', error.message);
252
+ }
253
+ }
254
+ }
255
+ // Start device if called directly or as a bin command
256
+ // When installed globally, npm creates a wrapper, so we need to check multiple conditions
257
+ const isMainModule = process.argv[1] && (
258
+ // Direct execution: node device.js
259
+ import.meta.url === `file://${process.argv[1]}` ||
260
+ fileURLToPath(import.meta.url) === process.argv[1] ||
261
+ // Global bin execution: desktop-commander-device (npm creates a wrapper)
262
+ process.argv[1].endsWith('desktop-commander-device') ||
263
+ process.argv[1].endsWith('desktop-commander-device.js'));
264
+ if (isMainModule) {
265
+ // Parse command-line arguments
266
+ const args = process.argv.slice(2);
267
+ const options = {
268
+ persistSession: args.includes('--persist-session')
269
+ };
270
+ if (options.persistSession) {
271
+ console.log('🔒 Session persistence enabled');
272
+ }
273
+ const device = new MCPDevice(options);
274
+ device.start();
275
+ }
@@ -0,0 +1,48 @@
1
+ import { Session, UserResponse } from '@supabase/supabase-js';
2
+ export interface AuthSession {
3
+ access_token: string;
4
+ refresh_token: string | null;
5
+ }
6
+ interface DeviceData {
7
+ user_id: string;
8
+ device_name: string;
9
+ capabilities: any;
10
+ status: string;
11
+ last_seen: string;
12
+ }
13
+ export declare class RemoteChannel {
14
+ private client;
15
+ private channel;
16
+ private heartbeatInterval;
17
+ initialize(url: string, key: string): void;
18
+ setSession(session: AuthSession): Promise<{
19
+ error: any;
20
+ }>;
21
+ getSession(): Promise<{
22
+ data: {
23
+ session: Session | null;
24
+ };
25
+ error: any;
26
+ }>;
27
+ getUser(): Promise<UserResponse>;
28
+ onAuthStateChange(callback: (event: string, session: Session | null) => void): {
29
+ data: {
30
+ subscription: import("@supabase/supabase-js").Subscription;
31
+ };
32
+ };
33
+ findDevice(deviceId: string, userId: string): Promise<{
34
+ id: any;
35
+ device_name: any;
36
+ } | null>;
37
+ updateDevice(deviceId: string, updates: any): Promise<import("@supabase/postgrest-js").PostgrestSingleResponse<null>>;
38
+ createDevice(deviceData: DeviceData): Promise<import("@supabase/postgrest-js").PostgrestSingleResponse<any>>;
39
+ registerDevice(userId: string, capabilities: any, currentDeviceId: string | null, deviceName: string): Promise<string>;
40
+ subscribe(userId: string, onToolCall: (payload: any) => void): Promise<void>;
41
+ markCallExecuting(callId: string): Promise<void>;
42
+ updateCallResult(callId: string, status: string, result?: any, errorMessage?: string | null): Promise<void>;
43
+ updateHeartbeat(deviceId: string): Promise<void>;
44
+ startHeartbeat(deviceId: string): void;
45
+ setOffline(deviceId: string | null): Promise<void>;
46
+ unsubscribe(): Promise<void>;
47
+ }
48
+ export {};
@@ -0,0 +1,198 @@
1
+ import { createClient } from '@supabase/supabase-js';
2
+ const HEARTBEAT_INTERVAL = 30000;
3
+ export class RemoteChannel {
4
+ constructor() {
5
+ this.client = null;
6
+ this.channel = null;
7
+ this.heartbeatInterval = null;
8
+ }
9
+ initialize(url, key) {
10
+ this.client = createClient(url, key);
11
+ }
12
+ async setSession(session) {
13
+ if (!this.client)
14
+ throw new Error('Client not initialized');
15
+ const { error } = await this.client.auth.setSession({
16
+ access_token: session.access_token,
17
+ refresh_token: session.refresh_token || ''
18
+ });
19
+ return { error };
20
+ }
21
+ async getSession() {
22
+ if (!this.client)
23
+ throw new Error('Client not initialized');
24
+ return await this.client.auth.getSession();
25
+ }
26
+ async getUser() {
27
+ if (!this.client)
28
+ throw new Error('Client not initialized');
29
+ return await this.client.auth.getUser();
30
+ }
31
+ onAuthStateChange(callback) {
32
+ if (!this.client)
33
+ throw new Error('Client not initialized');
34
+ return this.client.auth.onAuthStateChange(callback);
35
+ }
36
+ async findDevice(deviceId, userId) {
37
+ if (!this.client)
38
+ throw new Error('Client not initialized');
39
+ const { data, error } = await this.client
40
+ .from('mcp_devices')
41
+ .select('id, device_name')
42
+ .eq('id', deviceId)
43
+ .eq('user_id', userId)
44
+ .maybeSingle();
45
+ if (error)
46
+ throw error;
47
+ return data;
48
+ }
49
+ async updateDevice(deviceId, updates) {
50
+ if (!this.client)
51
+ throw new Error('Client not initialized');
52
+ return await this.client
53
+ .from('mcp_devices')
54
+ .update(updates)
55
+ .eq('id', deviceId);
56
+ }
57
+ async createDevice(deviceData) {
58
+ if (!this.client)
59
+ throw new Error('Client not initialized');
60
+ return await this.client
61
+ .from('mcp_devices')
62
+ .insert(deviceData)
63
+ .select()
64
+ .single();
65
+ }
66
+ async registerDevice(userId, capabilities, currentDeviceId, deviceName) {
67
+ let existingDevice = null;
68
+ if (currentDeviceId) {
69
+ try {
70
+ existingDevice = await this.findDevice(currentDeviceId, userId);
71
+ }
72
+ catch (e) {
73
+ // ignore error, treat as not found
74
+ console.warn('Error checking existing device:', e.message);
75
+ }
76
+ }
77
+ if (existingDevice) {
78
+ console.log(`🔍 Found existing device: ${existingDevice.device_name} (${existingDevice.id})`);
79
+ await this.updateDevice(existingDevice.id, {
80
+ status: 'online',
81
+ last_seen: new Date().toISOString(),
82
+ capabilities: capabilities,
83
+ device_name: deviceName
84
+ });
85
+ return existingDevice.id;
86
+ }
87
+ else {
88
+ if (currentDeviceId) {
89
+ console.log(` - ⚠️ persisted deviceId ${currentDeviceId} not found for user ${userId}. Creating new device...`);
90
+ }
91
+ else {
92
+ console.log(' - 📝 No existing device found, creating new registration...');
93
+ }
94
+ const { data: newDevice, error } = await this.createDevice({
95
+ user_id: userId,
96
+ device_name: deviceName,
97
+ capabilities: capabilities,
98
+ status: 'online',
99
+ last_seen: new Date().toISOString()
100
+ });
101
+ if (error)
102
+ throw error;
103
+ console.log(` - ✅ Device registered: ${newDevice.device_name}`);
104
+ console.log(` - ✅ Assigned new Device ID: ${newDevice.id}`);
105
+ return newDevice.id;
106
+ }
107
+ }
108
+ async subscribe(userId, onToolCall) {
109
+ if (!this.client)
110
+ throw new Error('Client not initialized');
111
+ console.debug(` - ⏳ Subscribing to call queue...`);
112
+ return new Promise((resolve, reject) => {
113
+ if (!this.client)
114
+ return reject(new Error('Client not initialized'));
115
+ this.channel = this.client.channel('device_tool_call_queue')
116
+ .on('postgres_changes', {
117
+ event: 'INSERT',
118
+ schema: 'public',
119
+ table: 'mcp_remote_calls',
120
+ filter: `user_id=eq.${userId}`
121
+ }, (payload) => onToolCall(payload))
122
+ .subscribe((status, err) => {
123
+ if (status === 'SUBSCRIBED') {
124
+ console.debug(' - 🔌 Connected to call queue');
125
+ resolve();
126
+ }
127
+ else if (status === 'CHANNEL_ERROR') {
128
+ console.error(' - ❌ Failed to connect to call queue:', err);
129
+ reject(err || new Error('Failed to initialize call queue subscription'));
130
+ }
131
+ else if (status === 'TIMED_OUT') {
132
+ console.error(' - ❌ Connection to call queue timed out');
133
+ reject(new Error('Call queue subscription timed out'));
134
+ }
135
+ });
136
+ });
137
+ }
138
+ async markCallExecuting(callId) {
139
+ if (!this.client)
140
+ throw new Error('Client not initialized');
141
+ await this.client
142
+ .from('mcp_remote_calls')
143
+ .update({ status: 'executing' })
144
+ .eq('id', callId);
145
+ }
146
+ async updateCallResult(callId, status, result = null, errorMessage = null) {
147
+ if (!this.client)
148
+ throw new Error('Client not initialized');
149
+ const updateData = {
150
+ status: status,
151
+ completed_at: new Date().toISOString()
152
+ };
153
+ if (result !== null)
154
+ updateData.result = result;
155
+ if (errorMessage !== null)
156
+ updateData.error_message = errorMessage;
157
+ await this.client
158
+ .from('mcp_remote_calls')
159
+ .update(updateData)
160
+ .eq('id', callId);
161
+ }
162
+ async updateHeartbeat(deviceId) {
163
+ if (!this.client)
164
+ return;
165
+ try {
166
+ await this.client
167
+ .from('mcp_devices')
168
+ .update({ last_seen: new Date().toISOString() })
169
+ .eq('id', deviceId);
170
+ }
171
+ catch (error) {
172
+ console.error('Heartbeat failed:', error.message);
173
+ }
174
+ }
175
+ startHeartbeat(deviceId) {
176
+ // Update last_seen every 30 seconds
177
+ this.heartbeatInterval = setInterval(async () => {
178
+ await this.updateHeartbeat(deviceId);
179
+ }, HEARTBEAT_INTERVAL);
180
+ }
181
+ async setOffline(deviceId) {
182
+ if (deviceId && this.client) {
183
+ await this.client
184
+ .from('mcp_devices')
185
+ .update({ status: 'offline' })
186
+ .eq('id', deviceId);
187
+ console.log('✓ Device marked as offline');
188
+ }
189
+ }
190
+ async unsubscribe() {
191
+ if (this.channel) {
192
+ if (this.heartbeatInterval)
193
+ clearInterval(this.heartbeatInterval);
194
+ await this.channel.unsubscribe();
195
+ console.log('✓ Unsubscribed from channel');
196
+ }
197
+ }
198
+ }
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "0.2.29-alpha.0";
1
+ export declare const VERSION = "0.2.29-alpha.1";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = '0.2.29-alpha.0';
1
+ export const VERSION = '0.2.29-alpha.1';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wonderwhy-er/desktop-commander",
3
- "version": "0.2.29-alpha.0",
3
+ "version": "0.2.29-alpha.1",
4
4
  "description": "MCP server for terminal operations and file editing",
5
5
  "mcpName": "io.github.wonderwhy-er/desktop-commander",
6
6
  "license": "MIT",
@@ -25,8 +25,8 @@
25
25
  "postinstall": "node dist/track-installation.js && node dist/npm-scripts/verify-ripgrep.js || node -e \"process.exit(0)\"",
26
26
  "open-chat": "open -n /Applications/Claude.app",
27
27
  "device:install": "cd src/remote-device && npm install",
28
- "device:start": "cd src/remote-device && npm run device",
29
- "device:dev": "cd src/remote-device && npm run device:dev",
28
+ "device:start": "tsx src/remote-device/device.ts",
29
+ "device:start:dev": "nodemon --watch src/remote-device --exec tsx src/remote-device/device.ts",
30
30
  "sync-version": "node scripts/sync-version.js",
31
31
  "bump": "node scripts/sync-version.js --bump",
32
32
  "bump:minor": "node scripts/sync-version.js --bump --minor",
@@ -83,14 +83,17 @@
83
83
  "dependencies": {
84
84
  "@modelcontextprotocol/sdk": "^1.9.0",
85
85
  "@opendocsg/pdf2md": "^0.2.2",
86
+ "@supabase/supabase-js": "^2.89.0",
86
87
  "@vscode/ripgrep": "^1.15.9",
87
88
  "cross-fetch": "^4.1.0",
88
89
  "exceljs": "^4.4.0",
90
+ "express": "^4.22.1",
89
91
  "fastest-levenshtein": "^1.0.16",
90
92
  "file-type": "^21.1.1",
91
93
  "glob": "^10.3.10",
92
94
  "isbinaryfile": "^5.0.4",
93
95
  "md-to-pdf": "^5.2.5",
96
+ "open": "^10.2.0",
94
97
  "pdf-lib": "^1.17.1",
95
98
  "remark": "^15.0.1",
96
99
  "remark-gfm": "^4.0.1",
@@ -103,11 +106,14 @@
103
106
  },
104
107
  "devDependencies": {
105
108
  "@anthropic-ai/mcpb": "^1.2.0",
109
+ "@types/express": "^5.0.6",
106
110
  "@types/node": "^20.17.24",
107
111
  "commander": "^13.1.0",
108
112
  "nexe": "^5.0.0-beta.4",
109
113
  "nodemon": "^3.0.2",
110
114
  "shx": "^0.3.4",
115
+ "ts-node": "^10.9.2",
116
+ "tsx": "^4.21.0",
111
117
  "typescript": "^5.3.3"
112
118
  }
113
119
  }