webhelm 1.0.5 → 1.0.7

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.
@@ -9,6 +9,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
9
9
  import { invokePlatformAction } from './platforms.mjs';
10
10
  import { ChatGPTQueue } from './chatgpt/chatgpt-queue.mjs';
11
11
  import { idleUrlFromIdentity, sameIdleUrl, staleSession } from './session-lifecycle.mjs';
12
+ import { stopMatchingSessions } from './session-control.mjs';
12
13
  import { ensureManagedChrome } from './chrome.mjs';
13
14
  import { ensureExtensionLoaded } from './extension-manager.mjs';
14
15
  import { createManagedChromeGuard } from './managed-chrome-guard.mjs';
@@ -70,8 +71,9 @@ async function acquireIdentityLease(session) {
70
71
  }
71
72
 
72
73
  async function connectBrowser() {
73
- if(browserWs && !browserWs.closed)return browserWs;
74
+ // Never expose a CDP connection before Extension initialization finishes.
74
75
  if(connectingBrowser)return connectingBrowser;
76
+ if(browserWs && !browserWs.closed)return browserWs;
75
77
  connectingBrowser=(async()=>{
76
78
  managedChrome=await ensureManagedChrome();
77
79
  const endpoint=managedChrome.endpoint;
@@ -122,9 +124,18 @@ async function connectBrowser() {
122
124
  finally{connectingBrowser=null;}
123
125
  }
124
126
 
125
- // The daemon owns both the managed Chrome process and the default Harness
127
+ // The daemon owns both the managed Chrome process and the default WebHelm
126
128
  // group. Chrome auto-updates can terminate the browser while the Node daemon
127
129
  // survives; an RPC-only health check cannot recover that loss.
130
+ const defaultWorkspaceMutex=new Mutex();
131
+ async function defaultWorkspaceName() {
132
+ if (process.env.WEBHELM_NO_PROFILE_EXTENSION_PROBE === '1') return 'WebHelm';
133
+ return defaultWorkspaceMutex.run(async()=>{
134
+ const activeLegacySessions=[...sessions.values()].filter(s=>s.workspace==='Harness').length;
135
+ const result=await extensionCall(null,'workspace.migrateDefault',{activeLegacySessions});
136
+ return result.name;
137
+ });
138
+ }
128
139
  async function guardManagedChrome(){
129
140
  if(shuttingDown || process.env.WEBHELM_CDP_URL || process.env.WEBHELM_NO_PROFILE_EXTENSION_PROBE==='1')
130
141
  return {skipped:true};
@@ -138,17 +149,18 @@ async function guardManagedChrome(){
138
149
  managedChrome=actual;
139
150
  // Session leasing also creates missing workspaces. Serialize this short
140
151
  // identity transaction with session start so one group is created, not two.
141
- const state=await leaseMutex('Harness').run(async()=>{
152
+ const name=await defaultWorkspaceName();
153
+ const state=await leaseMutex(name).run(async()=>{
142
154
  let current;
143
- try {current=await extensionCall(null,'workspace.status',{name:'Harness'});}
155
+ try {current=await extensionCall(null,'workspace.status',{name});}
144
156
  catch(error){
145
157
  if(sessions.size)throw error; // Never hot reload an Extension used by active Sessions.
146
158
  const browser=await connectBrowser();
147
159
  await ensureExtensionLoaded(browser);
148
- current=await extensionCall(null,'workspace.status',{name:'Harness'});
160
+ current=await extensionCall(null,'workspace.status',{name});
149
161
  }
150
162
  if(!current?.initialized || !Number.isInteger(current.physicalTabs) || current.physicalTabs<1)
151
- current=await extensionCall(null,'workspace.ensure',{name:'Harness'});
163
+ current=await extensionCall(null,'workspace.ensure',{name});
152
164
  return current;
153
165
  });
154
166
  return {managed_chrome_pid:actual.pid,group_id:state.groupId,physical_tabs:state.physicalTabs};
@@ -282,7 +294,13 @@ async function evaluateExtension(browser,sessionId,expression){
282
294
  if(!value?.ok)throw Error(value?.error?.message||'workspace extension RPC failed');
283
295
  return value.result;
284
296
  }
285
- async function extensionCall(_session,method,args={}) {
297
+ // Extension service-worker RPC uses one debugger attachment and must not
298
+ // race concurrent capacity polls, migration and browser Session allocation.
299
+ const extensionRpcMutex=new Mutex();
300
+ async function extensionCall(session,method,args={}) {
301
+ return extensionRpcMutex.run(()=>extensionCallSerialized(session,method,args));
302
+ }
303
+ async function extensionCallSerialized(_session,method,args={}) {
286
304
  const browser=await connectBrowser();
287
305
  const request=JSON.stringify({method,args});
288
306
  const {targetInfos=[]}=await browser.send('Target.getTargets');
@@ -297,7 +315,7 @@ async function extensionCall(_session,method,args={}) {
297
315
  `globalThis.browserWorkspaceManagerRpc(${request})`);
298
316
  }catch(error){debuggerSession=null;throw error;}
299
317
  }
300
- const page=targetInfos.find(t=>t.type==='page'&&t.url.startsWith(EXTENSION_PREFIX)&&t.url.includes('/workspace.html'));
318
+ const page=targetInfos.find(t=>t.type==='page'&&t.url.startsWith(EXTENSION_PREFIX)&&(t.url.includes('/workspace.html')||t.url.includes('/helm.html')));
301
319
  if(page){
302
320
  const {sessionId}=await browser.send('Target.attachToTarget',{targetId:page.targetId,flatten:true});
303
321
  try{return await evaluateExtension(browser,sessionId,`chrome.runtime.sendMessage(${request})`);}
@@ -307,7 +325,7 @@ async function extensionCall(_session,method,args={}) {
307
325
  // Wake a suspended MV3 worker from a temporary background extension page.
308
326
  // Only this temporary target is closed; no user tab is reused or modified.
309
327
  const {targetId}=await browser.send('Target.createTarget',{
310
- url:`${EXTENSION_PREFIX}workspace.html?webhelm_wakeup=1`,background:true});
328
+ url:`${EXTENSION_PREFIX}helm.html?webhelm_wakeup=1`,background:true});
311
329
  let sessionId;
312
330
  try{
313
331
  ({sessionId}=await browser.send('Target.attachToTarget',{targetId,flatten:true}));
@@ -333,7 +351,7 @@ function jsonSafe(value){try{JSON.stringify(value);return {value}}catch{return {
333
351
  async function createSession(req={}) {
334
352
  const browser=await connectBrowser();
335
353
  const sid=crypto.randomBytes(8).toString('hex');
336
- const s={session_id:sid, workspace:req.workspace||'Harness', targetId:null,
354
+ const s={session_id:sid, workspace:req.workspace||await defaultWorkspaceName(), targetId:null,
337
355
  rootTargetId:null, workspaceSupported:false,targets:new Set(), attachments:new Map(), eventDisposers:new Map(),
338
356
  leasedTabs:new Map(), idleUrls:new Map(), mutex:new Mutex(), events:[], namespace:null,context:null,
339
357
  inFlight:0, closing:false, last_used_at:Date.now()};
@@ -376,10 +394,10 @@ async function extensionSupported() {
376
394
  if(!installed)return false;
377
395
  let error;
378
396
  for(let attempt=0;attempt<3;attempt++){
379
- try{await extensionCall({workspace:'Harness'},'workspace.list');return true;}
397
+ try{await extensionCall({workspace:'WebHelm'},'workspace.list');return true;}
380
398
  catch(e){error=e;if(attempt<2)await delay(150);}
381
399
  }
382
- throw Error(`Browser Workspace extension installed but RPC unavailable: ${error?.message||'unknown'}`);
400
+ throw Error(`WebHelm extension installed but RPC unavailable: ${error?.message||'unknown'}`);
383
401
  }
384
402
 
385
403
  async function execute(req) {
@@ -431,7 +449,8 @@ async function closeSessionOwned(s){
431
449
  s.closing=true;
432
450
  let release_error=null;
433
451
  try {
434
- const count=s.targets.size;
452
+ const returnedTabs=s.leasedTabs.size;
453
+ const closedChildTabs=s.targets.size - returnedTabs;
435
454
  await s.mutex.run(async()=>{
436
455
  const browser=await connectBrowser();
437
456
  // Return managed tabs to their original Extension pool; keep their CDP targets.
@@ -449,7 +468,7 @@ async function closeSessionOwned(s){
449
468
  s.targets.clear();s.attachments.clear();s.leasedTabs.clear();s.idleUrls.clear();s.eventDisposers.clear();
450
469
  sessions.delete(s.session_id);
451
470
  });
452
- return {session_id:s.session_id,workspace:s.workspace,target_id:s.rootTargetId,closed_tabs:count,returned_to_idle:true,release_error};
471
+ return {session_id:s.session_id,workspace:s.workspace,target_id:s.rootTargetId,closed_tabs:closedChildTabs,returned_tabs:returnedTabs,returned_to_idle:true,release_error};
453
472
  }catch(error){
454
473
  s.closing=false; // Keep ownership for a future retry; never reassign on unconfirmed navigation.
455
474
  throw error;
@@ -488,7 +507,7 @@ function heartbeatSession(sid){
488
507
  return {session_id:sid,alive:true,last_used_at:s.last_used_at};
489
508
  }
490
509
 
491
- async function workspaceOp(req){const h=makeHelpers({workspace:req.name||'Harness',targetId:'',targets:new Set(),attachments:new Map()});if(req.op==='workspace-create')return h.workspace_create(req.name,req.pool_size);if(req.op==='workspace-delete')return h.workspace_delete(req.name,req.force);throw Error(`unknown op: ${req.op}`)}
510
+ async function workspaceOp(req){const h=makeHelpers({workspace:req.name||await defaultWorkspaceName(),targetId:'',targets:new Set(),attachments:new Map()});if(req.op==='workspace-create')return h.workspace_create(req.name,req.pool_size);if(req.op==='workspace-delete')return h.workspace_delete(req.name,req.force);throw Error(`unknown op: ${req.op}`)}
492
511
  function getChatGPTQueue() {
493
512
  if (chatgptQueue) return chatgptQueue;
494
513
  chatgptQueue = new ChatGPTQueue({
@@ -505,7 +524,7 @@ function getChatGPTQueue() {
505
524
  }
506
525
 
507
526
  async function workspaceCapacity(req={}) {
508
- const name=typeof req.workspace==='string'&&req.workspace.trim()?req.workspace.trim():'Harness';
527
+ const name=typeof req.workspace==='string'&&req.workspace.trim()?req.workspace.trim():await defaultWorkspaceName();
509
528
  const observedAt=new Date().toISOString();
510
529
  try {
511
530
  const state=await extensionCall(null,'workspace.status',{name});
@@ -523,6 +542,58 @@ async function workspaceCapacity(req={}) {
523
542
  }
524
543
  }
525
544
 
545
+ // The popup never runs commands. The daemon is the only actor that consumes
546
+ // Extension control requests, over the already-authenticated CDP channel.
547
+ let controlPollActive = false;
548
+ function visibleSessions() {
549
+ return [...sessions.values()].map(s => ({
550
+ session_id:s.session_id,workspace:s.workspace,last_used_at:s.last_used_at,
551
+ }));
552
+ }
553
+ async function pollExtensionControl() {
554
+ if (controlPollActive || shuttingDown) return;
555
+ controlPollActive = true;
556
+ try {
557
+ const pendingAction = await extensionCall(null,'workspace.control.poll',{sessions:visibleSessions()});
558
+ if (!pendingAction) return;
559
+ const {id,action,sessionId} = pendingAction;
560
+ // Keep the status panel fresh while a lengthy Session stop waits for
561
+ // browser navigation or an in-flight task.
562
+ const controlHeartbeat=setInterval(()=>{
563
+ extensionCall(null,'workspace.control.heartbeat',{sessions:visibleSessions()})
564
+ .catch(error=>console.warn(`WebHelm control heartbeat: ${error.message}`));
565
+ },3000);
566
+ let response;
567
+ try {
568
+ if (action === 'stop-all') {
569
+ response = await stopAllSessions();
570
+ } else if (action === 'stop-workspace') {
571
+ response = await stopWorkspaceSessions(pendingAction.workspace);
572
+ } else if (action === 'stop') {
573
+ response = {ok:true,stopped:await stopSession(sessionId)};
574
+ } else {
575
+ throw Error('Unknown extension control action');
576
+ }
577
+ } catch(error) {
578
+ response = {ok:false,error:String(error?.message||error)};
579
+ } finally {
580
+ clearInterval(controlHeartbeat);
581
+ }
582
+ await extensionCall(null,'workspace.control.complete',{id,response});
583
+ await extensionCall(null,'workspace.control.poll',{sessions:visibleSessions()});
584
+ } catch(error) {
585
+ console.warn(`WebHelm extension control sync failed: ${error.message}`);
586
+ } finally {
587
+ controlPollActive = false;
588
+ }
589
+ }
590
+ function stopWorkspaceSessions(workspace) {
591
+ return stopMatchingSessions(sessions, session => session.workspace === workspace, stopSession);
592
+ }
593
+ function stopAllSessions() {
594
+ return stopMatchingSessions(sessions, () => true, stopSession);
595
+ }
596
+
526
597
  /** Emit one canonical machine-readable action receipt as the final stdout line. */
527
598
  export function actionOutput(logs, value) {
528
599
  const entries=Array.isArray(logs)?logs:[];
@@ -575,7 +646,7 @@ async function runPlatform(req) {
575
646
  self_heal_capable:!auto,result};
576
647
  }
577
648
 
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'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}`)}}
649
+ 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':return stopAllSessions();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}`)}}
579
650
  function serve(conn) {
580
651
  let buffer = '';
581
652
  conn.setEncoding('utf8');
@@ -593,7 +664,10 @@ function serve(conn) {
593
664
  });
594
665
  }
595
666
 
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)}
667
+ 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();
668
+ const controlTimer=setInterval(()=>pollExtensionControl(),3000);
669
+ pollExtensionControl().catch(()=>{});
670
+ const cleanup=async()=>{if(shuttingDown)return;shuttingDown=true;clearInterval(controlTimer);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)}
597
671
  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)})}
598
672
  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')}`)}
599
673
  if(process.argv[2]==='daemon')main().catch(e=>{console.error(e);process.exit(1)});
@@ -0,0 +1,13 @@
1
+ // A Session is stopped by its daemon owner, never by direct tab mutation.
2
+ export async function stopMatchingSessions(entries, predicate, stopSession) {
3
+ const stopped = [], errors = [];
4
+ for (const [sessionId, session] of [...entries]) {
5
+ if (!predicate(session)) continue;
6
+ try { stopped.push(await stopSession(sessionId)); }
7
+ catch (error) {
8
+ errors.push({session_id:sessionId,error:String(error?.message || error)});
9
+ }
10
+ }
11
+ return {ok:errors.length === 0,stopped_count:stopped.length,
12
+ error_count:errors.length,stopped,errors};
13
+ }
@@ -8,7 +8,7 @@ export function staleSession(session, now = Date.now(), ttl = SESSION_IDLE_MS) {
8
8
 
9
9
  export function idleUrlFromIdentity(identityUrl, tabId) {
10
10
  const url = new URL(identityUrl);
11
- if (url.protocol !== 'chrome-extension:' || !url.pathname.endsWith('/workspace.html')) {
11
+ if (url.protocol !== 'chrome-extension:' || !['/workspace.html','/helm.html'].includes(url.pathname)) {
12
12
  throw new Error('invalid extension identity URL');
13
13
  }
14
14
  url.searchParams.set('role', 'idle');