webhelm 1.0.16 → 1.0.18

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
@@ -47,7 +47,7 @@ The Actions checkout is `~/.local/webhelm`, with site modules at `~/.local/webhe
47
47
 
48
48
  ## Architecture and compatibility
49
49
 
50
- 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.
50
+ 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); no background sweep is started. A timed-out Session refuses subsequent exec, platform Actions and heartbeat, while already-issued browser effects retain tab ownership until settled. A stop that times out while queued cannot execute a late release, and a stop already physically releasing keeps its Session closing until the release finishes. Managed tabs are navigated back to their Extension idle URL instead of closing CDP targets; the Extension does not use `tab.lastAccessed` GC. An async Session execution has a finite deadline: if it expires, its helper capabilities are revoked and the poisoned Session is collected on the next Tab request (or immediately by an explicit stop). Session stop also has a bounded drain/release deadline and cannot leave a permanent closing flag. Client IPC requests have an independent absolute timeout and return an error if the daemon does not respond. These safeguards do not introduce periodic background 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.
51
51
 
52
52
  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.
53
53
 
@@ -63,7 +63,7 @@ Keep platform-specific code and tests in `lalalic/webhelm-actions`. Runtime test
63
63
 
64
64
  ### Adding an unsupported platform
65
65
 
66
- 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.
66
+ For new platforms, follow the canonical [`platforms/NEW_PLATFORM.md`](https://github.com/lalalic/webhelm-actions/blob/main/platforms/NEW_PLATFORM.md) in the installed Actions checkout. 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.
67
67
 
68
68
  ## npm publishing
69
69
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webhelm",
3
- "version": "1.0.16",
3
+ "version": "1.0.18",
4
4
  "description": "Agent browser automation in a visible managed Chrome with persistent JavaScript sessions",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,7 +16,7 @@
16
16
  "PRIVACY.md"
17
17
  ],
18
18
  "scripts": {
19
- "test": "node --test test/node/*.test.mjs test/workspace-manager.test.mjs skills/webhelm/tests/*.test.mjs",
19
+ "test": "node --test test/node/*.test.mjs test/workspace-manager.test.mjs test/skill/*.test.mjs",
20
20
  "test:package": "npm pack --dry-run --json",
21
21
  "test:e2e:extension": "node test/e2e/extension-control.e2e.mjs"
22
22
  },
@@ -0,0 +1,322 @@
1
+ // CLI help is the single agent-facing command reference. Keep it in sync with
2
+ // the dispatcher's supported commands and flags.
3
+ const common = 'Use \`webhelm COMMAND --help\` or \`webhelm help COMMAND\` for detailed usage.';
4
+
5
+ const sections = {
6
+ '': `webhelm — operate managed Chrome through persistent browser Sessions and site Actions
7
+
8
+ Usage:
9
+ webhelm <command> [options]
10
+ webhelm help [command [subcommand]]
11
+
12
+ Commands:
13
+ webhelm setup Set up managed Chrome and local Actions
14
+ webhelm status Report browser and daemon status (JSON)
15
+ webhelm ensure Check and recover managed Chrome health
16
+ webhelm capacity Report available browser capacity (JSON)
17
+ webhelm session start [--url URL] Start a persistent browser Session
18
+ webhelm session list List CLI-owned Sessions
19
+ webhelm session exec ID [--code-file F] Run JavaScript in a Session
20
+ webhelm session heartbeat ID Keep a Session alive during idle waits
21
+ webhelm session stop ID|--all Stop a Session or explicitly stop all CLI Sessions
22
+ webhelm platform list List installed website integrations
23
+ webhelm platform profile NAME Inspect a site's supported fields
24
+ webhelm platform source NAME ACTION Read the source of an installed Action
25
+ webhelm platform run NAME ACTION [...] Execute an installed site Action
26
+ webhelm platform sync Update installed site Actions
27
+ webhelm platform publish --message MSG Publish reviewed site Action changes
28
+ webhelm migrate Migrate supported legacy local browser data
29
+ webhelm help [command [subcommand]] Show command help
30
+ Options:
31
+ -h, --help Show help without running the command
32
+
33
+ Output: operational commands emit JSON; platform source prints JavaScript source.
34
+ ${common}`,
35
+ help: `webhelm help — show command usage without side effects
36
+
37
+ Usage:
38
+ webhelm --help
39
+ webhelm help [COMMAND [SUBCOMMAND]]
40
+ webhelm COMMAND [SUBCOMMAND] --help
41
+
42
+ Shows all commands or detailed usage for any available command and subcommand.
43
+ Help never initializes Chrome, changes Sessions, or runs site Actions.
44
+
45
+ Example:
46
+ webhelm help session exec`,
47
+ setup: `webhelm setup — prepare local Actions and managed Chrome
48
+
49
+ Usage:
50
+ webhelm setup
51
+
52
+ Initializes or reuses the site Actions checkout, starts or reuses managed
53
+ Chrome and its daemon, and registers the login-time health check.
54
+ Does not replace an existing Actions checkout with local changes.
55
+ Output: JSON with status, Actions checkout path, daemon and browser details.
56
+
57
+ Example:
58
+ webhelm setup`,
59
+ status: `webhelm status — inspect managed browser and daemon
60
+
61
+ Usage:
62
+ webhelm status
63
+
64
+ Output: JSON describing CLI version, installed Actions location, managed
65
+ Chrome process and profile, health-check state, and daemon Session count.
66
+
67
+ Example:
68
+ webhelm status`,
69
+ ensure: `webhelm ensure — verify and recover managed Chrome health
70
+
71
+ Usage:
72
+ webhelm ensure
73
+
74
+ Checks the existing daemon and managed browser and attempts a safe recovery.
75
+ May defer an incompatible upgrade while active Sessions are running.
76
+ Output: JSON health or deferred-upgrade result.
77
+
78
+ Example:
79
+ webhelm ensure`,
80
+ capacity: `webhelm capacity — inspect browser availability
81
+
82
+ Usage:
83
+ webhelm capacity
84
+
85
+ Reports allocatable browser capacity without opening a Session.
86
+ Output: JSON capacity information.
87
+
88
+ Example:
89
+ webhelm capacity`,
90
+ migrate: `webhelm migrate — migrate supported legacy local browser data
91
+
92
+ Usage:
93
+ webhelm migrate
94
+
95
+ Run only after stopping the legacy daemon and browser and draining Sessions.
96
+ Preserves the signed-in browser profile and supported local state.
97
+ Output: JSON with migration results.
98
+
99
+ Example:
100
+ webhelm migrate`,
101
+ session: `webhelm session — manage persistent JavaScript browser Sessions
102
+
103
+ Usage:
104
+ webhelm session <subcommand> [options]
105
+
106
+ Subcommands:
107
+ start [--url URL] Start a Session and receive a session_id
108
+ list List active CLI-owned Sessions
109
+ exec ID [--code-file FILE] Run JavaScript in a persistent Session
110
+ heartbeat ID Refresh the idle timer for a Session
111
+ stop ID Stop one Session and release its tab
112
+ stop --all Stop all CLI-owned Sessions (destructive)
113
+
114
+ Always reuse the returned session_id; stop only Sessions owned by your task.
115
+ An idle Session can be reclaimed after five minutes without activity.
116
+ Long-running workflows should heartbeat at least every 60 seconds.
117
+
118
+ Examples:
119
+ webhelm session start --url https://example.com
120
+ webhelm session list
121
+ webhelm session exec SESSION_ID --code-file ./inspect.js
122
+ webhelm session stop SESSION_ID
123
+
124
+ Run 'webhelm session <subcommand> --help' for detailed usage.`,
125
+ 'session start': `webhelm session start — open a persistent browser Session
126
+
127
+ Usage:
128
+ webhelm session start [--url URL]
129
+
130
+ Options:
131
+ --url URL Optional initial http(s) destination
132
+ -h, --help Show help without opening a browser Session
133
+
134
+ Output: JSON containing the new session_id. Retain it for exec and stop.
135
+
136
+ Example:
137
+ webhelm session start --url https://example.com`,
138
+ 'session list': `webhelm session list — list CLI-owned Sessions
139
+
140
+ Usage:
141
+ webhelm session list
142
+
143
+ Output: JSON with session_count and sessions array, including session_id.
144
+ Does not create a Session.
145
+
146
+ Example:
147
+ webhelm session list`,
148
+ 'session exec': `webhelm session exec — run JavaScript in an existing Session
149
+
150
+ Usage:
151
+ webhelm session exec SESSION_ID [--code-file FILE]
152
+ webhelm session exec SESSION_ID <<'JS'
153
+ console.log(await snapshot());
154
+ JS
155
+
156
+ Arguments:
157
+ SESSION_ID ID returned by 'webhelm session start'
158
+
159
+ Options:
160
+ --code-file FILE Read JavaScript from FILE instead of standard input
161
+ -h, --help Show help without executing code
162
+
163
+ The JavaScript context persists across exec calls. Browser helpers are async:
164
+ snapshot, page_info, goto_url, wait_for_load, wait_for_element,
165
+ fill_input, click, js, cdp, new_tab, current_tab, list_tabs,
166
+ switch_tab, close_tab, upload_file, type_text, capture_screenshot,
167
+ wait.
168
+ Read 'snapshot()' before using element references and refresh after navigation.
169
+ Use 'new_tab()' rather than raw CDP target creation.
170
+ Output: JSON with execution status and captured result/stdout.
171
+
172
+ Example:
173
+ printf 'console.log(await page_info());\\n' | webhelm session exec SESSION_ID`,
174
+ 'session heartbeat': `webhelm session heartbeat — keep an active Session alive
175
+
176
+ Usage:
177
+ webhelm session heartbeat SESSION_ID
178
+
179
+ Refreshes the Session's idle timer without navigating or changing tabs.
180
+ Use at least every 60 seconds during long waits with no browser operations.
181
+ Output: JSON heartbeat result.
182
+
183
+ Example:
184
+ webhelm session heartbeat SESSION_ID`,
185
+ 'session stop': `webhelm session stop — release browser Sessions
186
+
187
+ Usage:
188
+ webhelm session stop SESSION_ID
189
+ webhelm session stop --all
190
+
191
+ Arguments:
192
+ SESSION_ID Release one Session owned by the current workflow
193
+
194
+ Options:
195
+ --all Stop every CLI-owned Session; use only when explicitly authorized
196
+ -h, --help Show help without stopping Sessions
197
+
198
+ Output: JSON release result. Stopping a Session may return its tab to idle.
199
+ Do not use --all for ordinary task cleanup.
200
+
201
+ Example:
202
+ webhelm session stop SESSION_ID`,
203
+ platform: `webhelm platform — use installed website Actions
204
+
205
+ Usage:
206
+ webhelm platform <subcommand> [options]
207
+
208
+ Subcommands:
209
+ list List installed site platforms
210
+ profile PLATFORM Report site fields and capabilities
211
+ source PLATFORM ACTION Print the Action's JavaScript source
212
+ run PLATFORM ACTION [opts] Execute a site Action in a browser Session
213
+ sync Fast-forward local Action definitions
214
+ publish --message MESSAGE Test, commit and push reviewed Action changes
215
+
216
+ Examples:
217
+ webhelm platform list
218
+ webhelm platform profile chatgpt
219
+ webhelm platform source z new-turn
220
+ webhelm platform run z new-turn --session-id SESSION_ID --config ./input.json
221
+
222
+ Run 'webhelm platform <subcommand> --help' for detailed usage.`,
223
+ 'platform list': `webhelm platform list — discover installed website platforms
224
+
225
+ Usage:
226
+ webhelm platform list
227
+
228
+ Output: JSON array of available site names, read from installed Actions.
229
+
230
+ Example:
231
+ webhelm platform list`,
232
+ 'platform profile': `webhelm platform profile — inspect a website integration
233
+
234
+ Usage:
235
+ webhelm platform profile PLATFORM
236
+
237
+ Arguments:
238
+ PLATFORM Site name from 'webhelm platform list'
239
+
240
+ Output: JSON profile with available capabilities, field schemas and notes
241
+ when declared by the platform. Read it before building Action input.
242
+
243
+ Example:
244
+ webhelm platform profile chatgpt`,
245
+ 'platform source': `webhelm platform source — inspect an installed site Action
246
+
247
+ Usage:
248
+ webhelm platform source PLATFORM ACTION
249
+
250
+ Arguments:
251
+ PLATFORM Site name from 'webhelm platform list'
252
+ ACTION Action name defined by the site's manifest
253
+
254
+ Output: JavaScript source text (not JSON). Inspection has no browser effect.
255
+
256
+ Example:
257
+ webhelm platform source z new-turn`,
258
+ 'platform run': `webhelm platform run — execute an installed site Action
259
+
260
+ Usage:
261
+ webhelm platform run PLATFORM ACTION --session-id SESSION_ID [--config FILE] [--url URL]
262
+ webhelm platform run PLATFORM ACTION --auto-session [--config FILE] [--url URL]
263
+ webhelm platform run chatgpt new-turn [--config FILE] [--mode async|sync] [--session-id ID]
264
+
265
+ Arguments:
266
+ PLATFORM Site name from 'webhelm platform list'
267
+ ACTION Action name declared by the site's manifest
268
+
269
+ Options:
270
+ --session-id ID Reuse a caller-owned Session (normal interactive mode)
271
+ --auto-session Create and release a Session for one self-contained Action
272
+ --config FILE Read a JSON object of Action-specific inputs (default: {})
273
+ --url URL Optional initial URL for a browser Action
274
+ --mode MODE Only for chatgpt/new-turn: async (default) or sync
275
+ -h, --help Show help without executing an Action
276
+
277
+ --session-id and --auto-session cannot be combined. For chatgpt/new-turn,
278
+ async mode uses no Session option; sync mode requires --session-id.
279
+ Inspect 'platform profile' and 'platform source' for Action-specific inputs.
280
+ Output: JSON Action result. An accepted submission may require later verification.
281
+
282
+ Examples:
283
+ webhelm platform run z new-turn --session-id SESSION_ID --config ./turn.json
284
+ webhelm platform run chatgpt new-turn --config ./turn.json
285
+ webhelm platform run chatgpt new-turn --mode sync --session-id SESSION_ID --config ./turn.json`,
286
+ 'platform sync': `webhelm platform sync — update installed site Actions
287
+
288
+ Usage:
289
+ webhelm platform sync
290
+
291
+ Fetches and fast-forwards the Actions checkout. Refuses to overwrite local
292
+ changes or a diverged checkout. Does not update the browser Runtime.
293
+ Output: JSON sync status and revision.
294
+
295
+ Example:
296
+ webhelm platform sync`,
297
+ 'platform publish': `webhelm platform publish — publish verified site Action changes
298
+
299
+ Usage:
300
+ webhelm platform publish --message "DESCRIPTION"
301
+
302
+ Options:
303
+ --message DESCRIPTION Required commit description (8–250 characters)
304
+ -h, --help Show help without publishing
305
+
306
+ Runs the independent Actions tests, stages site Action files, commits, and
307
+ pushes without force. Use only for reviewed, authorized changes. Do not
308
+ publish account data, secrets or browser traces.
309
+ Output: JSON publish status and revision.
310
+
311
+ Example:
312
+ webhelm platform publish --message "fix(z): update composer selector"`,
313
+ };
314
+
315
+ export const helpTopics = Object.freeze(Object.keys(sections).filter(Boolean));
316
+ export function cliHelp(topics=[]) {
317
+ const parts = topics.filter(Boolean);
318
+ const key = parts.length>1 && ['session','platform'].includes(parts[0])
319
+ ? parts.slice(0,2).join(' ') : (parts[0] || '');
320
+ if (!Object.hasOwn(sections,key)) throw Error(`unknown help topic: ${parts.join(' ')}`);
321
+ return sections[key];
322
+ }
package/runtime/cli.mjs CHANGED
@@ -9,6 +9,7 @@ import { PLATFORMS, listPlatforms, platformProfile, actionPath, validateConfig }
9
9
  import { ACTIONS_DIR, ensureActions, syncActions, publishActions } from './actions-repo.mjs';
