webhelm 1.0.0 → 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's managed Chrome profile and its IPC socket remain at their existing `~/.config/browser-workspace/` locations to preserve logins, sessions and queue data. 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
 
@@ -45,3 +46,11 @@ npm pack --dry-run
45
46
  ```
46
47
 
47
48
  Keep platform-specific code and tests in `lalalic/webhelm-actions`. Runtime tests should use mock CDP or owned, temporary real Chrome tabs; protect other active Sessions. GitHub Actions runs Runtime/Extension CI. The Skill and Extension remain version-controlled together; npm publishing uses the standalone package at the repository root.
49
+
50
+ ### Adding an unsupported platform
51
+
52
+ The WebHelm Skill includes [`skills/webhelm/NEW_PLATFORM.md`](skills/webhelm/NEW_PLATFORM.md). All new platform code belongs in **`~/.local/webhelm/platforms/<slug>`**, checked out from `lalalic/webhelm-actions`, including tests and docs. There is no separate `platforms/` directory in the Skill or npm package. Publishing an Action uses Git, not npm.
53
+
54
+ ## npm publishing
55
+
56
+ `.github/workflows/publish.yml` publishes CLI/Runtime changes on `main` using GitHub Actions and npm OIDC trusted publishing. It reuses Agents Relay's bounded-test and publication-report Actions, as the other repositories do. The CI workflow never stores an npm write token or asks for a local 2FA approval. Configure the **webhelm** npm package's Trusted Publisher once: GitHub user `lalalic`, repository `webhelm`, workflow filename `publish.yml` (no environment), with **Allow npm publish** enabled. New configurations otherwise default to staged-only publishing and require individual approvals. A successful publish is verified against the npm registry and reported to Agents Relay. Skill-only and platform Action changes do not trigger npm publication.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webhelm",
3
- "version": "1.0.0",
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/admin.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import net from 'node:net';
2
- import os from 'node:os';
2
+ import { RUNTIME_DIR } from './config-paths.mjs';
3
3
  import path from 'node:path';
4
- const socketPath=()=>process.env.WEBHELM_SOCKET||path.join(process.env.WEBHELM_RUNTIME_DIR||path.join(os.homedir(),'.config/browser-workspace/runtime'),'session-node.sock');
4
+ const socketPath=()=>process.env.WEBHELM_SOCKET||path.join(process.env.WEBHELM_RUNTIME_DIR||RUNTIME_DIR,'session-node.sock');
5
5
  function request(payload){return new Promise((resolve,reject)=>{const s=net.createConnection(socketPath());let buf='';s.setEncoding('utf8');s.on('connect',()=>s.write(JSON.stringify(payload)+'\n'));s.on('data',d=>{buf+=d;if(!buf.includes('\n'))return;s.end();try{const v=JSON.parse(buf);v.error?reject(Error(v.error)):resolve(v)}catch(e){reject(e)}});s.on('error',reject)})}
6
6
  export async function ensureWorkspace(name,size=5){if(typeof name!=='string'||!name.trim())throw TypeError('workspace name must be a non-empty string');if(!Number.isInteger(size)||size<1)throw TypeError('workspace size must be a positive integer');return request({op:'workspace-create',name:name.trim(),pool_size:size})}
7
7
  export async function deleteWorkspace(name,{force=false}={}){if(typeof name!=='string'||!name.trim())throw TypeError('workspace name must be a non-empty string');return request({op:'workspace-delete',name:name.trim(),force:Boolean(force)})}
@@ -74,7 +74,7 @@ export async function normalizeFailure(
74
74
  {
75
75
  logPath = join(
76
76
  homedir(),
77
- ".config/browser-workspace/runtime/chatgpt-delivery-errors.jsonl",
77
+ ".config/webhelm/runtime/chatgpt-delivery-errors.jsonl",
78
78
  ),
79
79
  } = {},
80
80
  ) {
@@ -1,12 +1,11 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import os from 'node:os';
4
3
  import net from 'node:net';
5
4
  import { spawn, execFileSync } from 'node:child_process';
6
5
 
7
- const HOME = os.homedir();
8
- const ROOT = path.join(HOME, '.config/browser-workspace');
9
- const PROFILE = path.join(ROOT, 'chrome');
6
+ import { CONFIG_ROOT, PROFILE_DIR, requireMigratedConfig } from './config-paths.mjs';
7
+ const ROOT = CONFIG_ROOT;
8
+ const PROFILE = PROFILE_DIR;
10
9
  const STATE = path.join(ROOT, 'managed-chrome.json');
11
10
  const LOCK = path.join(ROOT, 'managed-chrome-node.lock');
12
11
  const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
@@ -95,6 +94,7 @@ export async function ensureManagedChrome(){
95
94
  // Explicit CDP endpoints also support hermetic mock-browser tests.
96
95
  const specified=process.env.WEBHELM_CDP_URL;
97
96
  if (specified) return {mode:'override',reused:true,endpoint:specified,profile_dir:PROFILE};
97
+ requireMigratedConfig();
98
98
  return lockOwnership(async()=>{
99
99
  const existing=await fromState()||await discover();
100
100
  if(existing)return {...existing, mode:'managed',reused:true,endpoint:`http://127.0.0.1:${existing.port}`};
