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()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()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()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{ 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'&&outBytesadd('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+nmax)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{ 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);}