10
10
  import { migrateConfig, migrateChromeProfile } from './migrate.mjs';
11
11
  import { installLoginGuard, stageRuntimeForLoginGuard } from './login-guard.mjs';
12
+ import { cliHelp } from './cli-help.mjs';
12
13
 
13
14
  const ROOT = path.dirname(fileURLToPath(import.meta.url));
14
15
  const CLI_WORKSPACE = 'WebHelm';
@@ -19,30 +20,6 @@ const arg = (items, name) => {
19
20
  };
20
21
  const has = (items, name) => items.includes(name);
21
22
 
22
- function help() {
23
- console.log(`webhelm — browser automation through one Node.js runtime
24
-
25
- Usage:
26
- webhelm migrate # after stopping Chrome and the old WebHelm daemon
27
- webhelm setup
28
- webhelm status
29
- webhelm ensure # verify/recover managed Chrome and WebHelm group, not just daemon ping
30
- webhelm capacity # WebHelm workspace only
31
- webhelm session start [--url URL] # WebHelm workspace only
32
- webhelm session list
33
- webhelm session heartbeat SESSION_ID # keep a working Session alive
34
- webhelm session exec SESSION_ID [--code-file FILE] # JavaScript
35
- webhelm session stop SESSION_ID|--all
36
- webhelm platform list
37
- webhelm platform sync
38
- webhelm platform publish --message "DESCRIPTION"
39
- webhelm platform profile PLATFORM
40
- webhelm platform source PLATFORM ACTION
41
- webhelm platform run PLATFORM ACTION [--session-id ID | --auto-session] [--config FILE] [--mode async|sync]
42
-
43
- Agent workflows should keep their session_id across browser actions and always stop owned sessions.
44
- `);
45
- }
46
23
 
