webhelm 1.0.4 → 1.0.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webhelm",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "description": "Agent browser automation in a visible managed Chrome with persistent JavaScript sessions",
5
5
  "type": "module",
6
6
  "bin": {
@@ -23,10 +23,19 @@ function alive(pid) {
23
23
  try { if(!pid) return false;process.kill(Number(pid), 0);return true; }
24
24
  catch {return false;}
25
25
  }
26
+ function matchesManagedChromeProcess(pid,port) {
27
+ if(process.platform==='win32')return alive(pid); // Windows command-line discovery needs a separate provider.
28
+ try {
29
+ const command=execFileSync('ps',['-p',String(pid),'-o','command='],{encoding:'utf8',timeout:2500});
30
+ return command.includes(`--user-data-dir=${PROFILE}`) &&
31
+ command.includes(`--remote-debugging-port=${port}`) &&
32
+ !command.includes('--type=');
33
+ } catch{return false;}
34
+ }
26
35
  async function fromState() {
27
36
  let state;
28
37
  try { state=JSON.parse(fs.readFileSync(STATE, 'utf8')); } catch { return null; }
29
- if (state?.pid && Number.isInteger(state.port) && alive(state.pid) && await endpointLive(state.port)) return state;
38
+ if (state?.pid && Number.isInteger(state.port) && matchesManagedChromeProcess(state.pid,state.port) && await endpointLive(state.port)) return state;
30
39
  return null;
31
40
  }
32
41
  async function discover() {
@@ -39,7 +48,7 @@ async function discover() {
39
48
  const match=line.match(/^\s*(\d+)\s+.*?--remote-debugging-port=(\d+)/);
40
49
  if (!match) continue;
41
50
  const [pid,port]=match.slice(1).map(Number);
42
- if (!alive(pid) || !await endpointLive(port)) continue;
51
+ if (!matchesManagedChromeProcess(pid,port) || !await endpointLive(port)) continue;
43
52
  const state={pid,port,profile_dir:PROFILE,chrome_path:line.slice(line.search(/\S/)).replace(/^\d+\s*/, '').split(' --user-data-dir=')[0]};
44
53
  fs.writeFileSync(STATE,JSON.stringify(state,null,2));
45
54
  return state;
package/runtime/cli.mjs CHANGED
@@ -8,6 +8,7 @@ import { ensureDaemon, request, handle, SOCKET } from './node-daemon.mjs';
8
8
  import { PLATFORMS, listPlatforms, platformProfile, actionPath, validateConfig } from './platforms.mjs';
9
9
  import { ACTIONS_DIR, ensureActions, syncActions, publishActions } from './actions-repo.mjs';
10
10
  import { migrateConfig, migrateChromeProfile } from './migrate.mjs';
11
+ import { installLoginGuard, stageRuntimeForLoginGuard } from './login-guard.mjs';
11
12
 
12
13
  const ROOT = path.dirname(fileURLToPath(import.meta.url));
13
14
  const emit = value => console.log(JSON.stringify(value));
@@ -24,6 +25,7 @@ Usage:
24
25
  webhelm migrate # after stopping Chrome and the old WebHelm daemon
25
26
  webhelm setup
26
27
  webhelm status
28
+ webhelm ensure # verify/recover managed Chrome and Harness, not just daemon ping
27
29
  webhelm capacity [--workspace NAME]
28
30
  webhelm session start [--url URL]
29
31
  webhelm session list
@@ -108,14 +110,17 @@ async function main(args = process.argv.slice(2)) {
108
110
  if (group === 'setup') {
109
111
  const repo=await ensureActions();
110
112
  const chrome=await ensureDaemon();
111
- return emit({status:'ready',...repo,daemon_pid:chrome.pid,managed_chrome:chrome.managed_chrome});
113
+ const persisted=stageRuntimeForLoginGuard();
114
+ const login_guard=installLoginGuard({cliPath:path.join(persisted.path,'bin/webhelm.mjs'),nodePath:process.execPath});
115
+ return emit({status:'ready',...repo,daemon_pid:chrome.pid,managed_chrome:chrome.managed_chrome,login_guard});
112
116
  }
117
+ if (group === 'ensure') return emit(await request({op:'guard-check'}));
113
118
  if (group === 'status') {
114
119
  const daemon = await ensureDaemon();
115
120
  return emit({version:daemon.version, platform_dir:PLATFORMS, root:ROOT,
116
121
  cli:fileURLToPath(import.meta.url),
117
122
  admin_helper:process.env.WEBHELM_ADMIN_HELPER || (fs.existsSync(path.join(os.homedir(),'.agents/skills/webhelm/scripts/admin.mjs')) ? path.join(os.homedir(),'.agents/skills/webhelm/scripts/admin.mjs') : path.join(ROOT,'admin.mjs')),
118
- managed_chrome:daemon.managed_chrome,
123
+ managed_chrome:daemon.managed_chrome, chrome_guard:daemon.chrome_guard,
119
124
  daemon:{running:true, pid:daemon.pid, session_count:daemon.session_count, queue_paused:daemon.queue_paused, socket:SOCKET}});
120
125
  }
121
126
  if (group === 'capacity') {
@@ -2,9 +2,9 @@ import os from 'node:os';
2
2
  import path from 'node:path';
3
3
  import fs from 'node:fs';
4
4
 
5
- export const CONFIG_ROOT = path.join(os.homedir(), '.config', 'webhelm');
6
- export const LEGACY_ROOT = path.join(os.homedir(), '.config', 'browser-workspace');
7
- export const LOCAL_ROOT = path.join(os.homedir(), '.local', 'webhelm');
5
+ export const CONFIG_ROOT = path.resolve(process.env.WEBHELM_CONFIG_ROOT || path.join(os.homedir(), '.config', 'webhelm'));
6
+ export const LEGACY_ROOT = path.resolve(process.env.WEBHELM_LEGACY_ROOT || (process.env.WEBHELM_CONFIG_ROOT ? path.join(CONFIG_ROOT,'legacy-browser-workspace') : path.join(os.homedir(), '.config', 'browser-workspace')));
7
+ export const LOCAL_ROOT = path.resolve(process.env.WEBHELM_LOCAL_ROOT || path.join(os.homedir(), '.local', 'webhelm'));
8
8
  export const OLD_PROFILE_DIR = path.join(CONFIG_ROOT, 'chrome');
9
9
  export const PROFILE_DIR = path.join(LOCAL_ROOT, 'chrome');
10
10
  export const RUNTIME_DIR = path.join(CONFIG_ROOT, 'runtime');
@@ -3,9 +3,10 @@ import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import crypto from 'node:crypto';
5
5
  import {fileURLToPath} from 'node:url';
6
+ import {LOCAL_ROOT} from './config-paths.mjs';
6
7
 
7
8
  export const EXTENSION_ID='kkkpgmhpoeceojalkpfdidffdfnlgdog';
8
- export const EXTENSION_HOME=path.resolve(process.env.WEBHELM_EXTENSION_HOME || path.join(os.homedir(),'.local/webhelm/extension'));
9
+ export const EXTENSION_HOME=path.resolve(process.env.WEBHELM_EXTENSION_HOME || path.join(LOCAL_ROOT,'extension'));
9
10
  const SOURCE=fileURLToPath(new URL('./extension/',import.meta.url));
10
11
  const FILES=['manifest.json','service-worker.mjs','workspace-manager.mjs','workspace.html','workspace-page.mjs'];
11
12
 
@@ -0,0 +1,81 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import {execFileSync} from 'node:child_process';
5
+ import {fileURLToPath} from 'node:url';
6
+
7
+ const LABEL='com.lalalic.webhelm.keepalive';
8
+ // launchd must never depend on npm's removable npx cache, or on the Skill
9
+ // launcher being present. Stage an immutable, package-backed runtime locally.
10
+ export function stageRuntimeForLoginGuard({home=os.homedir(),source=fileURLToPath(new URL('../',import.meta.url)),yamlSource=null}={}){
11
+ const manifest=JSON.parse(fs.readFileSync(path.join(source,'package.json'),'utf8'));
12
+ if(manifest.name!=='webhelm'||!/^\d+\.\d+\.\d+$/.test(manifest.version))throw Error('Invalid WebHelm runtime package');
13
+ const parent=path.join(home,'.local/share/webhelm');
14
+ const target=path.join(parent,`runtime-${manifest.version}`);
15
+ const keyfiles=['package.json','bin/webhelm.mjs','runtime/cli.mjs','runtime/node-daemon.mjs','runtime/login-guard.mjs'];
16
+ const equal=()=>keyfiles.every(name=>fs.existsSync(path.join(target,name))&&fs.readFileSync(path.join(target,name)).equals(fs.readFileSync(path.join(source,name))))&&fs.existsSync(path.join(target,'node_modules/yaml/package.json'));
17
+ if(equal())return {path:target,version:manifest.version,updated:false};
18
+ const yamlDir=yamlSource||path.resolve(fileURLToPath(import.meta.resolve('yaml')),'../..');
19
+ if(!fs.existsSync(path.join(yamlDir,'package.json')))throw Error('Bundled Runtime yaml dependency missing');
20
+ fs.mkdirSync(parent,{recursive:true,mode:0o700});
21
+ const tmp=fs.mkdtempSync(path.join(parent,'.webhelm-runtime-stage-'));
22
+ try{
23
+ for(const dirname of ['bin','runtime'])fs.cpSync(path.join(source,dirname),path.join(tmp,dirname),{recursive:true});
24
+ fs.copyFileSync(path.join(source,'package.json'),path.join(tmp,'package.json'));
25
+ fs.mkdirSync(path.join(tmp,'node_modules'),{recursive:true});
26
+ fs.cpSync(yamlDir,path.join(tmp,'node_modules/yaml'),{recursive:true});
27
+ let old=null;
28
+ if(fs.existsSync(target)){
29
+ old=path.join(parent,`.webhelm-runtime-old-${process.pid}-${Date.now()}`);
30
+ fs.renameSync(target,old);
31
+ }
32
+ try{fs.renameSync(tmp,target);}catch(error){if(old)fs.renameSync(old,target);throw error;}
33
+ if(old)fs.rmSync(old,{recursive:true,force:true});
34
+ return {path:target,version:manifest.version,updated:true};
35
+ }finally{if(fs.existsSync(tmp))fs.rmSync(tmp,{recursive:true,force:true});}
36
+ }
37
+
38
+ const escapeXml=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&apos;'}[c]));
39
+ export function loginGuardDefinition(home=os.homedir(),{cliPath=path.join(home,'.local/bin/webhelm'),nodePath=null}={}) {
40
+ const cli=cliPath;
41
+ const program=nodePath||cli;
42
+ const args=nodePath?`<string>${escapeXml(cli)}</string><string>ensure</string>`:'<string>ensure</string>';
43
+ const environment=[path.join(home,'.local/bin'),path.join(home,'.local/opt/node22/bin'),
44
+ '/opt/homebrew/bin','/usr/local/bin','/usr/bin','/bin'].join(':');
45
+ // Use the cached, tested npm Runtime directly to avoid registry/network
46
+ // dependency at login or when Chrome has just auto-updated. This must call
47
+ // 'ensure', not 'status': a live daemon does not imply a live Chrome.
48
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict>\n<key>Label</key><string>${LABEL}</string>\n<key>ProgramArguments</key><array><string>${escapeXml(program)}</string>${args}</array>\n<key>EnvironmentVariables</key><dict><key>PATH</key><string>${escapeXml(environment)}</string><key>HOME</key><string>${escapeXml(home)}</string></dict>\n<key>RunAtLoad</key><true/>\n<key>StartInterval</key><integer>30</integer>\n<key>StandardOutPath</key><string>/dev/null</string>\n<key>StandardErrorPath</key><string>/dev/null</string>\n</dict></plist>\n`;
49
+ }
50
+ export function installLoginGuard({home=os.homedir(), platform=process.platform, apply=true,cliPath,nodePath}={}){
51
+ if(platform!=='darwin')return {status:'unsupported',label:LABEL};
52
+ const cli=cliPath||path.join(home,'.local/bin/webhelm');
53
+ if(!fs.existsSync(cli))return {status:'skipped',reason:'WebHelm runtime launcher missing',label:LABEL};
54
+ const dest=path.join(home,'Library/LaunchAgents',`${LABEL}.plist`);
55
+ const content=loginGuardDefinition(home,{cliPath,nodePath});
56
+ fs.mkdirSync(path.dirname(dest),{recursive:true});
57
+ if(fs.existsSync(dest)){
58
+ const old=fs.readFileSync(dest,'utf8');
59
+ if(!old.includes(`<string>${LABEL}</string>`))throw Error(`Refusing to replace unrelated LaunchAgent: ${dest}`);
60
+ }
61
+ const updated=!fs.existsSync(dest)||fs.readFileSync(dest,'utf8')!==content;
62
+ if(updated){
63
+ const staging=dest+`.${process.pid}.tmp`;
64
+ fs.writeFileSync(staging,content,{mode:0o600});fs.renameSync(staging,dest);
65
+ }
66
+ const service=`gui/${process.getuid()}/${LABEL}`;
67
+ if(!apply)return {status:'staged',path:dest,updated,label:LABEL};
68
+ try {
69
+ execFileSync('launchctl',['print',service],{timeout:5000,stdio:'ignore'});
70
+ if(updated){
71
+ execFileSync('launchctl',['bootout',service],{timeout:5000,stdio:'ignore'});
72
+ execFileSync('launchctl',['bootstrap',`gui/${process.getuid()}`,dest],{timeout:5000,stdio:'ignore'});
73
+ }
74
+ return {status:'active',path:dest,updated,label:LABEL};
75
+ } catch(error){
76
+ try{
77
+ execFileSync('launchctl',['bootstrap',`gui/${process.getuid()}`,dest],{timeout:5000,stdio:'ignore'});
78
+ return {status:'active',path:dest,updated,label:LABEL};
79
+ }catch(e){return {status:'staged_not_loaded',path:dest,label:LABEL,reason:e.message};}
80
+ }
81
+ }
@@ -0,0 +1,24 @@
1
+ // A daemon-owned, non-overlapping health loop. Do not use a separate worker or
2
+ // close a user's Chrome windows to recover the managed browser.
3
+ export function createManagedChromeGuard(check, {intervalMs=15000, onError=()=>{}}={}) {
4
+ if(typeof check!=='function')throw TypeError('health check must be a function');
5
+ let timer=null, inFlight=null, stopped=false;
6
+ let last={status:'not_checked'};
7
+ const tick=()=>{
8
+ if(stopped)return Promise.resolve(last);
9
+ if(inFlight)return inFlight;
10
+ inFlight=(async()=>{
11
+ try{last={status:'healthy',...(await check())};}
12
+ catch(error){last={status:'degraded',error:String(error?.message||error)};onError(error);}
13
+ finally{inFlight=null;}
14
+ return last;
15
+ })();
16
+ return inFlight;
17
+ };
18
+ return {
19
+ tick,
20
+ start(){if(timer)return;stopped=false;timer=setInterval(()=>{void tick()},intervalMs);timer.unref?.();void tick()},
21
+ stop(){stopped=true;if(timer)clearInterval(timer);timer=null},
22
+ status(){return {...last}}
23
+ };
24
+ }
@@ -11,6 +11,7 @@ import { ChatGPTQueue } from './chatgpt/chatgpt-queue.mjs';
11
11
  import { idleUrlFromIdentity, sameIdleUrl, staleSession } from './session-lifecycle.mjs';
12
12
  import { ensureManagedChrome } from './chrome.mjs';
13
13
  import { ensureExtensionLoaded } from './extension-manager.mjs';
14
+ import { createManagedChromeGuard } from './managed-chrome-guard.mjs';
14
15
  import { PROFILE_DIR, RUNTIME_DIR, requireMigratedConfig } from './config-paths.mjs';
15
16
 
16
17
  const ROOT = path.dirname(fileURLToPath(import.meta.url));
@@ -121,6 +122,42 @@ async function connectBrowser() {
121
122
  finally{connectingBrowser=null;}
122
123
  }
123
124
 
125
+ // The daemon owns both the managed Chrome process and the default Harness
126
+ // group. Chrome auto-updates can terminate the browser while the Node daemon
127
+ // survives; an RPC-only health check cannot recover that loss.
128
+ async function guardManagedChrome(){
129
+ if(shuttingDown || process.env.WEBHELM_CDP_URL || process.env.WEBHELM_NO_PROFILE_EXTENSION_PROBE==='1')
130
+ return {skipped:true};
131
+ const actual=await ensureManagedChrome();
132
+ if(!browserWs || browserWs.closed || managedChrome?.pid!==actual.pid || managedChrome?.endpoint!==actual.endpoint){
133
+ const old=browserWs;
134
+ browserWs=null;connected=false;debuggerSession=null;
135
+ if(old && !old.closed)old.ws?.close();
136
+ await connectBrowser();
137
+ }
138
+ managedChrome=actual;
139
+ // Session leasing also creates missing workspaces. Serialize this short
140
+ // identity transaction with session start so one group is created, not two.
141
+ const state=await leaseMutex('Harness').run(async()=>{
142
+ let current;
143
+ try {current=await extensionCall(null,'workspace.status',{name:'Harness'});}
144
+ catch(error){
145
+ if(sessions.size)throw error; // Never hot reload an Extension used by active Sessions.
146
+ const browser=await connectBrowser();
147
+ await ensureExtensionLoaded(browser);
148
+ current=await extensionCall(null,'workspace.status',{name:'Harness'});
149
+ }
150
+ if(!current?.initialized || !Number.isInteger(current.physicalTabs) || current.physicalTabs<1)
151
+ current=await extensionCall(null,'workspace.ensure',{name:'Harness'});
152
+ return current;
153
+ });
154
+ return {managed_chrome_pid:actual.pid,group_id:state.groupId,physical_tabs:state.physicalTabs};
155
+ }
156
+ const managedChromeGuard=createManagedChromeGuard(guardManagedChrome,{
157
+ intervalMs:Number(process.env.WEBHELM_CHROME_GUARD_INTERVAL_MS||15000),
158
+ onError:error=>console.warn(`WebHelm managed Chrome health check: ${error.message}`),
159
+ });
160
+
124
161
  function adoptPopup(info){if(info?.type!=='page'||!info.openerId)return;const owner=targets.get(info.openerId);if(owner){owner.targets.add(info.targetId);targets.set(info.targetId,owner)}}
125
162
  async function attach(s,targetId,b=null){b ||= await connectBrowser();const {sessionId}=await b.send('Target.attachToTarget',{targetId,flatten:true});s.targets.add(targetId);s.attachments.set(targetId,sessionId);targets.set(targetId,s);for(const d of ['Page','DOM','Runtime','Network'])await b.send(`${d}.enable`,{},sessionId);for(const dispose of s.eventDisposers?.get(targetId)||[])dispose();const removers=[];for(const method of ['Page.loadEventFired','Page.domContentEventFired','Page.javascriptDialogOpening','Page.javascriptDialogClosed','Runtime.exceptionThrown','Network.requestWillBeSent','Network.responseReceived'])removers.push(b.on(method,(params,owner)=>{if(owner===sessionId){if(s.events.length>=500)s.events.shift();s.events.push({method,params,session_id:sessionId,target_id:targetId})}}));s.eventDisposers?.set(targetId,removers);return sessionId}
126
163
  function sidFor(s,targetId=s.targetId){const sid=s.attachments.get(targetId);if(!sid)throw new Error(`target ${targetId} is not attached to this session`);return sid}
@@ -538,7 +575,7 @@ async function runPlatform(req) {
538
575
  self_heal_capable:!auto,result};
539
576
  }
540
577
 
541
- export async function handle(req){switch(req.op){case'ping':return {ok:true,pid:process.pid,version:PACKAGE_VERSION,queue_paused:process.env.WEBHELM_CHATGPT_QUEUE_PAUSED==='1',session_count:sessions.size,managed_chrome:managedChrome||{profile_dir:PROFILE_DIR, endpoint:process.env.WEBHELM_CDP_URL||'http://127.0.0.1:9222',reused:true}};case'heartbeat':return heartbeatSession(req.session_id);case'list':return {session_count:sessions.size,sessions:[...sessions.values()].map(s=>({session_id:s.session_id,workspace:s.workspace,target_id:s.targetId,last_used_at:s.last_used_at}))};case'start':return createSession(req);case'exec':return execute(req);case'stop':return stopSession(req.session_id);case'stop-all':{const stopped=[];for(const id of [...sessions.keys()])stopped.push(await stopSession(id));return {stopped_count:stopped.length,error_count:0,stopped,errors:[]}}case'capacity':return workspaceCapacity(req);case'workspace-create':case'workspace-delete':return workspaceOp(req);case'chatgpt-new-turn-enqueue':return getChatGPTQueue().enqueue(req.config);case'platform-run':return runPlatform(req);default:throw Error(`unknown op: ${req.op}`)}}
578
+ export async function handle(req){switch(req.op){case'ping':return {ok:true,pid:process.pid,version:PACKAGE_VERSION,queue_paused:process.env.WEBHELM_CHATGPT_QUEUE_PAUSED==='1',session_count:sessions.size,managed_chrome:managedChrome||{profile_dir:PROFILE_DIR, endpoint:process.env.WEBHELM_CDP_URL||'http://127.0.0.1:9222',reused:true},chrome_guard:managedChromeGuard.status()};case'guard-check':{let status=await managedChromeGuard.tick();for(let retry=0;retry<6 && status.status!=='healthy' && !status.skipped;retry++){await delay(800);status=await managedChromeGuard.tick();}if(status.status!=='healthy' && !status.skipped)throw Error(`WebHelm Chrome health degraded: ${status.error||status.status}`);return {ok:true,chrome_guard:status};}case'heartbeat':return heartbeatSession(req.session_id);case'list':return {session_count:sessions.size,sessions:[...sessions.values()].map(s=>({session_id:s.session_id,workspace:s.workspace,target_id:s.targetId,last_used_at:s.last_used_at}))};case'start':return createSession(req);case'exec':return execute(req);case'stop':return stopSession(req.session_id);case'stop-all':{const stopped=[];for(const id of [...sessions.keys()])stopped.push(await stopSession(id));return {stopped_count:stopped.length,error_count:0,stopped,errors:[]}}case'capacity':return workspaceCapacity(req);case'workspace-create':case'workspace-delete':return workspaceOp(req);case'chatgpt-new-turn-enqueue':return getChatGPTQueue().enqueue(req.config);case'platform-run':return runPlatform(req);default:throw Error(`unknown op: ${req.op}`)}}
542
579
  function serve(conn) {
543
580
  let buffer = '';
544
581
  conn.setEncoding('utf8');
@@ -556,7 +593,7 @@ function serve(conn) {
556
593
  });
557
594
  }
558
595
 
559
- async function main(){fs.mkdirSync(RUNTIME,{recursive:true,mode:0o700});let lockfd;try{lockfd=fs.openSync(LOCK,'wx',0o600);fs.writeFileSync(lockfd,String(process.pid))}catch{throw Error(`Node pilot lock exists: ${LOCK}`)}if(fs.existsSync(SOCKET)){try{const c=net.createConnection(SOCKET);await new Promise((res,rej)=>{c.once('connect',res);c.once('error',rej)});c.destroy();fs.closeSync(lockfd);fs.unlinkSync(LOCK);throw Error(`Node daemon socket already active: ${SOCKET}`)}catch(e){if(e.message.includes('already active'))throw e;fs.unlinkSync(SOCKET)}}socket=net.createServer(serve);await new Promise((res,rej)=>socket.listen(SOCKET,res).once('error',rej));fs.chmodSync(SOCKET,0o600);connectBrowser().catch(()=>{});getChatGPTQueue();const cleanup=async()=>{if(shuttingDown)return;shuttingDown=true;if(chatgptQueue)await chatgptQueue.shutdown().catch(()=>{});await handle({op:'stop-all'}).catch(()=>{});socket?.close();try{if(fs.existsSync(SOCKET))fs.unlinkSync(SOCKET)}catch{}try{fs.closeSync(lockfd);fs.unlinkSync(LOCK)}catch{}process.exit(0)};process.on('SIGTERM',cleanup);process.on('SIGINT',cleanup)}
596
+ async function main(){fs.mkdirSync(RUNTIME,{recursive:true,mode:0o700});let lockfd;try{lockfd=fs.openSync(LOCK,'wx',0o600);fs.writeFileSync(lockfd,String(process.pid))}catch{throw Error(`Node pilot lock exists: ${LOCK}`)}if(fs.existsSync(SOCKET)){try{const c=net.createConnection(SOCKET);await new Promise((res,rej)=>{c.once('connect',res);c.once('error',rej)});c.destroy();fs.closeSync(lockfd);fs.unlinkSync(LOCK);throw Error(`Node daemon socket already active: ${SOCKET}`)}catch(e){if(e.message.includes('already active'))throw e;fs.unlinkSync(SOCKET)}}socket=net.createServer(serve);await new Promise((res,rej)=>socket.listen(SOCKET,res).once('error',rej));fs.chmodSync(SOCKET,0o600);connectBrowser().catch(()=>{});managedChromeGuard.start();getChatGPTQueue();const cleanup=async()=>{if(shuttingDown)return;shuttingDown=true;managedChromeGuard.stop();if(chatgptQueue)await chatgptQueue.shutdown().catch(()=>{});await handle({op:'stop-all'}).catch(()=>{});socket?.close();try{if(fs.existsSync(SOCKET))fs.unlinkSync(SOCKET)}catch{}try{fs.closeSync(lockfd);fs.unlinkSync(LOCK)}catch{}process.exit(0)};process.on('SIGTERM',cleanup);process.on('SIGINT',cleanup)}
560
597
  async function request(payload,ensure=true){if(ensure)await ensureDaemon();return new Promise((resolve,reject)=>{const c=net.createConnection(SOCKET);let data='';c.setEncoding('utf8');c.on('connect',()=>c.write(JSON.stringify(payload)+'\n'));c.on('data',x=>{data+=x;if(!data.includes('\n'))return;c.end();try{const r=JSON.parse(data);r.error?reject(Error(r.error)):resolve(r)}catch(e){reject(e)}});c.on('error',reject)})}
561
598
  export async function ensureDaemon(){try{const p=await request({op:'ping'},false);if(p.ok)return p}catch{}if(!process.env.WEBHELM_CDP_URL)requireMigratedConfig();fs.mkdirSync(RUNTIME,{recursive:true,mode:0o700});if(fs.existsSync(LOCK)){let owner=0;try{owner=Number(fs.readFileSync(LOCK,'utf8').trim())}catch{};let alive=false;if(owner>0)try{process.kill(owner,0);alive=true}catch{};if(alive)throw Error(`daemon owner lock exists but socket is unreachable; refusing duplicate startup (${LOCK})`);fs.unlinkSync(LOCK)}const child=await import('node:child_process');const log=fs.openSync(path.join(RUNTIME,'session-node.log'),'a');child.spawn(process.execPath,[fileURLToPath(import.meta.url),'daemon'],{detached:true,stdio:['ignore',log,log],env:process.env}).unref();for(let i=0;i<100;i++){await delay(100);try{const p=await request({op:'ping'},false);if(p.ok)return p}catch{}}throw Error(`WebHelm Node daemon did not start; see ${path.join(RUNTIME,'session-node.log')}`)}
562
599
  if(process.argv[2]==='daemon')main().catch(e=>{console.error(e);process.exit(1)});