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 +3 -2
- package/package.json +1 -1
- package/runtime/cli.mjs +5 -0
- package/runtime/node-daemon.mjs +126 -44
- package/runtime/session-lifecycle.mjs +24 -0
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
|
|
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
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');
|
package/runtime/node-daemon.mjs
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
143
|
-
if(!s.targets.has(
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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,
|
|
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)
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
+
}
|