47
24
  function loadConfig(args) {
48
25
  const file = arg(args, '--config');
@@ -118,7 +95,14 @@ async function requireCliSession(sessionId) {
118
95
 
119
96
  async function main(args = process.argv.slice(2)) {
120
97
  const [group, command] = args;
121
- if (!group || ['--help', '-h', 'help'].includes(group) || has(args, '--help') || has(args, '-h')) return help();
98
+ if (!group || ['--help', '-h', 'help'].includes(group) || has(args, '--help') || has(args, '-h') ||
99
+ ((group === 'session' || group === 'platform') && (!command || command === 'help'))) {
100
+ const topics = group === 'help'
101
+ ? args.slice(1).filter(item => !['--help', '-h'].includes(item))
102
+ : ['--help', '-h'].includes(group) ? [] : args.filter(item => !['--help', '-h', 'help'].includes(item));
103
+ if (group === 'help' && !topics.length && (has(args, '--help') || has(args, '-h'))) topics.push('help');
104
+ return console.log(cliHelp(topics));
105
+ }
122
106
  rejectWorkspaceOverride(args);
123
107
  if (group === 'migrate') {
124
108
  await ensureActions(); // ~/.local/webhelm is the Actions Git checkout, never create an empty replacement.
@@ -8,7 +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
+ import { idleUrlFromIdentity, sameIdleUrl, staleSession, withinDeadline, SessionDeadlineError } from './session-lifecycle.mjs';
12
12
  import { stopMatchingSessions } from './session-control.mjs';
13
13
  import { ensureManagedChrome } from './chrome.mjs';
14
14
  import { ensureExtensionLoaded } from './extension-manager.mjs';
@@ -173,8 +173,8 @@ const managedChromeGuard=createManagedChromeGuard(guardManagedChrome,{
173
173
  function adoptPopup(info){if(info?.type!=='page'||!info.openerId)return;const owner=targets.get(info.openerId);if(owner){owner.targets.add(info.targetId);targets.set(info.targetId,owner)}}
174
174
  async function attach(s,targetId,b=null){b ||= await connectBrowser();const {sessionId}=await b.send('Target.attachToTarget',{targetId,flatten:true});s.targets.add(targetId);s.attachments.set(targetId,sessionId);targets.set(targetId,s);for(const d of ['Page','DOM','Runtime','Network'])await b.send(`${d}.enable`,{},sessionId);for(const dispose of s.eventDisposers?.get(targetId)||[])dispose();const removers=[];for(const method of ['Page.loadEventFired','Page.domContentEventFired','Page.javascriptDialogOpening','Page.javascriptDialogClosed','Runtime.exceptionThrown','Network.requestWillBeSent','Network.responseReceived'])removers.push(b.on(method,(params,owner)=>{if(owner===sessionId){if(s.events.length>=500)s.events.shift();s.events.push({method,params,session_id:sessionId,target_id:targetId})}}));s.eventDisposers?.set(targetId,removers);return sessionId}
175
175
  function sidFor(s,targetId=s.targetId){const sid=s.attachments.get(targetId);if(!sid)throw new Error(`target ${targetId} is not attached to this session`);return sid}
176
- async function evaluate(s,expression,targetId=s.targetId,awaitPromise=true){if(!s.targets.has(targetId))throw Error('cannot evaluate a target outside the active WebHelm session');const b=await connectBrowser();const r=await b.send('Runtime.evaluate',{expression,returnByValue:true,awaitPromise,userGesture:true},sidFor(s,targetId));if(r.exceptionDetails)throw new Error(r.exceptionDetails.exception?.description||r.exceptionDetails.text);return r.result?.value}
177
- async function cdp(s,method,params={},targetId=s.targetId){if(method==='Target.createTarget')throw Error('Target.createTarget is disabled; use new_tab(url)');if(method==='Target.attachToTarget'||method==='Target.detachFromTarget')throw Error('direct target attachment changes are disabled in WebHelm sessions');if(['Target.closeTarget','Target.activateTarget'].includes(method)&&!s.targets.has(params.targetId))throw Error('cannot operate on a tab outside the active WebHelm session');if(method.startsWith('Target.')||method.startsWith('Browser.')||method.startsWith('ServiceWorker.'))return connectBrowser().then(b=>b.send(method,params));return connectBrowser().then(b=>b.send(method,params,sidFor(s,targetId)))}
176
+ async function evaluate(s,expression,targetId=s.targetId,awaitPromise=true){if(s.poisoned || s.closing)throw Error('Session execution expired or closing');if(!s.targets.has(targetId))throw Error('cannot evaluate a target outside the active WebHelm session');const b=await connectBrowser();if(s.poisoned || s.closing)throw Error('Session execution expired or closing');const r=await b.send('Runtime.evaluate',{expression,returnByValue:true,awaitPromise,userGesture:true},sidFor(s,targetId));if(r.exceptionDetails)throw new Error(r.exceptionDetails.exception?.description||r.exceptionDetails.text);return r.result?.value}
177
+ async function cdp(s,method,params={},targetId=s.targetId){if(s.poisoned || s.closing)throw Error('Session execution expired or closing');if(method==='Target.createTarget')throw Error('Target.createTarget is disabled; use new_tab(url)');if(method==='Target.attachToTarget'||method==='Target.detachFromTarget')throw Error('direct target attachment changes are disabled in WebHelm sessions');if(['Target.closeTarget','Target.activateTarget'].includes(method)&&!s.targets.has(params.targetId))throw Error('cannot operate on a tab outside the active WebHelm session');return connectBrowser().then(browser=>{if(s.poisoned || s.closing)throw Error('Session execution expired or closing');if(method.startsWith('Target.')||method.startsWith('Browser.')||method.startsWith('ServiceWorker.'))return browser.send(method,params);return browser.send(method,params,sidFor(s,targetId));})}
178
178
  function makeHelpers(s){
179
179
  const h={
180
180
  js:(expression,target_id)=>evaluate(s,expression,target_id||s.targetId),
@@ -183,9 +183,11 @@ function makeHelpers(s){
183
183
  async current_tab(){const info=await cdp(s,'Target.getTargetInfo',{targetId:s.targetId});return info.targetInfo},
184
184
  async new_tab(url='about:blank') {
185
185
  const browser=await connectBrowser();
186
+ if(s.poisoned || s.closing)throw Error('Session execution expired or closing');
186
187
  let targetId,lease;
187
188
  if(s.workspaceSupported){
188
189
  ({lease,targetId}=await acquireIdentityLease(s));
190
+ if(s.poisoned || s.closing){await extensionCall(s,'workspace.release',{name:s.workspace,tabId:lease.tabId});throw Error('Session execution expired');}
189
191
  s.leasedTabs.set(targetId,lease.tabId);
190
192
  s.idleUrls.set(targetId,lease.idleUrl);
191
193
  }else ({targetId}=await browser.send('Target.createTarget',{url:'about:blank'}));
@@ -200,6 +202,7 @@ function makeHelpers(s){
200
202
  if(!s.targets.has(tid))throw Error('cannot close a tab outside the active WebHelm Session');
201
203
  return leaseMutex(s.workspace).run(async()=>{
202
204
  const browser=await connectBrowser();
205
+ if(s.poisoned || s.closing)throw Error('Session execution expired or closing');
203
206
  if(s.leasedTabs.has(tid))await navigateIdle(browser,s,tid,s.idleUrls.get(tid));
204
207
  else await browser.send('Target.closeTarget',{targetId:tid}); // Popup without pool slot.
205
208
  const attachment=s.attachments.get(tid);
@@ -227,7 +230,7 @@ function makeHelpers(s){
227
230
  },
228
231
  async fill_input(selector,text){if(!await h.wait_for_element(selector))throw Error(`element not found: ${selector}`);return evaluate(s,`(()=>{const e=document.querySelector(${JSON.stringify(selector)});const set=Object.getOwnPropertyDescriptor(Object.getPrototypeOf(e),'value')?.set;if(set)set.call(e,${JSON.stringify(String(text))});else e.textContent=${JSON.stringify(String(text))};e.dispatchEvent(new InputEvent('input',{bubbles:true,inputType:'insertText',data:${JSON.stringify(String(text))}}));e.dispatchEvent(new Event('change',{bubbles:true}));return true})()`)},
229
232
  async click(selector){if(!await h.wait_for_element(selector))throw Error(`element not found: ${selector}`);return evaluate(s,`(()=>{const e=document.querySelector(${JSON.stringify(selector)});e.click();return true})()`)},
230
- async capture_screenshot(pathname){const b=await connectBrowser();const data=await b.send('Page.captureScreenshot',{format:'png'},sidFor(s));if(pathname)fs.writeFileSync(pathname,Buffer.from(data.data,'base64'));return data.data},
233
+ async capture_screenshot(pathname){const b=await connectBrowser();if(s.poisoned || s.closing)throw Error('Session execution expired or closing');const data=await b.send('Page.captureScreenshot',{format:'png'},sidFor(s));if(s.poisoned || s.closing)throw Error('Session execution expired or closing');if(pathname)fs.writeFileSync(pathname,Buffer.from(data.data,'base64'));return data.data},
231
234
  async snapshot(interactive_only=false){const result=await evaluate(s,`(()=>{let n=0;const xs=[...document.querySelectorAll('button,a,input,textarea,select,[role],[tabindex]')].filter(e=>{const r=e.getBoundingClientRect(),s=getComputedStyle(e);return r.width&&r.height&&s.visibility!=='hidden'&&s.display!=='none'});return xs.map(e=>{const id='e'+(++n);e.setAttribute('data-ref',id);const role=e.getAttribute('role')||({BUTTON:'button',A:'link',INPUT:'textbox',TEXTAREA:'textbox',SELECT:'combobox'}[e.tagName]||e.tagName.toLowerCase());const name=e.getAttribute('aria-label')||e.innerText||e.value||e.placeholder||'';return '- '+role+' '+JSON.stringify(name.trim().slice(0,100))+' [ref='+id+']'}).join('\\n')})()`);return result||''},
232
235
  async workspace_status(){return extensionCall(s,'workspace.status',{name:s.workspace})},
233
236
  async workspace_capacity(){const r=await h.workspace_status();const idle=Array.isArray(r.idleTabIds)?r.idleTabIds.length:null,leased=Array.isArray(r.leasedTabIds)?r.leasedTabIds.length:null;return {name:r.name,poolSize:r.poolSize??null,maxCapacity:r.maxCapacity??r.poolSize??null,physicalTabs:r.physicalTabs??(idle!==null&&leased!==null?idle+leased:null),idle,leased,available:Number.isInteger(r.maxCapacity??r.poolSize)&&leased!==null?Math.max(0,(r.maxCapacity??r.poolSize)-leased):null}} ,
@@ -263,6 +266,22 @@ function makeHelpers(s){
263
266
  async current_url(){return (await h.page_info()).url},
264
267
  async title(){return (await h.page_info()).title}
265
268
  };
269
+ // Revoke even helper references retained by unresolved async VM scripts.
270
+ // A rejected async VM execution does not cancel browser operations it
271
+ // already started. Keep their tab ownership until those operations settle.
272
+ const effectHelpers=new Set(['js','cdp','goto_url','new_tab','close_tab','fill_input',
273
+ 'click','capture_screenshot','snapshot','type_text','upload_file','switch_tab','activate_tab']);
274
+ for(const [name,helper] of Object.entries(h)){
275
+ if(typeof helper!=='function')continue;
276
+ h[name]=(...args)=>{
277
+ if(s.poisoned || s.closing)throw Error('Session execution expired or closing');
278
+ if(!effectHelpers.has(name))return helper(...args);
279
+ s.pendingEffects++;
280
+ try{
281
+ return Promise.resolve(helper(...args)).finally(()=>{s.pendingEffects--;});
282
+ }catch(error){s.pendingEffects--;throw error;}
283
+ };
284
+ }
266
285
  Object.defineProperty(h,'target_id',{get:()=>s.targetId});
267
286
  Object.defineProperty(h,'targetId',{get:()=>s.targetId});
268
287
  return h;
@@ -363,7 +382,7 @@ async function createSession(req={}) {
363
382
  const s={session_id:sid, workspace:req.workspace||await defaultWorkspaceName(), targetId:null,
364
383
  rootTargetId:null, workspaceSupported:false,targets:new Set(), attachments:new Map(), eventDisposers:new Map(),
365
384
  leasedTabs:new Map(), idleUrls:new Map(), mutex:new Mutex(), events:[], namespace:null,context:null,
366
- inFlight:0, closing:false, last_used_at:Date.now()};
385
+ inFlight:0, pendingEffects:0, closing:false, poisoned:false, last_used_at:Date.now()};
367
386
  let targetId,lease;
368
387
  try {
369
388
  if(await extensionSupported()) {
@@ -411,10 +430,10 @@ async function extensionSupported() {
411
430
 
412
431
  async function execute(req) {
413
432
  const s=sessions.get(req.session_id);
414
- if(!s || s.closing)throw Error(`unknown session_id: ${req.session_id} (missing or closing)`);
433
+ if(!s || s.closing || s.poisoned)throw Error(`unknown session_id: ${req.session_id} (missing or closing)`);
415
434
  s.inFlight++;
416
435
  try {return await s.mutex.run(async()=>{
417
- if(s.closing)throw Error('session closing');
436
+ if(s.closing || s.poisoned)throw Error('session closing or expired');
418
437
  s.last_used_at=Date.now();
419
438
  const logs=[],errors=[];
420
439
  const sandbox={...s.namespace};
@@ -427,12 +446,15 @@ async function execute(req) {
427
446
  if(last&&!last.endsWith(';')&&!/^(return|throw|if|for|while|function|class|\}|\/\/)/.test(last))lines[lines.length-1]=`return (${last})`;
428
447
  source=lines.join('\n');
429
448
  const script=new vm.Script(`(async()=>{${source}\n})()`,{filename:'<webhelm-session>'});
430
- const result=await script.runInContext(s.context,{timeout:30000});
449
+ const timeoutMs=Math.max(30,Math.min(30000,Number(process.env.WEBHELM_SESSION_EXEC_TIMEOUT_MS)||30000));
450
+ const result=await withinDeadline(
451
+ Promise.resolve().then(()=>script.runInContext(s.context,{timeout:timeoutMs})),timeoutMs,'Session exec');
452
+ if(s.poisoned)throw Error('Session expired during execution');
431
453
  Object.assign(s.namespace,sandbox);
432
454
  const out={session_id:s.session_id,workspace:s.workspace,target_id:s.targetId,ok:true,stdout:logs.join('\n'),stderr:errors.join('\n')};
433
455
  if(result!==undefined)Object.assign(out,jsonSafe(result));
434
456
  return out;
435
- }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}`}}
457
+ }catch(e){if(e instanceof SessionDeadlineError || (e?.message||'').includes('Script execution timed out'))s.poisoned=true;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}`}}
436
458
  });} finally {s.inFlight--;s.last_used_at=Date.now();}
437
459
  }
438
460
 
@@ -456,6 +478,7 @@ async function navigateIdle(browser,s,targetId,idleUrl){
456
478
  async function closeSessionOwned(s){
457
479
  if(sessions.get(s.session_id)!==s)return {session_id:s.session_id,already_closed:true};
458
480
  s.closing=true;
481
+ s.poisoned=true; // Revoke delayed helpers after terminal close.
459
482
  let release_error=null;
460
483
  try {
461
484
  const returnedTabs=s.leasedTabs.size;
@@ -487,7 +510,7 @@ async function closeSessionOwned(s){
487
510
  async function reconcileWorkspaceSessions(workspace){
488
511
  const browser=await connectBrowser();
489
512
  for(const s of [...sessions.values()]){
490
- if(s.workspace!==workspace||s.closing||s.inFlight>0)continue;
513
+ if(s.workspace!==workspace||s.closing||s.inFlight>0||s.pendingEffects>0)continue;
491
514
  let alreadyIdle=false;
492
515
  for(const [tid,idleUrl] of s.idleUrls){
493
516
  try {if(sameIdleUrl(await targetUrl(browser,tid),idleUrl)){alreadyIdle=true;break;}}
@@ -503,15 +526,39 @@ async function stopSession(sid){
503
526
  const s=sessions.get(sid);
504
527
  if(!s)throw Error(`unknown session_id: ${sid}`);
505
528
  s.closing=true;
506
- // Do not hold the workspace allocation lock while an in-flight operation
507
- // might be acquiring a new tab: that would deadlock its Session mutex.
508
- await s.mutex.run(async()=>{});
509
- return leaseMutex(s.workspace).run(()=>closeSessionOwned(s));
529
+ // A timeout does not cancel a queued mutex callback. Fence this stop
530
+ // attempt before it can touch a Session whose close request failed.
531
+ const attempt={valid:true,releaseStarted:false};
532
+ const timeoutMs=Math.max(50,Math.min(90000,Number(process.env.WEBHELM_SESSION_STOP_TIMEOUT_MS)||60000));
533
+ const deadline=performance.now()+timeoutMs;
534
+ try {
535
+ await withinDeadline(s.mutex.run(async()=>{}),timeoutMs,'Session stop drain');
536
+ // Browser effects already dispatched by an expired VM may outlive its
537
+ // mutex. They must settle before an idle Tab can be reassigned.
538
+ while(s.pendingEffects>0){
539
+ const remaining=deadline-performance.now();
540
+ if(remaining<=0)throw new SessionDeadlineError('Session stop effects',timeoutMs);
541
+ await withinDeadline(delay(Math.min(25,remaining)),remaining,'Session stop effects');
542
+ }
543
+ return await withinDeadline(leaseMutex(s.workspace).run(async()=>{
544
+ if(!attempt.valid)throw new SessionDeadlineError('Cancelled Session stop',timeoutMs);
545
+ attempt.releaseStarted=true;
546
+ return closeSessionOwned(s);
547
+ }),Math.max(1,deadline-performance.now()),'Session stop release');
548
+ }catch(error){
549
+ attempt.valid=false;
550
+ if(sessions.get(sid)===s && !attempt.releaseStarted){
551
+ s.closing=false;s.poisoned=true;
552
+ }
553
+ // If physical close already started, keep closing=true until its own
554
+ // success/failure path finishes; otherwise GC could race its navigation.
555
+ throw error;
556
+ }
510
557
  }
511
558
 
512
559
  function heartbeatSession(sid){
513
560
  const s=sessions.get(sid);
514
- if(!s||s.closing)throw Error(`unknown session_id: ${sid} (missing or closing)`);
561
+ if(!s||s.closing||s.poisoned)throw Error(`unknown session_id: ${sid} (missing, closing or expired)`);
515
562
  s.last_used_at=Date.now();
516
563
  return {session_id:sid,alive:true,last_used_at:s.last_used_at};
517
564
  }
@@ -629,25 +676,31 @@ async function runPlatform(req) {
629
676
  const sid=started.session_id;
630
677
  if (!sid) throw Error('platform run requires a session_id or auto_session');
631
678
  const session=sessions.get(sid);
632
- if (!session) throw Error(`unknown session_id: ${sid}`);
679
+ if (!session || session.poisoned || session.closing) throw Error(`unknown session_id: ${sid}`);
633
680
  let result, cleanup;
634
681
  session.inFlight++;
635
682
  try {
636
683
  result=await session.mutex.run(async()=>{
684
+ if(session.poisoned||session.closing)throw Error('Session expired while waiting for platform action');
637
685
  const logs=[];
638
686
  const helpers=makeHelpers(session);
639
- helpers.enqueueChatGPTTurn=request=>getChatGPTQueue().enqueue(request);
640
- helpers.chatgptQueue=getChatGPTQueue();
687
+ helpers.enqueueChatGPTTurn=request=>{
688
+ if(session.poisoned||session.closing)throw Error('Session execution expired or closing');
689
+ return getChatGPTQueue().enqueue(request);
690
+ };
691
+ helpers.chatgptQueue={enqueue:helpers.enqueueChatGPTTurn};
641
692
  try {
642
- const value=await invokePlatformAction(platform,action,{
693
+ const actionTimeoutMs=Math.max(50,Math.min(300000,Number(process.env.WEBHELM_PLATFORM_TIMEOUT_MS)||180000));
694
+ const value=await withinDeadline(Promise.resolve().then(()=>invokePlatformAction(platform,action,{
643
695
  helpers, config,
644
696
  log:(...items)=>logs.push(items.map(v=>typeof v==='string'?v:JSON.stringify(v)).join(' ')),
645
- });
697
+ })),actionTimeoutMs,'Platform action');
646
698
  // Keep the legacy stdout JSON receipt for NeoY/Relay callers, while
647
699
  // retaining the structured `value` for new integrations. Do not print
648
700
  // a duplicate receipt if the Action already logged its JSON result.
649
701
  return {ok:true,stdout:actionOutput(logs,value),stderr:'',value};
650
702
  } catch(error) {
703
+ if(error instanceof SessionDeadlineError)session.poisoned=true;
651
704
  return {ok:false,stdout:logs.join('\n').slice(-40000),stderr:'',error:`${error.name}: ${error.message}`};
652
705
  }
653
706
  });
@@ -667,19 +720,27 @@ async function runPlatform(req) {
667
720
  }
668
721
 
669
722
  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'stop-workspace':{if(typeof req.workspace!=='string'||!req.workspace.trim())throw Error('workspace required');return stopWorkspaceSessions(req.workspace)}case'capacity':return workspaceCapacity(req);case'workspace-create':case'workspace-resize':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}`)}}
670
- function serve(conn) {
671
- let buffer = '';
723
+ export function serve(conn) {
724
+ let buffer = '', dispatched = false;
725
+ conn.on('error',()=>{ /* A disconnected client must not crash the daemon. */ });
672
726
  conn.setEncoding('utf8');
673
727
  conn.on('data', async (chunk) => {
728
+ if(dispatched)return;
674
729
  buffer += chunk;
730
+ if(buffer.length>8_000_000){
731
+ dispatched=true;
732
+ if(!conn.destroyed)conn.end(JSON.stringify({error:'WebHelm IPC request too large'})+'\n');
733
+ return;
734
+ }
675
735
  const newline = buffer.indexOf('\n');
676
- if (newline < 0) return;
736
+ if (newline < 0 || dispatched) return;
737
+ dispatched=true;
677
738
  const raw = buffer.slice(0, newline);
678
739
  try {
679
740
  const reply = await handle(JSON.parse(raw));
680
- conn.end(JSON.stringify(reply) + '\n');
741
+ if(!conn.destroyed)conn.end(JSON.stringify(reply) + '\n');
681
742
  } catch (error) {
682
- conn.end(JSON.stringify({ error: `${error.name}: ${error.message}` }) + '\n');
743
+ if(!conn.destroyed)conn.end(JSON.stringify({ error: `${error.name}: ${error.message}` }) + '\n');
683
744
  }
684
745
  });
685
746
  }
@@ -688,7 +749,47 @@ async function main(){fs.mkdirSync(RUNTIME,{recursive:true,mode:0o700});let lock
688
749
  const controlTimer=setInterval(()=>pollExtensionControl(),3000);
689
750
  pollExtensionControl().catch(()=>{});
690
751
  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)}
691
- 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)})}
752
+ // IPC duration is independent of the daemon's JS/Action execution timeout.
753
+ // A wedged daemon can no longer strand CLI processes indefinitely.
754
+ export function ipcTimeoutMs(op) {
755
+ const override=Number(process.env.WEBHELM_IPC_TIMEOUT_MS);
756
+ if(Number.isFinite(override) && override>0)
757
+ return Math.max(50,Math.min(300000,override));
758
+ return ({ping:5000,heartbeat:15000,list:15000,capacity:30000,
759
+ stop:90000,'stop-all':120000,'stop-workspace':120000,
760
+ exec:45000,'platform-run':240000,start:90000}[op] || 60000);
761
+ }
762
+ async function request(payload,ensure=true){
763
+ if(ensure)await ensureDaemon();
764
+ return new Promise((resolve,reject)=>{
765
+ const conn=net.createConnection(SOCKET);
766
+ const op=payload?.op||'unknown';
767
+ let data='',settled=false,deadlineTimer;
768
+ const finish=(error,result)=>{
769
+ if(settled)return;
770
+ settled=true;
771
+ clearTimeout(deadlineTimer);
772
+ conn.destroy();
773
+ if(error)reject(error);
774
+ else resolve(result);
775
+ };
776
+ conn.setEncoding('utf8');
777
+ deadlineTimer=setTimeout(()=>finish(Error('WebHelm IPC '+op+' timed out')),ipcTimeoutMs(op));
778
+ conn.on('connect',()=>conn.write(JSON.stringify(payload)+'\n'));
779
+ conn.on('data',chunk=>{
780
+ data+=chunk;
781
+ if(data.length>8_000_000)return finish(Error('WebHelm IPC response too large'));
782
+ const newline=data.indexOf('\n');
783
+ if(newline<0)return;
784
+ try{
785
+ const result=JSON.parse(data.slice(0,newline));
786
+ result.error?finish(Error(String(result.error))):finish(null,result);
787
+ }catch(error){finish(error)}
788
+ });
789
+ conn.on('end',()=>{if(!settled)finish(Error('WebHelm IPC '+op+' closed without response'))});
790
+ conn.on('error',error=>finish(error));
791
+ });
792
+ }
692
793
  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')}`)}
693
794
  if(process.argv[2]==='daemon')main().catch(e=>{console.error(e);process.exit(1)});
694
795
  export {request,SOCKET,RUNTIME};
@@ -1,9 +1,32 @@
1
1
  // A Session is live only while its owner performs operations or renews its heartbeat.
2
2
  export const SESSION_IDLE_MS = 5 * 60 * 1000;
3
3
 
4
+ export class SessionDeadlineError extends Error {
5
+ constructor(operation, milliseconds) {
6
+ super(operation + ' exceeded ' + milliseconds + 'ms deadline');
7
+ this.name = 'SessionDeadlineError';
8
+ }
9
+ }
10
+
11
+ // A VM timeout cannot terminate an unresolved async Promise.
12
+ // Callers must revoke Session helpers when a deadline expires.
13
+ export async function withinDeadline(promise, milliseconds, operation) {
14
+ let timer;
15
+ try {
16
+ return await Promise.race([
17
+ promise,
18
+ new Promise((_, reject) => {
19
+ timer = setTimeout(() => reject(new SessionDeadlineError(operation, milliseconds)), milliseconds);
20
+ }),
21
+ ]);
22
+ } finally {
23
+ clearTimeout(timer);
24
+ }
25
+ }
26
+
4
27
  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;
28
+ return !session.closing && !(session.inFlight > 0) && !(session.pendingEffects > 0) &&
29
+ (session.poisoned === true || now - session.last_used_at >= ttl);
7
30
  }
8
31
 
9
32
  export function idleUrlFromIdentity(identityUrl) {