yourhand-agent.mjs
Rama main · réplica de lectura del repositorio original.
import fs from 'node:fs';
import crypto from 'node:crypto';
import os from 'node:os';
import path from 'node:path';
import readline from 'node:readline';
import { spawn } from 'node:child_process';
import { AsyncLocalStorage } from 'node:async_hooks';
import { fileURLToPath } from 'node:url';
import WebSocket from 'ws';
import { FileActionJournal } from './yh-action-journal.mjs';
import { resolveYourHandNativeHelper } from './src/multiuser/native-helper-path.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const VERSION = '0.9.4-approval-wait';
const CONFIG_FILE = process.env.YOURHAND_CONFIG || path.join(__dirname, 'yourhand-config.json');
const BOOTSTRAP_FILE = process.env.YOURHAND_BOOTSTRAP || path.join(__dirname, 'yourhand-bootstrap.json');
function ensureDeviceKeys(){
const privateKeyFile=path.join(__dirname,'device-private.pem');
const publicKeyFile=path.join(__dirname,'device-public.pem');
if(!fs.existsSync(privateKeyFile)||!fs.existsSync(publicKeyFile)){
const {publicKey,privateKey}=crypto.generateKeyPairSync('ed25519');
fs.writeFileSync(privateKeyFile,privateKey.export({type:'pkcs8',format:'pem'}),{encoding:'utf8',mode:0o600});
fs.writeFileSync(publicKeyFile,publicKey.export({type:'spki',format:'pem'}),'utf8');
}
return {privateKeyFile,publicKeyFile};
}
async function loadOrEnrollConfig(){
if(fs.existsSync(CONFIG_FILE))return JSON.parse(fs.readFileSync(CONFIG_FILE,'utf8'));
if(!fs.existsSync(BOOTSTRAP_FILE))throw new Error('YourHand is not paired yet. Install or pair this device first.');
const boot=JSON.parse(fs.readFileSync(BOOTSTRAP_FILE,'utf8'));
const token=String(boot.pairingToken||'').trim();
const enrollUrl=String(boot.enrollUrl||'').trim();
if(!token||!/^https:\/\//i.test(enrollUrl))throw new Error('Invalid YourHand bootstrap configuration');
const {privateKeyFile,publicKeyFile}=ensureDeviceKeys();
const publicKey=fs.readFileSync(publicKeyFile,'utf8');
const r=await fetch(enrollUrl,{
method:'POST',
headers:{'content-type':'application/json'},
body:JSON.stringify({token,hostname:os.hostname(),displayName:os.hostname(),publicKey})
});
const body=await r.json().catch(()=>({}));
if(!r.ok||!body.deviceId)throw new Error(body.error||('Enrollment failed: HTTP '+r.status));
const cfg={
deviceId:body.deviceId,
displayName:body.displayName||os.hostname(),
serverUrl:body.serverUrl||boot.serverUrl||'',
privateKeyFile,
publicKeyFile,
nativeHelper:path.join(__dirname,'YourHandNative.exe'),
disableDesktopCommander:boot.disableDesktopCommander!==false,
browserPort:Number(boot.browserPort||9222)
};
const tmp=CONFIG_FILE+'.tmp';
fs.writeFileSync(tmp,JSON.stringify(cfg,null,2),'utf8');
fs.renameSync(tmp,CONFIG_FILE);
try{fs.unlinkSync(BOOTSTRAP_FILE);}catch{}
return cfg;
}
const config = await loadOrEnrollConfig();
const actionJournal=new FileActionJournal(path.join(path.dirname(CONFIG_FILE),'yh-action-journal'));
const DC_COMMAND = config.dcCommand || 'desktop-commander';
const DC_ARGS = Array.isArray(config.dcArgs) ? config.dcArgs : [];
const NATIVE_EXE = resolveYourHandNativeHelper(config.nativeHelper, __dirname);
let mcpClient=null, mcpTransport=null, mcpTools=[], mcpInit=null;
let nativeProc=null, nativeInit=null, nativeSeq=0;
const nativePending=new Map();
let ws=null, reconnectDelay=1000, heartbeat=null, reconnectTimer=null, shuttingDown=false;
const actionTrace=new AsyncLocalStorage();
function safeStep(kind,action,elapsedMs,error){
const ctx=actionTrace.getStore();
if(!ctx||ctx.steps.length>=64)return;
const safe=String(action||'').replace(/[^A-Za-z0-9_-]/g,'_').slice(0,46);
const methodId=(kind==='native'?'AGENT_NATIVE_':'AGENT_BROWSER_')+safe.toUpperCase();
// Raw exception, file path, target selector and arguments NEVER leave Agent.
const e=String(error?.message||error||'');
const errorCode=!error?null:/USER_APPROVAL_REQUIRED/i.test(e)?'USER_APPROVAL_REQUIRED':
/stale/i.test(e)?'STALE_REFERENCE':
/timeout/i.test(e)?'TIMEOUT':/locked|interactive|secure.desktop/i.test(e)?'NO_INTERACTIVE_SESSION':
/access.denied|uac|permission/i.test(e)?'ELEVATION_REQUIRED':'UNCLASSIFIED_ERROR';
ctx.steps.push({methodId,routeId:kind==='native'?'windows.native':'browser.cdp',
attemptIndex:ctx.steps.length+1,durationMs:Math.min(600000,Math.max(0,Math.round(elapsedMs))),
outcome:error?'failed':'completed',errorCode});
}
const NATIVE_ACTIONS=[
'ping','list_windows','foreground','focus_window','close_window','mouse','scroll',
'hotkey','type_text','clipboard_get','clipboard_set','screenshot','ui_tree','ui_find',
'ui_invoke','ui_set_value','semantic_snapshot','semantic_batch','observe','click_preview','wait_window','launch','sleep','batch'
];
function log(...args){ console.log(new Date().toISOString(),...args); }
async function initMcp(){
if(mcpClient)return;
if(mcpInit)return mcpInit;
mcpInit=(async()=>{
if(config.disableDesktopCommander===true)throw new Error('Desktop Commander disabled');
const [{Client},{StdioClientTransport,getDefaultEnvironment}]=await Promise.all([
import('@modelcontextprotocol/sdk/client/index.js'),
import('@modelcontextprotocol/sdk/client/stdio.js')
]);
const transport=new StdioClientTransport({
command:DC_COMMAND,args:DC_ARGS,cwd:__dirname,
env:{...getDefaultEnvironment(),DC_REMOTE_DEVICE:'true'}
});
const client=new Client({name:'yourhand-device',version:VERSION},{capabilities:{}});
client.onclose=()=>{log('Local Desktop Commander closed');mcpClient=null;mcpTransport=null;mcpTools=[];};
client.onerror=e=>log('Local MCP error:',e?.message||String(e));
await client.connect(transport);
const listed=await client.listTools();
mcpClient=client;mcpTransport=transport;mcpTools=listed.tools||[];
log(`Local Desktop Commander ready (${mcpTools.length} tools)`);
})().finally(()=>{mcpInit=null;});
return mcpInit;
}
async function ensureMcp(){if(!mcpClient)await initMcp();}
function rejectNativePending(reason){
for(const [id,p] of nativePending){clearTimeout(p.timer);p.reject(new Error(reason));}
nativePending.clear();
}
async function initNative(){
if(nativeProc&&!nativeProc.killed)return;
if(nativeInit)return nativeInit;
nativeInit=new Promise((resolve,reject)=>{
if(!fs.existsSync(NATIVE_EXE)){reject(new Error(`Native helper not found: ${NATIVE_EXE}`));return;}
const p=spawn(NATIVE_EXE,[],{cwd:__dirname,windowsHide:true,stdio:['pipe','pipe','pipe']});
nativeProc=p;
const rl=readline.createInterface({input:p.stdout});
rl.on('line',line=>{
let msg;try{msg=JSON.parse(line);}catch{return;}
const wait=nativePending.get(msg.id);if(!wait)return;
clearTimeout(wait.timer);nativePending.delete(msg.id);
msg.ok?wait.resolve(msg):wait.reject(new Error(msg.error||'Native helper error'));
});
p.stderr.on('data',d=>log('Native stderr:',String(d).trim()));
p.on('error',err=>{log('Native helper error:',err.message);rejectNativePending(err.message);nativeProc=null;});
p.on('exit',(code,signal)=>{log(`Native helper exited code=${code} signal=${signal}`);rejectNativePending('Native helper exited');nativeProc=null;});
setTimeout(()=>resolve(),40);
}).finally(()=>{nativeInit=null;});
return nativeInit;
}
async function nativeCallInner(action,args={},timeoutMs=15000){
await initNative();
if(!nativeProc||nativeProc.killed)throw new Error('Native helper unavailable');
const id=`n${Date.now().toString(36)}-${(++nativeSeq).toString(36)}`;
return new Promise((resolve,reject)=>{
const timer=setTimeout(()=>{nativePending.delete(id);reject(new Error(`Native timeout after ${timeoutMs}ms: ${action}`));},Math.min(Math.max(timeoutMs,500),180000));
nativePending.set(id,{resolve,reject,timer});
nativeProc.stdin.write(JSON.stringify({id,action,args})+'\n','utf8',err=>{
if(err){clearTimeout(timer);nativePending.delete(id);reject(err);}
});
});
}
async function nativeCall(action,args={},timeoutMs=15000){
const t=Date.now();
try{
const result=await nativeCallInner(action,args,timeoutMs);
safeStep('native',action,Date.now()-t,null);
return result;
}catch(error){
safeStep('native',action,Date.now()-t,error);
throw error;
}
}
const BROWSER_PORT = Number(config.browserPort || 9222);
let browserProc = null;
function browserExe(){
const candidates=[config.browserExe,'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe','C:/Program Files/Microsoft/Edge/Application/msedge.exe','C:/Program Files/Google/Chrome/Application/chrome.exe','C:/Program Files (x86)/Google/Chrome/Application/chrome.exe'].filter(Boolean);
for(const p of candidates){ if(fs.existsSync(p)) return p; }
throw new Error('No supported Chromium browser found');
}
async function browserJson(pathname, options={}){
const r=await fetch(`http://127.0.0.1:${BROWSER_PORT}${pathname}`, options);
if(!r.ok) throw new Error(`CDP HTTP ${r.status}: ${await r.text()}`);
return r.json();
}
async function browserStatus(){
try {
const v=await browserJson('/json/version');
const tabs=await browserJson('/json/list');
return {running:true,port:BROWSER_PORT,browser:v.Browser||'',protocolVersion:v['Protocol-Version']||'',tabs:tabs.filter(x=>x.type==='page').length};
} catch { return {running:false,port:BROWSER_PORT}; }
}
async function ensureBrowser(args={}){
const st=await browserStatus(); if(st.running) return st;
const exe=browserExe();
const profile=args.profileDir || config.browserProfileDir || path.join(process.env.LOCALAPPDATA || __dirname,'YourHandBrowserProfile');
fs.mkdirSync(profile,{recursive:true});
const startUrl=args.url || 'about:blank';
const argv=[`--remote-debugging-port=${BROWSER_PORT}`,'--remote-allow-origins=*',`--user-data-dir=${profile}`,'--no-first-run','--no-default-browser-check','--disable-features=msEdgeFirstRunExperience','--disable-background-mode',startUrl];
browserProc=spawn(exe,argv,{cwd:path.dirname(exe),windowsHide:false,stdio:'ignore'});
browserProc.on('exit',()=>{browserProc=null;});
const deadline=Date.now()+(args.timeoutMs||10000);
while(Date.now()<deadline){ await new Promise(r=>setTimeout(r,120)); const bs=await browserStatus(); if(bs.running) return {...bs,started:true,exe,profile}; }
throw new Error('Browser CDP did not start in time');
}
async function browserTargets(){
await ensureBrowser();
const tabs=await browserJson('/json/list');
return tabs.filter(x=>x.type==='page').map(x=>({id:x.id,title:x.title,url:x.url,webSocketDebuggerUrl:x.webSocketDebuggerUrl}));
}
async function browserNewTab(url='about:blank'){
await ensureBrowser();
const t=await browserJson('/json/new?'+encodeURIComponent(url),{method:'PUT'});
return {id:t.id,title:t.title,url:t.url};
}
async function cdpCall(method,params={},targetId=null,timeoutMs=15000){
const tabs=await browserJson('/json/list');
let tab=targetId ? tabs.find(x=>x.id===targetId) : tabs.find(x=>x.type==='page');
if(!tab){ const n=await browserNewTab('about:blank'); return cdpCall(method,params,n.id,timeoutMs); }
if(!tab.webSocketDebuggerUrl) throw new Error('Target has no debugger websocket');
return new Promise((resolve,reject)=>{
const sock=new WebSocket(tab.webSocketDebuggerUrl);
const id=1;
const timer=setTimeout(()=>{try{sock.close();}catch{} reject(new Error(`CDP timeout: ${method}`));},timeoutMs);
sock.on('open',()=>sock.send(JSON.stringify({id,method,params})));
sock.on('message',raw=>{ let msg; try{msg=JSON.parse(raw.toString());}catch{return;} if(msg.id!==id)return; clearTimeout(timer); try{sock.close();}catch{} if(msg.error)reject(new Error(`CDP ${msg.error.code}: ${msg.error.message}`)); else resolve(msg.result||{}); });
sock.on('error',err=>{clearTimeout(timer);reject(err);});
});
}
async function waitReady(targetId,timeoutMs=15000){
const deadline=Date.now()+timeoutMs;
while(Date.now()<deadline){
try { const r=await cdpCall('Runtime.evaluate',{expression:'document.readyState',returnByValue:true},targetId,3000); const v=r?.result?.value; if(v==='complete'||v==='interactive') return v; } catch {}
await new Promise(r=>setTimeout(r,80));
}
return 'timeout';
}
async function browserActionInner(action,args={}){
if(action==='status') return browserStatus();
if(action==='start') return ensureBrowser(args);
if(action==='tabs') return browserTargets();
if(action==='new_tab') return browserNewTab(args.url||'about:blank');
if(action==='navigate'){
await ensureBrowser();
let targetId=args.targetId;
if(args.newTab){const n=await browserNewTab('about:blank');targetId=n.id;}
if(!targetId){const ts=await browserTargets();targetId=ts[0]?.id;}
if(!targetId){const n=await browserNewTab('about:blank');targetId=n.id;}
await cdpCall('Page.enable',{},targetId);
await cdpCall('Page.navigate',{url:args.url},targetId,args.timeoutMs||15000);
const ready=await waitReady(targetId,args.timeoutMs||15000);
const info=await cdpCall('Runtime.evaluate',{expression:'({title:document.title,url:location.href,ready:document.readyState})',returnByValue:true},targetId);
return {targetId,ready,...(info?.result?.value||{})};
}
if(action==='eval'){
await ensureBrowser();
const r=await cdpCall('Runtime.evaluate',{expression:args.expression||'',awaitPromise:args.awaitPromise!==false,returnByValue:args.returnByValue!==false},args.targetId||null,args.timeoutMs||15000);
return {targetId:args.targetId||null,result:r.result||null,exceptionDetails:r.exceptionDetails||null};
}
if(action==='text'){ const r=await browserAction('eval',{targetId:args.targetId,expression:'document.body ? document.body.innerText : ""'}); return {targetId:args.targetId||null,text:r?.result?.value||''}; }
if(action==='click'){ const sel=JSON.stringify(args.selector||''); return browserAction('eval',{targetId:args.targetId,expression:`(()=>{const e=document.querySelector(${sel});if(!e)return {ok:false};e.scrollIntoView({block:'center',inline:'center'});e.click();return {ok:true,tag:e.tagName,text:(e.innerText||e.value||'').slice(0,200)}})()`}); }
if(action==='type'){ const sel=JSON.stringify(args.selector||''); const val=JSON.stringify(args.text??''); return browserAction('eval',{targetId:args.targetId,expression:`(()=>{const e=document.querySelector(${sel});if(!e)return {ok:false};e.focus();const p=Object.getPrototypeOf(e);const d=Object.getOwnPropertyDescriptor(p,'value');if(d&&d.set)d.set.call(e,${val});else e.value=${val};e.dispatchEvent(new Event('input',{bubbles:true}));e.dispatchEvent(new Event('change',{bubbles:true}));return {ok:true,value:e.value}})()`}); }
if(action==='wait_selector'){
const deadline=Date.now()+(args.timeoutMs||10000);
while(Date.now()<deadline){ const r=await browserAction('eval',{targetId:args.targetId,expression:`!!document.querySelector(${JSON.stringify(args.selector||'')})`}); if(r?.result?.value===true)return {found:true,targetId:args.targetId||null}; await new Promise(r=>setTimeout(r,args.pollMs||80)); }
return {found:false,targetId:args.targetId||null};
}
if(action==='close_tab'){ await ensureBrowser(); const ok=await fetch(`http://127.0.0.1:${BROWSER_PORT}/json/close/${encodeURIComponent(args.targetId||'')}`); return {closed:ok.ok,targetId:args.targetId||''}; }
throw new Error(`Unknown browser action: ${action}`);
}
async function browserScreenshot(args={}){
await ensureBrowser();
const r=await cdpCall('Page.captureScreenshot',{format:args.format||'png',fromSurface:true,captureBeyondViewport:args.captureBeyondViewport!==false},args.targetId||null,args.timeoutMs||30000);
const mime=(args.format||'png')==='jpeg'?'image/jpeg':'image/png';
return {content:[{type:'image',data:r.data,mimeType:mime},{type:'text',text:JSON.stringify({targetId:args.targetId||null,base64Chars:r.data?.length||0})}]};
}
async function browserBatch(args={}){
const steps=Array.isArray(args.steps)?args.steps:[]; if(!steps.length)throw new Error('steps array required');
const results=[];
for(let i=0;i<steps.length;i++){const st=steps[i]||{};const t0=Date.now();try{const result=await browserAction(st.action,st);results.push({index:i,action:st.action,ok:true,elapsedMs:Date.now()-t0,result});}catch(e){results.push({index:i,action:st.action,ok:false,elapsedMs:Date.now()-t0,error:e?.message||String(e)});if(!args.continueOnError)break;}}
return {count:results.length,results};
}
async function execLocal(args={}){
const shell=(args.shell||'powershell').toLowerCase();
const command=String(args.command||'');
if(!command) throw new Error('command required');
let exe,argv;
if(shell==='cmd'){
exe=process.env.ComSpec || 'C:/Windows/System32/cmd.exe';
argv=['/d','/s','/c',command];
}else{
exe=args.executable || 'C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe';
argv=['-NoLogo','-NoProfile','-Sta','-Command',command];
}
const cwd=args.cwd || process.env.USERPROFILE || __dirname;
const timeoutMs=Math.min(Math.max(Number(args.timeoutMs||60000),500),300000);
const maxBytes=Math.min(Math.max(Number(args.maxBytes||5*1024*1024),1024),20*1024*1024);
const t0=Date.now();
return new Promise((resolve,reject)=>{
const child=spawn(exe,argv,{cwd,windowsHide:true,env:{...process.env,...(args.env||{})}});
let stdout='',stderr='',outBytes=0,errBytes=0,timedOut=false;
const add=(which,buf)=>{
const s=buf.toString('utf8');
if(which==='out'&&outBytes<maxBytes){const remain=maxBytes-outBytes;const part=Buffer.from(s).subarray(0,remain).toString('utf8');stdout+=part;outBytes+=Buffer.byteLength(part);}
if(which==='err'&&errBytes<maxBytes){const remain=maxBytes-errBytes;const part=Buffer.from(s).subarray(0,remain).toString('utf8');stderr+=part;errBytes+=Buffer.byteLength(part);}
};
child.stdout.on('data',b=>add('out',b));
child.stderr.on('data',b=>add('err',b));
child.on('error',reject);
const timer=setTimeout(()=>{timedOut=true;try{child.kill();}catch{}},timeoutMs);
child.on('close',(code,signal)=>{clearTimeout(timer);resolve({code,signal,timedOut,elapsedMs:Date.now()-t0,stdout,stderr,stdoutTruncated:outBytes>=maxBytes,stderrTruncated:errBytes>=maxBytes});});
});
}
const procSessions=new Map();
const PROC_MAX_BUFFER=5*1024*1024;
function fileMeta(p,st){
return {path:p,name:path.basename(p),type:st.isDirectory()?'directory':st.isFile()?'file':'other',size:st.size,mtime:st.mtime.toISOString(),ctime:st.ctime.toISOString()};
}
function fsSearch(root,args){
const query=String(args.query||'').toLowerCase();
const maxResults=Math.min(Math.max(Number(args.maxResults||200),1),5000);
const maxDepth=Math.min(Math.max(Number(args.maxDepth||8),0),64);
const includeDirs=args.includeDirs===true;
const results=[];
const walk=(dir,depth)=>{
if(results.length>=maxResults||depth>maxDepth)return;
let ents=[]; try{ents=fs.readdirSync(dir,{withFileTypes:true});}catch{return;}
for(const e of ents){
if(results.length>=maxResults)break;
const full=path.join(dir,e.name);
const hit=!query||e.name.toLowerCase().includes(query)||full.toLowerCase().includes(query);
if(hit&&(e.isFile()||(includeDirs&&e.isDirectory()))){try{results.push(fileMeta(full,fs.statSync(full)));}catch{}}
if(e.isDirectory())walk(full,depth+1);
}
};
walk(root,0);
return {root,query,count:results.length,truncated:results.length>=maxResults,results};
}
function fsCall(action,args={}){
const p=args.path?path.resolve(String(args.path)):null;
if(action==='stat'){ if(!p)throw new Error('path required'); return fileMeta(p,fs.statSync(p)); }
if(action==='list'){
if(!p)throw new Error('path required');
const ents=fs.readdirSync(p,{withFileTypes:true});
const items=[];
for(const e of ents){const full=path.join(p,e.name);try{items.push(fileMeta(full,fs.statSync(full)));}catch{items.push({path:full,name:e.name,type:e.isDirectory()?'directory':'unknown'});}}
return {path:p,count:items.length,items};
}
if(action==='read_text'){
if(!p)throw new Error('path required'); const enc=args.encoding||'utf8'; const max=Math.min(Math.max(Number(args.maxBytes||5*1024*1024),1),20*1024*1024);
const st=fs.statSync(p); const start=Math.max(Number(args.offset||0),0); const len=Math.min(Math.max(Number(args.length||Math.min(max,st.size-start)),0),max);
const fd=fs.openSync(p,'r'); const buf=Buffer.alloc(len); const n=fs.readSync(fd,buf,0,len,start); fs.closeSync(fd);
return {path:p,offset:start,bytes:n,totalBytes:st.size,truncated:start+n<st.size,text:buf.subarray(0,n).toString(enc)};
}
if(action==='read_binary'){
if(!p)throw new Error('path required'); const max=Math.min(Math.max(Number(args.maxBytes||10*1024*1024),1),20*1024*1024); const st=fs.statSync(p); if(st.size>max)throw new Error(`file too large (${st.size} bytes > ${max})`);
const b=fs.readFileSync(p); return {path:p,bytes:b.length,base64:b.toString('base64')};
}
if(action==='write_text'){
if(!p)throw new Error('path required'); if(args.createParents!==false)fs.mkdirSync(path.dirname(p),{recursive:true}); const data=String(args.text??'');
if(args.append===true)fs.appendFileSync(p,data,{encoding:args.encoding||'utf8'}); else fs.writeFileSync(p,data,{encoding:args.encoding||'utf8'});
return fileMeta(p,fs.statSync(p));
}
if(action==='write_binary'){
if(!p)throw new Error('path required'); if(args.createParents!==false)fs.mkdirSync(path.dirname(p),{recursive:true}); const b=Buffer.from(String(args.base64||''),'base64');
if(args.append===true)fs.appendFileSync(p,b); else fs.writeFileSync(p,b); return fileMeta(p,fs.statSync(p));
}
if(action==='mkdir'){ if(!p)throw new Error('path required'); fs.mkdirSync(p,{recursive:args.recursive!==false}); return {path:p,created:true}; }
if(action==='move'){ if(!p||!args.destination)throw new Error('path and destination required'); const d=path.resolve(String(args.destination)); if(args.createParents!==false)fs.mkdirSync(path.dirname(d),{recursive:true}); fs.renameSync(p,d); return {from:p,to:d}; }
if(action==='copy'){ if(!p||!args.destination)throw new Error('path and destination required'); const d=path.resolve(String(args.destination)); if(args.createParents!==false)fs.mkdirSync(path.dirname(d),{recursive:true}); fs.cpSync(p,d,{recursive:args.recursive===true,force:args.force!==false}); return {from:p,to:d}; }
if(action==='remove'){ if(!p)throw new Error('path required'); fs.rmSync(p,{recursive:args.recursive===true,force:args.force===true}); return {path:p,removed:true}; }
if(action==='search'){ if(!p)throw new Error('path required'); return fsSearch(p,args); }
throw new Error(`Unknown fs action: ${action}`);
}
function procAppend(sess,kind,buf){
const s=buf.toString('utf8'); sess.buffer+=s; sess[kind]+=s; sess.totalChars+=s.length;
if(sess.buffer.length>PROC_MAX_BUFFER){const cut=sess.buffer.length-PROC_MAX_BUFFER;sess.buffer=sess.buffer.slice(cut);sess.baseOffset+=cut;}
if(sess.stdout.length>PROC_MAX_BUFFER)sess.stdout=sess.stdout.slice(-PROC_MAX_BUFFER);
if(sess.stderr.length>PROC_MAX_BUFFER)sess.stderr=sess.stderr.slice(-PROC_MAX_BUFFER);
}
function procStart(args={}){
const shell=(args.shell||'powershell').toLowerCase(); const interactive=args.interactive===true; const command=String(args.command||'');
let exe,argv;
if(args.executable){exe=String(args.executable);argv=Array.isArray(args.arguments)?args.arguments.map(String):[];}
else if(shell==='cmd'){exe=process.env.ComSpec||'C:/Windows/System32/cmd.exe';argv=interactive?['/d']:['/d','/s','/c',command];}
else {exe='C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe';argv=interactive?['-NoLogo','-NoProfile','-Sta']:['-NoLogo','-NoProfile','-Sta','-Command',command];}
const id=crypto.randomUUID(); const cwd=args.cwd||process.env.USERPROFILE||__dirname;
const child=spawn(exe,argv,{cwd,windowsHide:args.showWindow!==true,env:{...process.env,...(args.env||{})},stdio:['pipe','pipe','pipe']});
const sess={id,pid:child.pid,child,exe,argv,cwd,createdAt:new Date().toISOString(),buffer:'',stdout:'',stderr:'',baseOffset:0,totalChars:0,exited:false,code:null,signal:null};
procSessions.set(id,sess);
child.stdout.on('data',b=>procAppend(sess,'stdout',b)); child.stderr.on('data',b=>procAppend(sess,'stderr',b));
child.on('exit',(code,signal)=>{sess.exited=true;sess.code=code;sess.signal=signal;sess.exitedAt=new Date().toISOString();});
child.on('error',e=>{procAppend(sess,'stderr',Buffer.from(String(e?.stack||e)));});
return {sessionId:id,pid:child.pid,exe,arguments:argv,cwd,interactive};
}
function procCall(action,args={}){
if(action==='start')return procStart(args);
if(action==='list'){return {count:procSessions.size,sessions:[...procSessions.values()].map(s=>({sessionId:s.id,pid:s.pid,exe:s.exe,cwd:s.cwd,createdAt:s.createdAt,exited:s.exited,code:s.code,signal:s.signal,baseOffset:s.baseOffset,nextOffset:s.baseOffset+s.buffer.length}))};}
const id=String(args.sessionId||''); const s=procSessions.get(id); if(!s)throw new Error(`Unknown process session: ${id}`);
if(action==='read'){const req=Number(args.offset??s.baseOffset);const start=Math.max(req,s.baseOffset);const local=Math.max(0,start-s.baseOffset);const text=s.buffer.slice(local);return {sessionId:id,pid:s.pid,text,requestedOffset:req,actualOffset:start,nextOffset:s.baseOffset+s.buffer.length,truncated:req<s.baseOffset,exited:s.exited,code:s.code,signal:s.signal};}
if(action==='write'){if(s.exited)throw new Error('process already exited');const input=String(args.input??'');s.child.stdin.write(input);return {sessionId:id,pid:s.pid,chars:input.length};}
if(action==='kill'){if(!s.exited){try{s.child.kill(args.signal||undefined);}catch{}}return {sessionId:id,pid:s.pid,killed:true};}
if(action==='close_stdin'){try{s.child.stdin.end();}catch{}return {sessionId:id,pid:s.pid,closed:true};}
if(action==='forget'){if(!s.exited&&!args.force)throw new Error('process still running; set force=true or kill it first');if(!s.exited&&args.force){try{s.child.kill();}catch{}}procSessions.delete(id);return {sessionId:id,forgotten:true};}
throw new Error(`Unknown process action: ${action}`);
}
function systemInfo(){
return {
hostname:os.hostname(),platform:os.platform(),release:os.release(),arch:os.arch(),
cpus:os.cpus().length,totalMemory:os.totalmem(),freeMemory:os.freemem(),
uptimeSeconds:os.uptime(),node:process.version,deviceName:config.displayName||config.deviceName||os.hostname(),
agentVersion:VERSION,nativeHelper:fs.existsSync(NATIVE_EXE),nativeActions:NATIVE_ACTIONS.length,browserCdp:true,browserPort:BROWSER_PORT,desktopCommanderAvailable:!!mcpClient,desktopCommanderDisabled:config.disableDesktopCommander===true,directFilesystem:true,directProcessSessions:true
};
}
async function browserAction(action,args={}){
const t=Date.now();
try{
const result=await browserActionInner(action,args);
safeStep('browser',action,Date.now()-t,null);
return result;
}catch(error){
safeStep('browser',action,Date.now()-t,error);
throw error;
}
}
async function nativeScreenshot(params={}){
const shotParams={memory:true,...params};
const msg=await nativeCall('screenshot',shotParams,30000);
const info=msg.result||{};
let data=info.dataBase64||'';
if(!data){
const p=info.path;
if(!p||!fs.existsSync(p))throw new Error('Screenshot payload missing');
data=fs.readFileSync(p).toString('base64');
if(params.keep!==true){try{fs.unlinkSync(p);}catch{}}
}
const mimeType=info.format==='jpeg'?'image/jpeg':'image/png';
return {
content:[
{type:'image',data,mimeType},
{type:'text',text:JSON.stringify({width:info.width,height:info.height,sourceWidth:info.sourceWidth||info.width,sourceHeight:info.sourceHeight||info.height,format:info.format||'png',quality:info.quality||null,bytes:info.bytes,nativeElapsedMs:msg.elapsedMs,memory:!!info.dataBase64})}
]
};
}
async function nativeObserve(params={}){
const t0=Date.now();
const msg=await nativeCall('observe',{format:'jpeg',quality:45,maxWidth:960,maxHeight:700,...params},30000);
const result=msg.result||{};
const shot=result.screenshot||{};
let data=shot.dataBase64||'';
if(!data)throw new Error('Observe screenshot payload missing');
const mimeType=shot.format==='jpeg'?'image/jpeg':'image/png';
const meta={
observationId:result.observationId||null,
frameId:result.frameId||null,
validForMs:result.validForMs||0,
window:result.window||null,
snapshot:result.snapshot||null,
screenshot:{width:shot.width,height:shot.height,sourceWidth:shot.sourceWidth||shot.width,sourceHeight:shot.sourceHeight||shot.height,format:shot.format||'jpeg',quality:shot.quality||45,bytes:shot.bytes},
nativeElapsedMs:msg.elapsedMs,
totalAgentElapsedMs:Date.now()-t0
};
return {content:[
{type:'image',data,mimeType},
{type:'text',text:JSON.stringify(meta)}
]};
}
async function nativeBatchFrame(params={}){
const steps=Array.isArray(params.steps)?params.steps:[];
if(!steps.length)throw new Error('steps array required');
const timeoutMs=Math.min(Math.max(Number(params.timeoutMs||60000),500),180000);
const t0=Date.now();
const batch=await nativeCall('batch',{steps,continueOnError:!!params.continueOnError},timeoutMs);
const shotArgs={memory:true,window:true,captureMethod:'screen',format:'jpeg',quality:45,maxWidth:960,maxHeight:700,...(params.screenshot||{})};
const shot=await nativeCall('screenshot',shotArgs,30000);
const info=shot.result||{};
let data=info.dataBase64||'';
if(!data){
const p=info.path;
if(!p||!fs.existsSync(p))throw new Error('Screenshot payload missing');
data=fs.readFileSync(p).toString('base64');
if(shotArgs.keep!==true){try{fs.unlinkSync(p);}catch{}}
}
const mimeType=info.format==='jpeg'?'image/jpeg':'image/png';
return {content:[
{type:'image',data,mimeType},
{type:'text',text:JSON.stringify({batch:batch.result||null,width:info.width,height:info.height,sourceWidth:info.sourceWidth||info.width,sourceHeight:info.sourceHeight||info.height,format:info.format||shotArgs.format,quality:info.quality||shotArgs.quality,bytes:info.bytes,batchNativeElapsedMs:batch.elapsedMs,screenshotNativeElapsedMs:shot.elapsedMs,totalAgentElapsedMs:Date.now()-t0})}
]};
}
async function dispatch(method,params={}){
if(method==='ping')return {pong:true,at:new Date().toISOString()};
if(method==='system_info')return systemInfo();
if(method==='mcp_call'){
await ensureMcp();
return mcpClient.callTool({name:params.tool,arguments:params.args||{},_meta:{remote:true,yourhand:true}});
}
if(method==='native_call'){
const perform=async()=>{
const msg=await nativeCall(params.action,params.args||{},params.timeoutMs||15000);
return {action:params.action,nativeElapsedMs:msg.elapsedMs,result:msg.result};
};
if(['click_preview','semantic_batch'].includes(params.action) && params._yhActionId)
return actionJournal.execute(params._yhActionId,{method,action:params.action,args:params.args||{}},perform);
return perform();
}
if(method==='native_screenshot')return nativeScreenshot(params);
if(method==='native_observe')return nativeObserve(params);
if(method==='native_batch_frame')return nativeBatchFrame(params);
if(method==='browser_call'){
const args=params.args||{};
if(params.action==='eval' && args._yhActionId)
return actionJournal.execute(args._yhActionId,
{method,action:'guarded_eval',targetId:args.targetId,expression:args.expression},
()=>browserAction(params.action,args));
return browserAction(params.action,args);
}
if(method==='guarded_action_status'){
return actionJournal.status(params.actionId);
}
if(method==='browser_batch')return browserBatch(params||{});
if(method==='browser_screenshot')return browserScreenshot(params||{});
if(method==='exec'){
if(params?._yhActionId)
return actionJournal.execute(params._yhActionId,
{method:'exec',shell:params.shell||'powershell',command:params.command||'',
cwd:params.cwd||null,timeoutMs:params.timeoutMs||60000},
()=>execLocal(params));
return execLocal(params||{});
}
if(method==='fs_call')return fsCall(params.action,params.args||{});
if(method==='proc_call')return procCall(params.action,params.args||{});
throw new Error(`Unknown method: ${method}`);
}
function toolSummary(){
return mcpTools.map(t=>({name:t.name,description:t.description||'',inputSchema:t.inputSchema||null,annotations:t.annotations||null}));
}
function scheduleReconnect(){
if(shuttingDown || reconnectTimer)return;
const delay=reconnectDelay;
reconnectDelay=Math.min(reconnectDelay*2,30000);
reconnectTimer=setTimeout(async()=>{
reconnectTimer=null;
try{await connectRemote();}
catch(err){log('Reconnect failed:',err?.message||String(err));scheduleReconnect();}
},delay);
}
async function connectRemote(){
if(shuttingDown || (ws && (ws.readyState===WebSocket.CONNECTING || ws.readyState===WebSocket.OPEN)))return;
await initNative();
if(!config.deviceId) throw new Error('YourHand deviceId is missing. Pair this device again.');
const url=new URL(config.serverUrl);
url.searchParams.set('device_id',config.deviceId);
const wsOptions={rejectUnauthorized:true};
if(config.caFile && fs.existsSync(config.caFile)) wsOptions.ca=fs.readFileSync(config.caFile);
log(`YourHand connecting ${config.displayName||os.hostname()} -> ${url.origin}${url.pathname}`);
ws=new WebSocket(url,wsOptions);
const socket=ws;
let ready=false;
const connectionDeadline=setTimeout(()=>{
if(socket.readyState===WebSocket.CONNECTING || (socket.readyState===WebSocket.OPEN && !ready)){
log('YourHand connection/auth timed out; reconnecting');socket.terminate();
}
},30000);
const markReady=()=>{
if(ready || socket!==ws || socket.readyState!==WebSocket.OPEN)return;
ready=true;clearTimeout(connectionDeadline);reconnectDelay=1000;
socket.send(JSON.stringify({type:'hello',meta:{...systemInfo(),dcVersion:config.dcVersion||'0.2.51'},tools:toolSummary()}));
heartbeat=setInterval(()=>{if(socket.readyState===WebSocket.OPEN)socket.send(JSON.stringify({type:'heartbeat'}));},20000);
heartbeat.unref?.();
log('YourHand authenticated and ready');
};
socket.on('open',()=>log('YourHand socket open; waiting for Ed25519 challenge'));
socket.on('message',async raw=>{
if(socket!==ws || shuttingDown)return;
let msg;try{msg=JSON.parse(raw.toString());}catch{return;}
if(msg.type==='challenge'){
try{
const key=fs.readFileSync(config.privateKeyFile,'utf8');
const signature=crypto.sign(null,Buffer.from(msg.nonce,'utf8'),key).toString('base64');
socket.send(JSON.stringify({type:'auth',signature}));
}catch(error){log('Signature auth failed locally:',error?.message||String(error));socket.close(1008,'Local authentication failure');}
return;
}
if(msg.type==='auth_ok'){markReady();return;}
if(msg.type!=='request')return;
const actionStart=Date.now();
const trace={steps:[]};
try{
const result=await actionTrace.run(trace,()=>dispatch(msg.method,msg.params||{}));
if(socket.readyState===WebSocket.OPEN)socket.send(JSON.stringify({type:'response',id:msg.id,ok:true,result,agentElapsedMs:Date.now()-actionStart,agentMethodCalls:trace.steps}));
else log('Request finished after its socket disconnected; result not replayed',msg.id);
}catch(error){
if(socket.readyState===WebSocket.OPEN)socket.send(JSON.stringify({type:'response',id:msg.id,ok:false,error:error?.stack||error?.message||String(error),agentElapsedMs:Date.now()-actionStart,agentMethodCalls:trace.steps}));
else log('Request failed after its socket disconnected; no automatic replay',msg.id);
}
});
socket.on('error',err=>log('YourHand websocket error:',err.message));
socket.on('close',(code,reason)=>{
clearTimeout(connectionDeadline);
if(socket!==ws)return; // obsolete sockets cannot schedule duplicate reconnects
ready=false;if(heartbeat)clearInterval(heartbeat);heartbeat=null;
ws=null;
log(`YourHand connection closed ${code} ${String(reason)}`);
scheduleReconnect();
});
}
async function shutdown(){
shuttingDown=true;
if(reconnectTimer)clearTimeout(reconnectTimer);reconnectTimer=null;
log('Shutting down');if(heartbeat)clearInterval(heartbeat);
try{ws?.close(1000,'Agent shutdown');}catch{}
try{await mcpClient?.close();}catch{}
try{await mcpTransport?.close();}catch{}
try{nativeProc?.kill();}catch{}
setTimeout(()=>process.exit(0),250).unref();
}
process.on('SIGINT',shutdown);process.on('SIGTERM',shutdown);
process.on('uncaughtException',err=>log('Uncaught:',err));
process.on('unhandledRejection',err=>log('Unhandled rejection:',err));
try{if(config.disableDesktopCommander===true){log('Desktop Commander disabled by config');mcpClient=null;mcpTools=[];}else{try{await initMcp();}catch(e){log('Desktop Commander optional init failed:',e?.message||String(e));mcpClient=null;mcpTools=[];}}await initNative();try{const dc=await nativeCall('desktop_context',{},5000);log('Desktop context',JSON.stringify(dc.result));}catch(e){log('Desktop context error',e.message);}await connectRemote();}
catch(error){log('Startup failed:',error?.stack||error);process.exit(1);}