package/runtime/cli.mjs CHANGED
@@ -7,6 +7,7 @@ import { randomUUID } from 'node:crypto';
7
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
+ import { migrateConfig } from './migrate.mjs';
10
11
 
11
12
  const ROOT = path.dirname(fileURLToPath(import.meta.url));
12
13
  const emit = value => console.log(JSON.stringify(value));
@@ -20,11 +21,13 @@ function help() {
20
21
  console.log(`webhelm — browser automation through one Node.js runtime
21
22
 
22
23
  Usage:
24
+ webhelm migrate # after stopping Chrome and the old WebHelm daemon
23
25
  webhelm setup
24
26
  webhelm status
25
27
  webhelm capacity [--workspace NAME]
26
28
  webhelm session start [--url URL]
27
29
  webhelm session list
30
+ webhelm session heartbeat SESSION_ID # keep a working Session alive
28
31
  webhelm session exec SESSION_ID [--code-file FILE] # JavaScript
29
32
  webhelm session stop SESSION_ID|--all
30
33
  webhelm platform list
@@ -96,6 +99,7 @@ async function runPlatform(args) {
96
99
  async function main(args = process.argv.slice(2)) {
97
100
  const [group, command] = args;
98
101
  if (!group || ['--help', '-h', 'help'].includes(group) || has(args, '--help') || has(args, '-h')) return help();
102
+ if (group === 'migrate') return emit(await migrateConfig());
99
103
  if (group === 'setup') {
100
104
  const repo=await ensureActions();
101
105
  const chrome=await ensureDaemon();
@@ -103,7 +107,7 @@ async function main(args = process.argv.slice(2)) {
103
107
  }
104
108
  if (group === 'status') {
105
109
  const daemon = await ensureDaemon();
106
- return emit({version:'1.0.0', platform_dir:PLATFORMS, root:ROOT,
110
+ return emit({version:'1.0.1', platform_dir:PLATFORMS, root:ROOT,
107
111
  cli:fileURLToPath(import.meta.url),
108
112
  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')),
109
113
  managed_chrome:daemon.managed_chrome,
@@ -123,6 +127,10 @@ async function main(args = process.argv.slice(2)) {
123
127
  if (group === 'session') {
124
128
  if (command === 'start') return emit(await request({op:'start', workspace:arg(args,'--workspace'), url:arg(args,'--url')}));
125
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
+ }
126
134
  if (command === 'stop') return emit(await request(has(args,'--all') ? {op:'stop-all'} : {op:'stop',session_id:args[2]}));
127
135
  if (command === 'exec') {
128
136
  if (!args[2]) throw Error('session exec requires SESSION_ID');
@@ -0,0 +1,14 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ import fs from 'node:fs';
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 PROFILE_DIR = path.join(CONFIG_ROOT, 'chrome');
8
+ export const RUNTIME_DIR = path.join(CONFIG_ROOT, 'runtime');
9
+
10
+ // Avoid starting a second FIFO dispatcher or empty Chrome profile against legacy data.
11
+ export function requireMigratedConfig() {
12
+ if (fs.existsSync(path.join(LEGACY_ROOT, 'chrome')) && !fs.existsSync(PROFILE_DIR))
13
+ throw Error(`Legacy Chrome profile exists at ${LEGACY_ROOT}. Stop old Chrome and WebHelm daemon, then run webhelm migrate.`);
14
+ }
@@ -0,0 +1,47 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { execFileSync } from 'node:child_process';
4
+ import net from 'node:net';
5
+ import { CONFIG_ROOT, LEGACY_ROOT } from './config-paths.mjs';
6
+
7
+ function liveSocket(socketPath) {
8
+ return new Promise(resolve => {
9
+ const socket = net.createConnection(socketPath);
10
+ const finish = result => { socket.destroy(); resolve(result); };
11
+ socket.once('connect', () => finish(true));
12
+ socket.once('error', () => finish(false));
13
+ socket.setTimeout(1200, () => finish(true)); // Fail closed if probing hangs.
14
+ });
15
+ }
16
+
17
+ export async function migrateConfig({ legacyRoot = LEGACY_ROOT, configRoot = CONFIG_ROOT,
18
+ processList = () => execFileSync('ps', ['-axo', 'command='], { encoding: 'utf8' }),
19
+ socketCheck = liveSocket } = {}) {
20
+ const legacy = path.resolve(legacyRoot);
21
+ const target = path.resolve(configRoot);
22
+ if (legacy === target) throw Error('Legacy and new config paths are identical');
23
+ let oldStat;
24
+ try { oldStat = fs.lstatSync(legacy); }
25
+ catch (e) { if (e.code === 'ENOENT') return { status: 'not_needed', config_root: target }; throw e; }
26
+ if (oldStat.isSymbolicLink()) {
27
+ if (fs.existsSync(target) && fs.realpathSync(legacy) === fs.realpathSync(target))
28
+ return { status: 'already_migrated', config_root: target, compatibility_symlink: legacy };
29
+ throw Error(`Legacy path is a different symlink: ${legacy}`);
30
+ }
31
+ if (!oldStat.isDirectory()) throw Error(`Legacy path is not a directory: ${legacy}`);
32
+ if (fs.existsSync(target)) throw Error(`Target already exists; refusing to overwrite: ${target}`);
33
+ const running = processList().split('\n').filter(line =>
34
+ line.includes(`--user-data-dir=${path.join(legacy, 'chrome')}`) ||
35
+ (line.includes('node-daemon.mjs daemon') && !line.includes('migrate.mjs')));
36
+ if (running.length) throw Error('Chrome or WebHelm daemon is still running; stop Sessions, daemon and Chrome before migration');
37
+ const socket = path.join(legacy, 'runtime', 'session-node.sock');
38
+ if (fs.existsSync(socket) && await socketCheck(socket))
39
+ throw Error('Legacy daemon socket is active; refusing migration');
40
+ fs.renameSync(legacy, target); // Same-volume atomic directory move; preserve Chrome data and FIFO WAL.
41
+ try { fs.symlinkSync(target, legacy, 'dir'); }
42
+ catch (error) {
43
+ fs.renameSync(target, legacy);
44
+ throw error;
45
+ }
46
+ return { status: 'migrated', config_root: target, compatibility_symlink: legacy };
47
+ }
@@ -8,12 +8,14 @@ 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';
13
+ import { PROFILE_DIR, RUNTIME_DIR, requireMigratedConfig } from './config-paths.mjs';
12
14
 
13
15
  const ROOT = path.dirname(new URL(import.meta.url).pathname);
14
16
  const HOME = os.homedir();
15
- export const PROFILE_DIR = path.join(HOME, '.config/browser-workspace/chrome');
16
- const DEFAULT_RUNTIME = path.join(HOME, '.config/browser-workspace/runtime');
17
+ export { PROFILE_DIR };
18
+ const DEFAULT_RUNTIME = RUNTIME_DIR;
17
19
  const RUNTIME = process.env.WEBHELM_RUNTIME_DIR || DEFAULT_RUNTIME;
18
20
  const SOCKET = process.env.WEBHELM_SOCKET || path.join(RUNTIME, 'session-node.sock');
19
21
  const LOCK = process.env.WEBHELM_LOCK || path.join(RUNTIME, 'session-node.lock');
@@ -48,6 +50,7 @@ function leaseMutex(workspace) {
48
50
  }
49
51
  async function acquireIdentityLease(session) {
50
52
  return leaseMutex(session.workspace).run(async()=>{
53
+ await reconcileWorkspaceSessions(session.workspace);
51
54
  const lease=await extensionCall(session,'workspace.acquireIdentity',{name:session.workspace});
52
55
  const targetId=await targetForUrl(lease.identityUrl);
53
56
  if(!targetId){
@@ -55,9 +58,10 @@ async function acquireIdentityLease(session) {
55
58
  throw Error('Workspace extension leased a tab without a matching CDP target');
56
59
  }
57
60
  if(targets.has(targetId)){
58
- // Never release a tab owned by another active Session.
61
+ // Do not navigate/release a tab still owned by another Session.
59
62
  throw Error('Workspace extension returned a target already owned by another Session');
60
63
  }
64
+ lease.idleUrl ||= idleUrlFromIdentity(lease.identityUrl, lease.tabId);
61
65
  return {lease,targetId};
62
66
  });
63
67
  }
@@ -130,6 +134,7 @@ function makeHelpers(s){
130
134
  if(s.workspaceSupported){
131
135
  ({lease,targetId}=await acquireIdentityLease(s));
132
136
  s.leasedTabs.set(targetId,lease.tabId);
137
+ s.idleUrls.set(targetId,lease.idleUrl);
133
138
  }else ({targetId}=await browser.send('Target.createTarget',{url:'about:blank'}));
134
139
  await attach(s,targetId);
135
140
  s.targetId=targetId;
@@ -138,21 +143,20 @@ function makeHelpers(s){
138
143
  return targetId;
139
144
  },
140
145
  async close_tab(target) {
141
- const targetId=typeof target==='string'?target:(target?.targetId||target?.target_id)||s.targetId;
142
- if(!s.targets.has(targetId))throw Error('cannot close a tab outside the active WebHelm Session');
143
- const lease=s.leasedTabs.get(targetId);
144
- if(lease){
145
- try{await extensionCall(s,'workspace.release',{name:s.workspace,tabId:lease});}
146
- catch{await connectBrowser().then(b=>b.send('Target.closeTarget',{targetId}));}
147
- }else await connectBrowser().then(b=>b.send('Target.closeTarget',{targetId}));
148
- s.leasedTabs.delete(targetId);
149
- s.targets.delete(targetId);
150
- s.attachments.delete(targetId);
151
- for(const dispose of s.eventDisposers.get(targetId)||[])dispose();
152
- s.eventDisposers.delete(targetId);
153
- targets.delete(targetId);
154
- if(s.targetId===targetId)s.targetId=s.targets.values().next().value;
155
- 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
+ });
156
160
  },
157
161
  async page_info(){return evaluate(s,'({url:location.href,title:document.title,readyState:document.readyState})')},
158
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')},
@@ -290,12 +294,14 @@ async function createSession(req={}) {
290
294
  const sid=crypto.randomBytes(8).toString('hex');
291
295
  const s={session_id:sid, workspace:req.workspace||'Harness', targetId:null,
292
296
  rootTargetId:null, workspaceSupported:false,targets:new Set(), attachments:new Map(), eventDisposers:new Map(),
293
- 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()};
294
299
  let targetId,lease;
295
300
  try {
296
301
  if(await extensionSupported()) {
297
302
  ({lease,targetId}=await acquireIdentityLease(s));
298
303
  s.leasedTabs.set(targetId,lease.tabId);
304
+ s.idleUrls.set(targetId,lease.idleUrl);
299
305
  s.workspaceSupported=true;
300
306
  } else {
301
307
  ({targetId}=await browser.send('Target.createTarget',{url:'about:blank'}));
@@ -334,35 +340,110 @@ async function extensionSupported() {
334
340
  throw Error(`Browser Workspace extension installed but RPC unavailable: ${error?.message||'unknown'}`);
335
341
  }
336
342
 
337
- 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
+
338
432
  async function stopSession(sid){
339
433
  const s=sessions.get(sid);
340
434
  if(!s)throw Error(`unknown session_id: ${sid}`);
341
- const count=s.targets.size;
342
- let release_error=null, recovered_tab_closes=0;
343
- await s.mutex.run(async()=>{
344
- const browser=await connectBrowser();
345
- for(const tid of [...s.targets]){
346
- try{
347
- const leasedTabId=s.leasedTabs.get(tid);
348
- if(leasedTabId) {
349
- try{await extensionCall(s,'workspace.release',{name:s.workspace,tabId:leasedTabId});}
350
- catch(error){
351
- // If MV3 lost its transient ownership map, never leave an orphan.
352
- // The CDP Target is owned exclusively by this Session.
353
- await browser.send('Target.closeTarget',{targetId:tid});
354
- recovered_tab_closes++;
355
- }
356
- }else await browser.send('Target.closeTarget',{targetId:tid});
357
- }catch(error){release_error=release_error||String(error);}
358
- s.leasedTabs.delete(tid);targets.delete(tid);
359
- for(const dispose of s.eventDisposers.get(tid)||[])dispose();
360
- s.eventDisposers.delete(tid);
361
- }
362
- for(const [,attachment] of s.attachments)await browser.send('Target.detachFromTarget',{sessionId:attachment}).catch(()=>{});
363
- sessions.delete(sid);
364
- });
365
- 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};
366
447
  }
367
448
 
368
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}`)}
@@ -417,6 +498,7 @@ async function runPlatform(req) {
417
498
  const session=sessions.get(sid);
418
499
  if (!session) throw Error(`unknown session_id: ${sid}`);
419
500
  let result, cleanup;
501
+ session.inFlight++;
420
502
  try {
421
503
  result=await session.mutex.run(async()=>{
422
504
  const logs=[];
@@ -437,6 +519,7 @@ async function runPlatform(req) {
437
519
  }
438
520
  });
439
521
  } finally {
522
+ session.inFlight--;session.last_used_at=Date.now();
440
523
  cleanup=auto ? await stopSession(sid).catch(e=>({release_error:e.message})) : {caller_owned_session:true};
441
524
  }
442
525
  result.cleanup=cleanup;
@@ -450,7 +533,7 @@ async function runPlatform(req) {
450
533
  self_heal_capable:!auto,result};
451
534
  }
452
535
 
453
- export async function handle(req){switch(req.op){case'ping':return {ok:true,pid:process.pid,version:'1.0.0',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}`)}}
454
537
  function serve(conn) {
455
538
  let buffer = '';
456
539
  conn.setEncoding('utf8');
@@ -470,6 +553,6 @@ function serve(conn) {
470
553
 
471
554
  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)}
472
555
  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)})}
473
- export async function ensureDaemon(){try{const p=await request({op:'ping'},false);if(p.ok)return p}catch{}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')}`)}
556
+ export async function ensureDaemon(){requireMigratedConfig();try{const p=await request({op:'ping'},false);if(p.ok)return p}catch{}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')}`)}
474
557
  if(process.argv[2]==='daemon')main().catch(e=>{console.error(e);process.exit(1)});
475
558
  export {request,SOCKET,RUNTIME};
@@ -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
+ }