webhelm 1.0.1 → 1.0.2

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/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
  | [`lalalic/webhelm`](https://github.com/lalalic/webhelm) | npm Runtime/CLI, Skill (`skills/webhelm`), Chrome Extension, tests | npm `webhelm@latest`; Skill source separately |
10
10
  | [`lalalic/webhelm-actions`](https://github.com/lalalic/webhelm-actions) | Site manifests, ChatGPT/Z.ai/DeepSeek/XHS/etc. Actions | Git checkout at `~/.local/webhelm` |
11
11
 
12
- The npm package **does not include** the Skill or site Actions. The Skill's executable is a small `npx --yes webhelm@latest` launcher; its contents may update without a Runtime release.
12
+ The npm package **does not include** the Skill or site Actions. The Skill's executable uses `npm exec --yes --package=webhelm@latest -- webhelm` to avoid inherited npm lifecycle variable conflicts; its contents may update without a Runtime release.
13
13
 
14
14
  ## Install and use
15
15
 
@@ -24,6 +24,7 @@ npx --yes webhelm@latest session exec SESSION_ID <<'JS'
24
24
  console.log(await snapshot());
25
25
  JS
26
26
  npx --yes webhelm@latest session stop SESSION_ID
27
+ # Long-running worker managers: session heartbeat SESSION_ID every 60 seconds
27
28
  ```
28
29
 
29
30
  Use `npx skills add lalalic/webhelm --skill webhelm` to install agent guidance and the bundled Extension source. The Skill lives **in this repo** at `skills/webhelm/SKILL.md`.
@@ -32,7 +33,7 @@ The Actions checkout is `~/.local/webhelm`, with site modules at `~/.local/webhe
32
33
 
33
34
  ## Architecture and compatibility
34
35
 
35
- The Runtime stores its managed Chrome profile, persistent FIFO queue, and IPC socket under `~/.config/webhelm/`. Run `webhelm migrate` with the legacy daemon and Chrome stopped to migrate existing `~/.config/browser-workspace/` data without dropping logins or queued turns. The command leaves a compatibility symlink for legacy maintenance scripts. Only one daemon is permitted to own the queue. A CLI package update does not kill a running daemon or close another agent's tabs; upgrade daemons when idle.
36
+ The Runtime stores its managed Chrome profile, persistent FIFO queue, and IPC socket under `~/.config/webhelm/`. Run `webhelm migrate` with the legacy daemon and Chrome stopped to migrate existing `~/.config/browser-workspace/` data without dropping logins or queued turns. The command leaves a compatibility symlink for legacy maintenance scripts. Session idle GC runs on Node before new Session allocation (5 minutes without operations or explicit heartbeat). Managed tabs are navigated back to their Extension idle URL instead of closing CDP targets; the Extension does not use `tab.lastAccessed` GC. Only one daemon is permitted to own the queue. A CLI package update does not kill a running daemon or close another agent's tabs; upgrade daemons when idle.
36
37
 
37
38
  NeoY and Relay can continue using `webhelm` session commands, `result.stdout` JSON receipts, and `admin_helper` returned by `status`. An `admin_helper` inside the installed Skill is a stable connector to the same Node IPC socket. `webhelm capacity` replaces the legacy Python capacity probe.
38
39
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webhelm",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "description": "Agent browser automation in a visible managed Chrome with persistent JavaScript sessions",
5
5
  "type": "module",
6
6
  "bin": {
package/runtime/cli.mjs CHANGED
@@ -27,6 +27,7 @@ Usage:
27
27
  webhelm capacity [--workspace NAME]
28
28
  webhelm session start [--url URL]
29
29
  webhelm session list
30
+ webhelm session heartbeat SESSION_ID # keep a working Session alive
30
31
  webhelm session exec SESSION_ID [--code-file FILE] # JavaScript
31
32
  webhelm session stop SESSION_ID|--all
32
33
  webhelm platform list
@@ -126,6 +127,10 @@ async function main(args = process.argv.slice(2)) {
126
127
  if (group === 'session') {
127
128
  if (command === 'start') return emit(await request({op:'start', workspace:arg(args,'--workspace'), url:arg(args,'--url')}));
128
129
  if (command === 'list') return emit(await request({op:'list'}));
130
+ if (command === 'heartbeat') {
131
+ if (!args[2]) throw Error('session heartbeat requires SESSION_ID');
132
+ return emit(await request({op:'heartbeat',session_id:args[2]}));
133
+ }
129
134
  if (command === 'stop') return emit(await request(has(args,'--all') ? {op:'stop-all'} : {op:'stop',session_id:args[2]}));
130
135
  if (command === 'exec') {
131
136
  if (!args[2]) throw Error('session exec requires SESSION_ID');
@@ -8,6 +8,7 @@ import crypto from 'node:crypto';
8
8
  import { fileURLToPath, pathToFileURL } from 'node:url';
9
9
  import { invokePlatformAction } from './platforms.mjs';
10
10
  import { ChatGPTQueue } from './chatgpt/chatgpt-queue.mjs';
11
+ import { idleUrlFromIdentity, sameIdleUrl, staleSession } from './session-lifecycle.mjs';
11
12
  import { ensureManagedChrome } from './chrome.mjs';
12
13
  import { PROFILE_DIR, RUNTIME_DIR, requireMigratedConfig } from './config-paths.mjs';
13
14
 
@@ -49,6 +50,7 @@ function leaseMutex(workspace) {
49
50
  }
50
51
  async function acquireIdentityLease(session) {
51
52
  return leaseMutex(session.workspace).run(async()=>{
53
+ await reconcileWorkspaceSessions(session.workspace);
52
54
  const lease=await extensionCall(session,'workspace.acquireIdentity',{name:session.workspace});
53
55
  const targetId=await targetForUrl(lease.identityUrl);
54
56
  if(!targetId){
@@ -56,9 +58,10 @@ async function acquireIdentityLease(session) {
56
58
  throw Error('Workspace extension leased a tab without a matching CDP target');
57
59
  }
58
60
  if(targets.has(targetId)){
59
- // Never release a tab owned by another active Session.
61
+ // Do not navigate/release a tab still owned by another Session.
60
62
  throw Error('Workspace extension returned a target already owned by another Session');
61
63
  }
64
+ lease.idleUrl ||= idleUrlFromIdentity(lease.identityUrl, lease.tabId);
62
65
  return {lease,targetId};
63
66
  });
64
67
  }
@@ -131,6 +134,7 @@ function makeHelpers(s){
131
134
  if(s.workspaceSupported){
132
135
  ({lease,targetId}=await acquireIdentityLease(s));
133
136
  s.leasedTabs.set(targetId,lease.tabId);
137
+ s.idleUrls.set(targetId,lease.idleUrl);
134
138
  }else ({targetId}=await browser.send('Target.createTarget',{url:'about:blank'}));
135
139
  await attach(s,targetId);
136
140
  s.targetId=targetId;
@@ -139,21 +143,20 @@ function makeHelpers(s){
139
143
  return targetId;
140
144
  },
141
145
  async close_tab(target) {
142
- const targetId=typeof target==='string'?target:(target?.targetId||target?.target_id)||s.targetId;
143
- if(!s.targets.has(targetId))throw Error('cannot close a tab outside the active WebHelm Session');
144
- const lease=s.leasedTabs.get(targetId);
145
- if(lease){
146
- try{await extensionCall(s,'workspace.release',{name:s.workspace,tabId:lease});}
147
- catch{await connectBrowser().then(b=>b.send('Target.closeTarget',{targetId}));}
148
- }else await connectBrowser().then(b=>b.send('Target.closeTarget',{targetId}));
149
- s.leasedTabs.delete(targetId);
150
- s.targets.delete(targetId);
151
- s.attachments.delete(targetId);
152
- for(const dispose of s.eventDisposers.get(targetId)||[])dispose();
153
- s.eventDisposers.delete(targetId);
154
- targets.delete(targetId);
155
- if(s.targetId===targetId)s.targetId=s.targets.values().next().value;
156
- return {closed:true,target_id:targetId};
146
+ const tid=typeof target==='string'?target:(target?.targetId||target?.target_id)||s.targetId;
147
+ if(!s.targets.has(tid))throw Error('cannot close a tab outside the active WebHelm Session');
148
+ return leaseMutex(s.workspace).run(async()=>{
149
+ const browser=await connectBrowser();
150
+ if(s.leasedTabs.has(tid))await navigateIdle(browser,s,tid,s.idleUrls.get(tid));
151
+ else await browser.send('Target.closeTarget',{targetId:tid}); // Popup without pool slot.
152
+ const attachment=s.attachments.get(tid);
153
+ if(attachment)await browser.send('Target.detachFromTarget',{sessionId:attachment}).catch(()=>{});
154
+ s.leasedTabs.delete(tid);s.idleUrls.delete(tid);s.targets.delete(tid);s.attachments.delete(tid);
155
+ for(const dispose of s.eventDisposers.get(tid)||[])dispose();
156
+ s.eventDisposers.delete(tid);targets.delete(tid);
157
+ if(s.targetId===tid)s.targetId=s.targets.values().next().value;
158
+ return {closed:true,target_id:tid,returned_to_idle:true};
159
+ });
157
160
  },
158
161
  async page_info(){return evaluate(s,'({url:location.href,title:document.title,readyState:document.readyState})')},
159
162
  async wait_for_load(timeout=15000){const end=Date.now()+timeout;while(Date.now()<end){try{if(await evaluate(s,'document.readyState')==='complete')return true}catch{}await delay(100)}throw Error('page load timed out')},
@@ -291,12 +294,14 @@ async function createSession(req={}) {
291
294
  const sid=crypto.randomBytes(8).toString('hex');
292
295
  const s={session_id:sid, workspace:req.workspace||'Harness', targetId:null,
293
296
  rootTargetId:null, workspaceSupported:false,targets:new Set(), attachments:new Map(), eventDisposers:new Map(),
294
- leasedTabs:new Map(), mutex:new Mutex(), events:[], namespace:null,context:null,last_used_at:Date.now()};
297
+ leasedTabs:new Map(), idleUrls:new Map(), mutex:new Mutex(), events:[], namespace:null,context:null,
298
+ inFlight:0, closing:false, last_used_at:Date.now()};
295
299
  let targetId,lease;
296
300
  try {
297
301
  if(await extensionSupported()) {
298
302
  ({lease,targetId}=await acquireIdentityLease(s));
299
303
  s.leasedTabs.set(targetId,lease.tabId);
304
+ s.idleUrls.set(targetId,lease.idleUrl);
300
305
  s.workspaceSupported=true;
301
306
  } else {
302
307
  ({targetId}=await browser.send('Target.createTarget',{url:'about:blank'}));
@@ -335,35 +340,110 @@ async function extensionSupported() {
335
340
  throw Error(`Browser Workspace extension installed but RPC unavailable: ${error?.message||'unknown'}`);
336
341
  }
337
342
 
338
- async function execute(req){const s=sessions.get(req.session_id);if(!s)throw Error(`unknown session_id: ${req.session_id}`);return s.mutex.run(async()=>{s.last_used_at=Date.now();const logs=[],errors=[];const sandbox={...s.namespace};sandbox.console={log:(...x)=>logs.push(x.map(v=>typeof v==='string'?v:JSON.stringify(v)).join(' ')),error:(...x)=>errors.push(x.map(v=>typeof v==='string'?v:JSON.stringify(v)).join(' ')),warn:(...x)=>errors.push(x.map(v=>typeof v==='string'?v:JSON.stringify(v)).join(' '))};s.context=vm.createContext(sandbox);try{let source=String(req.code||'').replace(/(^|\n)\s*(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=/g,'$1globalThis.$2 =');const lines=source.trimEnd().split('\n');const last=lines.at(-1)?.trim()||'';if(last&&!last.endsWith(';')&&!/^(return|throw|if|for|while|function|class|\}|\/\/)/.test(last)){lines[lines.length-1]=`return (${last})`}source=lines.join('\n');const script=new vm.Script(`(async()=>{${source}\n})()`,{filename:'<webhelm-session>'});const result=await script.runInContext(s.context,{timeout:30000});Object.assign(s.namespace,sandbox);const out={session_id:s.session_id,workspace:s.workspace,target_id:s.targetId,ok:true,stdout:logs.join('\n'),stderr:errors.join('\n')};if(result!==undefined)Object.assign(out,jsonSafe(result));return out}catch(e){return {session_id:s.session_id,workspace:s.workspace,target_id:s.targetId,ok:false,stdout:logs.join('\n'),stderr:errors.join('\n'),error:`${e.name}: ${e.message}`}}})}
343
+ async function execute(req) {
344
+ const s=sessions.get(req.session_id);
345
+ if(!s || s.closing)throw Error(`unknown session_id: ${req.session_id} (missing or closing)`);
346
+ s.inFlight++;
347
+ try {return await s.mutex.run(async()=>{
348
+ if(s.closing)throw Error('session closing');
349
+ s.last_used_at=Date.now();
350
+ const logs=[],errors=[];
351
+ const sandbox={...s.namespace};
352
+ sandbox.console={log:(...x)=>logs.push(x.map(v=>typeof v==='string'?v:JSON.stringify(v)).join(' ')),error:(...x)=>errors.push(x.map(v=>typeof v==='string'?v:JSON.stringify(v)).join(' ')),warn:(...x)=>errors.push(x.map(v=>typeof v==='string'?v:JSON.stringify(v)).join(' '))};
353
+ s.context=vm.createContext(sandbox);
354
+ try {
355
+ let source=String(req.code||'').replace(/(^|\n)\s*(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=/g,'$1globalThis.$2 =');
356
+ const lines=source.trimEnd().split('\n');
357
+ const last=lines.at(-1)?.trim()||'';
358
+ if(last&&!last.endsWith(';')&&!/^(return|throw|if|for|while|function|class|\}|\/\/)/.test(last))lines[lines.length-1]=`return (${last})`;
359
+ source=lines.join('\n');
360
+ const script=new vm.Script(`(async()=>{${source}\n})()`,{filename:'<webhelm-session>'});
361
+ const result=await script.runInContext(s.context,{timeout:30000});
362
+ Object.assign(s.namespace,sandbox);
363
+ const out={session_id:s.session_id,workspace:s.workspace,target_id:s.targetId,ok:true,stdout:logs.join('\n'),stderr:errors.join('\n')};
364
+ if(result!==undefined)Object.assign(out,jsonSafe(result));
365
+ return out;
366
+ }catch(e){return {session_id:s.session_id,workspace:s.workspace,target_id:s.targetId,ok:false,stdout:logs.join('\n'),stderr:errors.join('\n'),error:`${e.name}: ${e.message}`}}
367
+ });} finally {s.inFlight--;s.last_used_at=Date.now();}
368
+ }
369
+
370
+ async function targetUrl(browser,targetId) {
371
+ const reply=await browser.send('Target.getTargetInfo',{targetId});
372
+ return reply.targetInfo?.url||'';
373
+ }
374
+
375
+ async function navigateIdle(browser,s,targetId,idleUrl){
376
+ if(!idleUrl)throw Error('missing idle URL for owned tab');
377
+ if(sameIdleUrl(await targetUrl(browser,targetId),idleUrl))return;
378
+ await browser.send('Page.navigate',{url:idleUrl},sidFor(s,targetId));
379
+ for(let i=0;i<60;i++){
380
+ if(sameIdleUrl(await targetUrl(browser,targetId),idleUrl))return;
381
+ await delay(50);
382
+ }
383
+ throw Error('idle navigation not confirmed; Session retained');
384
+ }
385
+
386
+ // Must be invoked while the workspace's allocation mutex is held.
387
+ async function closeSessionOwned(s){
388
+ if(sessions.get(s.session_id)!==s)return {session_id:s.session_id,already_closed:true};
389
+ s.closing=true;
390
+ let release_error=null;
391
+ try {
392
+ const count=s.targets.size;
393
+ await s.mutex.run(async()=>{
394
+ const browser=await connectBrowser();
395
+ // Return managed tabs to their original Extension pool; keep their CDP targets.
396
+ for(const tid of [...s.targets]){
397
+ if(s.leasedTabs.has(tid)){
398
+ await navigateIdle(browser,s,tid,s.idleUrls.get(tid));
399
+ } else {
400
+ // Unleased child popup targets have no managed pool slot.
401
+ await browser.send('Target.closeTarget',{targetId:tid});
402
+ }
403
+ }
404
+ for(const [,attachment] of s.attachments)await browser.send('Target.detachFromTarget',{sessionId:attachment}).catch(()=>{});
405
+ for(const [tid,disposers] of s.eventDisposers){for(const dispose of disposers)dispose();targets.delete(tid);}
406
+ for(const tid of s.targets)targets.delete(tid);
407
+ s.targets.clear();s.attachments.clear();s.leasedTabs.clear();s.idleUrls.clear();s.eventDisposers.clear();
408
+ sessions.delete(s.session_id);
409
+ });
410
+ return {session_id:s.session_id,workspace:s.workspace,target_id:s.rootTargetId,closed_tabs:count,returned_to_idle:true,release_error};
411
+ }catch(error){
412
+ s.closing=false; // Keep ownership for a future retry; never reassign on unconfirmed navigation.
413
+ throw error;
414
+ }
415
+ }
416
+
417
+ async function reconcileWorkspaceSessions(workspace){
418
+ const browser=await connectBrowser();
419
+ for(const s of [...sessions.values()]){
420
+ if(s.workspace!==workspace||s.closing||s.inFlight>0)continue;
421
+ let alreadyIdle=false;
422
+ for(const [tid,idleUrl] of s.idleUrls){
423
+ try {if(sameIdleUrl(await targetUrl(browser,tid),idleUrl)){alreadyIdle=true;break;}}
424
+ catch {alreadyIdle=true;break;} // Lost target means this Session is no longer valid.
425
+ }
426
+ if(!alreadyIdle&&!staleSession(s))continue;
427
+ try {await closeSessionOwned(s);}
428
+ catch(error){console.error(JSON.stringify({component:'session-gc',session_id:s.session_id,error:String(error)}));}
429
+ }
430
+ }
431
+
339
432
  async function stopSession(sid){
340
433
  const s=sessions.get(sid);
341
434
  if(!s)throw Error(`unknown session_id: ${sid}`);
342
- const count=s.targets.size;
343
- let release_error=null, recovered_tab_closes=0;
344
- await s.mutex.run(async()=>{
345
- const browser=await connectBrowser();
346
- for(const tid of [...s.targets]){
347
- try{
348
- const leasedTabId=s.leasedTabs.get(tid);
349
- if(leasedTabId) {
350
- try{await extensionCall(s,'workspace.release',{name:s.workspace,tabId:leasedTabId});}
351
- catch(error){
352
- // If MV3 lost its transient ownership map, never leave an orphan.
353
- // The CDP Target is owned exclusively by this Session.
354
- await browser.send('Target.closeTarget',{targetId:tid});
355
- recovered_tab_closes++;
356
- }
357
- }else await browser.send('Target.closeTarget',{targetId:tid});
358
- }catch(error){release_error=release_error||String(error);}
359
- s.leasedTabs.delete(tid);targets.delete(tid);
360
- for(const dispose of s.eventDisposers.get(tid)||[])dispose();
361
- s.eventDisposers.delete(tid);
362
- }
363
- for(const [,attachment] of s.attachments)await browser.send('Target.detachFromTarget',{sessionId:attachment}).catch(()=>{});
364
- sessions.delete(sid);
365
- });
366
- return {session_id:sid,workspace:s.workspace,target_id:s.rootTargetId,closed_tabs:count,recovered_tab_closes,release_error};
435
+ s.closing=true;
436
+ // Do not hold the workspace allocation lock while an in-flight operation
437
+ // might be acquiring a new tab: that would deadlock its Session mutex.
438
+ await s.mutex.run(async()=>{});
439
+ return leaseMutex(s.workspace).run(()=>closeSessionOwned(s));
440
+ }
441
+
442
+ function heartbeatSession(sid){
443
+ const s=sessions.get(sid);
444
+ if(!s||s.closing)throw Error(`unknown session_id: ${sid} (missing or closing)`);
445
+ s.last_used_at=Date.now();
446
+ return {session_id:sid,alive:true,last_used_at:s.last_used_at};
367
447
  }
368
448
 
369
449
  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}`)}
@@ -418,6 +498,7 @@ async function runPlatform(req) {
418
498
  const session=sessions.get(sid);
419
499
  if (!session) throw Error(`unknown session_id: ${sid}`);
420
500
  let result, cleanup;
501
+ session.inFlight++;
421
502
  try {
422
503
  result=await session.mutex.run(async()=>{
423
504
  const logs=[];
@@ -438,6 +519,7 @@ async function runPlatform(req) {
438
519
  }
439
520
  });
440
521
  } finally {
522
+ session.inFlight--;session.last_used_at=Date.now();
441
523
  cleanup=auto ? await stopSession(sid).catch(e=>({release_error:e.message})) : {caller_owned_session:true};
442
524
  }
443
525
  result.cleanup=cleanup;
@@ -451,7 +533,7 @@ async function runPlatform(req) {
451
533
  self_heal_capable:!auto,result};
452
534
  }
453
535
 
454
- export async function handle(req){switch(req.op){case'ping':return {ok:true,pid:process.pid,version:'1.0.1',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'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}`)}}
536
+ export async function handle(req){switch(req.op){case'ping':return {ok:true,pid:process.pid,version:'1.0.1',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}`)}}
455
537
  function serve(conn) {
456
538
  let buffer = '';
457
539
  conn.setEncoding('utf8');
@@ -0,0 +1,24 @@
1
+ // A Session is live only while its owner performs operations or renews its heartbeat.
2
+ export const SESSION_IDLE_MS = 5 * 60 * 1000;
3
+
4
+ export function staleSession(session, now = Date.now(), ttl = SESSION_IDLE_MS) {
5
+ return !session.closing && !(session.inFlight > 0) &&
6
+ now - session.last_used_at >= ttl;
7
+ }
8
+
9
+ export function idleUrlFromIdentity(identityUrl, tabId) {
10
+ const url = new URL(identityUrl);
11
+ if (url.protocol !== 'chrome-extension:' || !url.pathname.endsWith('/workspace.html')) {
12
+ throw new Error('invalid extension identity URL');
13
+ }
14
+ url.searchParams.set('role', 'idle');
15
+ url.searchParams.set('slot', String(tabId));
16
+ return url.href;
17
+ }
18
+
19
+ export function sameIdleUrl(actual, expected) {
20
+ try {
21
+ const url = new URL(actual), idle = new URL(expected);
22
+ return url.protocol === 'chrome-extension:' && url.href === idle.href;
23
+ } catch { return false; }
24
+ }