webhelm 1.0.6 → 1.0.8
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 +10 -0
- package/package.json +3 -2
- package/runtime/cli.mjs +2 -2
- package/runtime/extension/control-channel.mjs +120 -0
- package/runtime/extension/helm.html +124 -0
- package/runtime/extension/icons/webhelm-128.png +0 -0
- package/runtime/extension/icons/webhelm-16.png +0 -0
- package/runtime/extension/icons/webhelm-32.png +0 -0
- package/runtime/extension/icons/webhelm-48.png +0 -0
- package/runtime/extension/manifest.json +8 -4
- package/runtime/extension/popup.html +61 -0
- package/runtime/extension/popup.mjs +136 -0
- package/runtime/extension/service-worker.mjs +96 -45
- package/runtime/extension/workspace-manager.mjs +57 -17
- package/runtime/extension/workspace-page.mjs +26 -4
- package/runtime/extension/workspace-summary.mjs +35 -0
- package/runtime/extension/workspace.html +8 -5
- package/runtime/extension-manager.mjs +23 -5
- package/runtime/node-daemon.mjs +93 -19
- package/runtime/session-control.mjs +13 -0
- package/runtime/session-lifecycle.mjs +7 -4
package/runtime/node-daemon.mjs
CHANGED
|
@@ -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';
|
|
@@ -64,14 +65,15 @@ async function acquireIdentityLease(session) {
|
|
|
64
65
|
// Do not navigate/release a tab still owned by another Session.
|
|
65
66
|
throw Error('Workspace extension returned a target already owned by another Session');
|
|
66
67
|
}
|
|
67
|
-
lease.idleUrl ||= idleUrlFromIdentity(lease.identityUrl
|
|
68
|
+
lease.idleUrl ||= idleUrlFromIdentity(lease.identityUrl);
|
|
68
69
|
return {lease,targetId};
|
|
69
70
|
});
|
|
70
71
|
}
|
|
71
72
|
|
|
72
73
|
async function connectBrowser() {
|
|
73
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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}
|
|
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||
|
|
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:'
|
|
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(`
|
|
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
|
|
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:
|
|
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||
|
|
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():
|
|
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'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':
|
|
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();
|
|
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
|
+
}
|
|
@@ -6,13 +6,16 @@ export function staleSession(session, now = Date.now(), ttl = SESSION_IDLE_MS) {
|
|
|
6
6
|
now - session.last_used_at >= ttl;
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
export function idleUrlFromIdentity(identityUrl
|
|
9
|
+
export function idleUrlFromIdentity(identityUrl) {
|
|
10
10
|
const url = new URL(identityUrl);
|
|
11
|
-
if (url.protocol !== 'chrome-extension:' || !
|
|
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
|
-
url.searchParams.
|
|
15
|
-
|
|
14
|
+
const workspace = url.searchParams.get('workspace');
|
|
15
|
+
if (!workspace) throw new Error('missing workspace in extension identity URL');
|
|
16
|
+
// Construct the canonical idle URL from scratch. Never copy lease identity
|
|
17
|
+
// or unrelated query parameters into an idle tab.
|
|
18
|
+
url.search = new URLSearchParams({workspace, role:'idle'}).toString();
|
|
16
19
|
return url.href;
|
|
17
20
|
}
|
|
18
21
|
|