@reticlehq/server 3.8.0 → 3.8.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/SKILL.md +2 -2
- package/dist/command/cli/answers/gate-hook-message.d.ts +2 -0
- package/dist/command/cli/answers/gate-hook-message.js +5 -1
- package/dist/command/cli/auth/cloud-session.d.ts +1 -19
- package/dist/command/cli/auth/device-flow.d.ts +3 -37
- package/dist/command/cli/cli-doctor.js +30 -23
- package/dist/command/cli/cli-flow-commands.js +7 -1
- package/dist/command/cli/cli-parse-serve.d.ts +24 -0
- package/dist/command/cli/cli-parse-serve.js +124 -0
- package/dist/command/cli/cli-parse.d.ts +1 -10
- package/dist/command/cli/cli-parse.js +62 -121
- package/dist/command/cli/cli-usage.js +5 -2
- package/dist/command/cli/cloud-link.d.ts +1 -20
- package/dist/command/cli/doctor/doctor-agent-link.d.ts +23 -0
- package/dist/command/cli/doctor/doctor-agent-link.js +28 -0
- package/dist/command/cli/ports/port-holder.js +6 -1
- package/dist/command/cli/project-name.d.ts +1 -7
- package/dist/command/cli/project-name.js +1 -0
- package/dist/command/daemon/daemon.d.ts +6 -0
- package/dist/command/daemon/daemon.js +52 -2
- package/dist/command/daemon/win32-escape.d.ts +32 -0
- package/dist/command/daemon/win32-escape.js +119 -0
- package/dist/command/setup/bringup/dev-server-files.d.ts +1 -9
- package/dist/command/setup/bringup/dev-server-wait.js +5 -1
- package/dist/command/version/version-skew.d.ts +2 -0
- package/dist/command/version/version-skew.js +7 -3
- package/dist/features/crawl/crawl-tools.js +2 -2
- package/dist/features/exhaust/ledger.d.ts +15 -96
- package/dist/features/exhaust/ledger.js +3 -3
- package/dist/features/license/license.d.ts +2 -14
- package/dist/hooks/hook-commands.js +19 -9
- package/dist/judgement/capsule/capsule-store.d.ts +2 -39
- package/dist/language/flows/annotate-tools.js +1 -1
- package/dist/language/flows/fields/flow-secret-field.js +1 -1
- package/dist/language/flows/flow-expect-grammar.js +14 -6
- package/dist/language/flows/flow-lease.d.ts +27 -0
- package/dist/language/flows/flow-lease.js +59 -0
- package/dist/language/flows/flow-replay-run.d.ts +1 -87
- package/dist/language/flows/flow-replay-run.js +88 -283
- package/dist/language/flows/flow-replayable.d.ts +1 -1
- package/dist/language/flows/flow-start-path.d.ts +108 -0
- package/dist/language/flows/flow-start-path.js +313 -0
- package/dist/language/flows/flow-tools.d.ts +2 -23
- package/dist/language/flows/flow-tools.js +34 -66
- package/dist/language/flows/recording/flake.d.ts +3 -27
- package/dist/language/flows/recording/flake.js +1 -1
- package/dist/language/flows/stores/assertion-tiers-store.js +1 -1
- package/dist/machine/git-tracks.js +1 -0
- package/dist/memory/cloud/cloud-sync.d.ts +2 -24
- package/dist/memory/intent/intent-shard.d.ts +41 -266
- package/dist/memory/journal/envelope-store.js +1 -1
- package/dist/memory/journal/envelope.d.ts +6 -154
- package/dist/memory/journal/on-disk/ambient-file.d.ts +1 -7
- package/dist/memory/journal/on-disk/ambient-file.js +1 -1
- package/dist/memory/journal/self-observation.js +3 -1
- package/dist/memory/journal/session-journal.js +39 -20
- package/dist/portal/input/drive-error.d.ts +6 -0
- package/dist/portal/input/drive-error.js +9 -0
- package/dist/portal/input/real-input-press.d.ts +34 -0
- package/dist/portal/input/real-input-press.js +150 -0
- package/dist/portal/input/real-input.d.ts +6 -23
- package/dist/portal/input/real-input.js +95 -192
- package/dist/portal/input/viewport-tools.d.ts +19 -15
- package/dist/portal/pool/browser-pool.d.ts +5 -55
- package/dist/portal/pool/browser-pool.js +121 -100
- package/dist/portal/pool/pool-lease.d.ts +128 -0
- package/dist/portal/pool/pool-lease.js +49 -0
- package/dist/portal/production/production-activation.d.ts +5 -27
- package/dist/portal/session/control-envelope.d.ts +1 -1
- package/dist/portal/session/zero-install-first-call.d.ts +3 -0
- package/dist/portal/session/zero-install-first-call.js +33 -12
- package/dist/surface/mcp/mcp.d.ts +3 -3
- package/dist/surface/mcp/mcp.js +4 -6
- package/dist/surface/tools/act/act-danger.js +32 -17
- package/dist/surface/tools/act/durable.js +1 -1
- package/dist/surface/tools/act-sequence-tool.js +3 -3
- package/dist/surface/tools/act-tools.js +15 -16
- package/dist/surface/tools/agent/compact-envelope.d.ts +2 -2
- package/dist/surface/tools/coverage-tools.js +1 -1
- package/dist/surface/tools/dynamic-tools.d.ts +8 -0
- package/dist/surface/tools/dynamic-tools.js +36 -16
- package/dist/surface/tools/empty-session-list.d.ts +5 -29
- package/dist/surface/tools/feedback-tools.js +1 -1
- package/dist/surface/tools/gap-novelty.d.ts +1 -1
- package/dist/surface/tools/gaps/capability-absences.js +4 -3
- package/dist/surface/tools/harness-gate.d.ts +1 -1
- package/dist/surface/tools/harness-toolset.js +3 -4
- package/dist/surface/tools/lease-permissions.d.ts +1 -1
- package/dist/surface/tools/lease-readiness.d.ts +23 -1
- package/dist/surface/tools/lease-readiness.js +75 -9
- package/dist/surface/tools/lease-tools.d.ts +1 -52
- package/dist/surface/tools/lease-tools.js +79 -142
- package/dist/surface/tools/observe-tools.js +3 -1
- package/dist/surface/tools/read-tools.js +5 -5
- package/dist/surface/tools/real-input-attempt.js +70 -31
- package/dist/surface/tools/session-ports.d.ts +57 -0
- package/dist/surface/tools/session-ports.js +102 -0
- package/dist/surface/tools/tool-kit.d.ts +5 -3
- package/dist/surface/tools/tools.js +1 -1
- package/dist/telemetry/daemon-telemetry.d.ts +3 -1
- package/dist/telemetry/daemon-telemetry.js +9 -2
- package/dist/telemetry/telemetry.d.ts +3 -1
- package/dist/telemetry/telemetry.js +2 -0
- package/docs/agent-cheatsheet.md +12 -0
- package/docs/cli/gate.mdx +1 -1
- package/docs/cli/verify.mdx +8 -1
- package/docs/first-drive.md +7 -0
- package/docs/multi-agent-testing.md +2 -0
- package/docs/packages/react.mdx +14 -2
- package/docs/packages/vite-plugin.mdx +1 -0
- package/docs/predicates.mdx +6 -0
- package/docs/skill.md +3 -3
- package/docs/troubleshooting.mdx +10 -0
- package/docs/usage.md +1 -1
- package/package.json +8 -9
|
@@ -17,43 +17,8 @@ import { installLeaseMarker, leaseMarkerOf } from './lease-marker-stamp.js';
|
|
|
17
17
|
import { grantLeasePermissions, readNotificationPermission } from './context-permissions.js';
|
|
18
18
|
import { readViewport } from './lease-emulation.js';
|
|
19
19
|
import { NotHtmlDocumentError, READER_NOT_HTML } from './zero-install.js';
|
|
20
|
-
|
|
21
|
-
export
|
|
22
|
-
/**
|
|
23
|
-
* How long a lease outlives the MCP connection that took it. Long enough for an agent whose
|
|
24
|
-
* connection only blipped to touch it again; short enough that a gone agent's window closes soon.
|
|
25
|
-
*/
|
|
26
|
-
export const ORPHANED_LEASE_GRACE_MS = 30_000;
|
|
27
|
-
/** Default cap on how long a lease's initial navigation may take before it fails (frees the slot). */
|
|
28
|
-
const DEFAULT_NAV_TIMEOUT_MS = 30_000;
|
|
29
|
-
/**
|
|
30
|
-
* How many reaped lease ids to remember. Only the most recent departure is ever asked about, so
|
|
31
|
-
* this is slack for a burst sweep, not a history.
|
|
32
|
-
*/
|
|
33
|
-
const MAX_REMEMBERED_REAPED_LEASES = 64;
|
|
34
|
-
/** What a headed lease is refused with where no window can be opened. */
|
|
35
|
-
export const NO_HEADED_BROWSER = 'no headed browser can be opened here';
|
|
36
|
-
/**
|
|
37
|
-
* An acquire that asked to be refused rather than queued found every slot taken (#1535). Queued,
|
|
38
|
-
* it waited behind the cap with no deadline and no status, and `reticle drive` reported the 60 s
|
|
39
|
-
* timeout as a daemon shutting down. The message names the cap and the one override for it.
|
|
40
|
-
*/
|
|
41
|
-
export class PoolAtCapacityError extends Error {
|
|
42
|
-
max;
|
|
43
|
-
constructor(max) {
|
|
44
|
-
super(`all ${String(max)} lease slots are in use. Release one (reticle_lease{action:"release"}, or ` +
|
|
45
|
-
'stop a `reticle drive`), or raise RETICLE_MAX_CONTEXTS.');
|
|
46
|
-
this.max = max;
|
|
47
|
-
this.name = 'PoolAtCapacityError';
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
/**
|
|
51
|
-
* What an acquire that asked for an init script is refused with when the page cannot run one.
|
|
52
|
-
*
|
|
53
|
-
* A refusal rather than a lease: the caller asked for a stub of the bindings a desktop shell
|
|
54
|
-
* injects, and a tab opened without it would throw in the app's view and read as a broken app.
|
|
55
|
-
*/
|
|
56
|
-
const INIT_SCRIPT_UNSUPPORTED = 'Init script failed: this page does not support init scripts, so the requested stub would not be there';
|
|
20
|
+
import { DEFAULT_LEASE_TTL_MS, DEFAULT_NAV_TIMEOUT_MS, INIT_SCRIPT_UNSUPPORTED, MAX_REMEMBERED_REAPED_LEASES, NO_HEADED_BROWSER, ORPHANED_LEASE_GRACE_MS, pageLoading, PoolAtCapacityError, } from './pool-lease.js';
|
|
21
|
+
export { DEFAULT_LEASE_TTL_MS, NO_HEADED_BROWSER, ORPHANED_LEASE_GRACE_MS, PoolAtCapacityError, } from './pool-lease.js';
|
|
57
22
|
/**
|
|
58
23
|
* Owns a headless browser (and, when asked, a headed one) and leases isolated contexts out of them,
|
|
59
24
|
* capped at `maxContexts` across both.
|
|
@@ -97,8 +62,9 @@ export class BrowserPool {
|
|
|
97
62
|
/** FIFO of acquires waiting for a slot; each resolves when one frees. */
|
|
98
63
|
#waiters = [];
|
|
99
64
|
constructor(launch, opts) {
|
|
100
|
-
if (opts.maxContexts < 1)
|
|
65
|
+
if (opts.maxContexts < 1) {
|
|
101
66
|
throw new Error('maxContexts must be >= 1');
|
|
67
|
+
}
|
|
102
68
|
this.#headless = { launch, headed: false };
|
|
103
69
|
this.#headed = { launch: opts.launchHeaded, headed: true };
|
|
104
70
|
this.#max = opts.maxContexts;
|
|
@@ -139,11 +105,13 @@ export class BrowserPool {
|
|
|
139
105
|
* Prefers the alias the agent was given, when the app registered under its own name.
|
|
140
106
|
*/
|
|
141
107
|
leaseIdOnOrigin(origin, owner) {
|
|
142
|
-
if (owner === undefined)
|
|
108
|
+
if (owner === undefined) {
|
|
143
109
|
return undefined;
|
|
110
|
+
}
|
|
144
111
|
for (const [leaseId, lease] of this.#active) {
|
|
145
|
-
if (lease.owner !== owner)
|
|
112
|
+
if (lease.owner !== owner) {
|
|
146
113
|
continue;
|
|
114
|
+
}
|
|
147
115
|
let leaseOrigin;
|
|
148
116
|
try {
|
|
149
117
|
leaseOrigin = new URL(lease.url).origin;
|
|
@@ -151,11 +119,13 @@ export class BrowserPool {
|
|
|
151
119
|
catch {
|
|
152
120
|
continue;
|
|
153
121
|
}
|
|
154
|
-
if (leaseOrigin !== origin)
|
|
122
|
+
if (leaseOrigin !== origin) {
|
|
155
123
|
continue;
|
|
124
|
+
}
|
|
156
125
|
for (const [alias, id] of this.#aliases) {
|
|
157
|
-
if (id === leaseId)
|
|
126
|
+
if (id === leaseId) {
|
|
158
127
|
return alias;
|
|
128
|
+
}
|
|
159
129
|
}
|
|
160
130
|
return leaseId;
|
|
161
131
|
}
|
|
@@ -184,10 +154,12 @@ export class BrowserPool {
|
|
|
184
154
|
* that would have read a computed value.
|
|
185
155
|
*/
|
|
186
156
|
alias(registeredId, leaseId) {
|
|
187
|
-
if (registeredId === leaseId)
|
|
157
|
+
if (registeredId === leaseId) {
|
|
188
158
|
return;
|
|
189
|
-
|
|
159
|
+
}
|
|
160
|
+
if (!this.#active.has(leaseId)) {
|
|
190
161
|
return;
|
|
162
|
+
}
|
|
191
163
|
this.#aliases.set(registeredId, leaseId);
|
|
192
164
|
}
|
|
193
165
|
/**
|
|
@@ -225,8 +197,9 @@ export class BrowserPool {
|
|
|
225
197
|
/** Mark a lease as still in use (called on agent activity) so the reaper doesn't reclaim it. */
|
|
226
198
|
touch(sessionId) {
|
|
227
199
|
const lease = this.#active.get(this.#leaseIdOf(sessionId));
|
|
228
|
-
if (lease === undefined)
|
|
200
|
+
if (lease === undefined) {
|
|
229
201
|
return;
|
|
202
|
+
}
|
|
230
203
|
lease.touchedAt = this.#now();
|
|
231
204
|
delete lease.detachedAt;
|
|
232
205
|
}
|
|
@@ -237,9 +210,11 @@ export class BrowserPool {
|
|
|
237
210
|
*/
|
|
238
211
|
detachOwner(owner) {
|
|
239
212
|
const now = this.#now();
|
|
240
|
-
for (const lease of this.#active.values())
|
|
241
|
-
if (lease.owner === owner)
|
|
213
|
+
for (const lease of this.#active.values()) {
|
|
214
|
+
if (lease.owner === owner) {
|
|
242
215
|
lease.detachedAt = now;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
243
218
|
}
|
|
244
219
|
/**
|
|
245
220
|
* The MCP connection `owner` came back: a proxy that reconnects keeps its id. Its leases stop
|
|
@@ -268,8 +243,9 @@ export class BrowserPool {
|
|
|
268
243
|
*/
|
|
269
244
|
async screenshotLease(sessionId, opts = {}) {
|
|
270
245
|
const lease = this.#active.get(this.#leaseIdOf(sessionId));
|
|
271
|
-
if (lease === undefined || lease.page.screenshot === undefined)
|
|
246
|
+
if (lease === undefined || lease.page.screenshot === undefined) {
|
|
272
247
|
return undefined;
|
|
248
|
+
}
|
|
273
249
|
this.touch(sessionId);
|
|
274
250
|
try {
|
|
275
251
|
return await lease.page.screenshot(opts);
|
|
@@ -291,8 +267,9 @@ export class BrowserPool {
|
|
|
291
267
|
*/
|
|
292
268
|
async gotoLease(sessionId, url, timeoutMs) {
|
|
293
269
|
const lease = this.#active.get(this.#leaseIdOf(sessionId));
|
|
294
|
-
if (lease === undefined)
|
|
270
|
+
if (lease === undefined) {
|
|
295
271
|
return false;
|
|
272
|
+
}
|
|
296
273
|
this.touch(sessionId);
|
|
297
274
|
try {
|
|
298
275
|
await lease.page.goto(url, timeoutMs === undefined ? {} : { timeoutMs });
|
|
@@ -311,8 +288,9 @@ export class BrowserPool {
|
|
|
311
288
|
*/
|
|
312
289
|
async hoverLease(sessionId, x, y) {
|
|
313
290
|
const lease = this.#active.get(this.#leaseIdOf(sessionId));
|
|
314
|
-
if (lease === undefined || lease.page.hover === undefined)
|
|
291
|
+
if (lease === undefined || lease.page.hover === undefined) {
|
|
315
292
|
return false;
|
|
293
|
+
}
|
|
316
294
|
this.touch(sessionId);
|
|
317
295
|
try {
|
|
318
296
|
await lease.page.hover(x, y);
|
|
@@ -325,8 +303,9 @@ export class BrowserPool {
|
|
|
325
303
|
/** Set a live lease's media features, or false when this is not a lease or its page cannot. */
|
|
326
304
|
async emulateLease(sessionId, media) {
|
|
327
305
|
const lease = this.#active.get(this.#leaseIdOf(sessionId));
|
|
328
|
-
if (lease?.page.emulateMedia === undefined)
|
|
306
|
+
if (lease?.page.emulateMedia === undefined) {
|
|
329
307
|
return false;
|
|
308
|
+
}
|
|
330
309
|
this.touch(sessionId);
|
|
331
310
|
try {
|
|
332
311
|
await lease.page.emulateMedia(media);
|
|
@@ -336,6 +315,14 @@ export class BrowserPool {
|
|
|
336
315
|
return false;
|
|
337
316
|
}
|
|
338
317
|
}
|
|
318
|
+
/** Whether this lease's page is still loading. False for a non-lease, or a page that cannot say. */
|
|
319
|
+
async leaseLoading(sessionId) {
|
|
320
|
+
const lease = this.#active.get(this.#leaseIdOf(sessionId));
|
|
321
|
+
if (lease === undefined) {
|
|
322
|
+
return false;
|
|
323
|
+
}
|
|
324
|
+
return pageLoading(lease.page).catch(() => false);
|
|
325
|
+
}
|
|
339
326
|
/** Whether this session is a lease whose page has a real keyboard. */
|
|
340
327
|
hasKeyboard(sessionId) {
|
|
341
328
|
return this.#active.get(this.#leaseIdOf(sessionId))?.page.press !== undefined;
|
|
@@ -347,8 +334,9 @@ export class BrowserPool {
|
|
|
347
334
|
*/
|
|
348
335
|
async pressLease(sessionId, args) {
|
|
349
336
|
const lease = this.#active.get(this.#leaseIdOf(sessionId));
|
|
350
|
-
if (lease?.page.press === undefined)
|
|
337
|
+
if (lease?.page.press === undefined) {
|
|
351
338
|
return undefined;
|
|
339
|
+
}
|
|
352
340
|
this.touch(sessionId);
|
|
353
341
|
return lease.page.press(args);
|
|
354
342
|
}
|
|
@@ -368,8 +356,9 @@ export class BrowserPool {
|
|
|
368
356
|
*/
|
|
369
357
|
async setViewportLease(sessionId, size) {
|
|
370
358
|
const lease = this.#active.get(this.#leaseIdOf(sessionId));
|
|
371
|
-
if (lease === undefined || lease.page.setViewport === undefined)
|
|
359
|
+
if (lease === undefined || lease.page.setViewport === undefined) {
|
|
372
360
|
return false;
|
|
361
|
+
}
|
|
373
362
|
this.touch(sessionId);
|
|
374
363
|
try {
|
|
375
364
|
await lease.page.setViewport(size);
|
|
@@ -386,15 +375,17 @@ export class BrowserPool {
|
|
|
386
375
|
}
|
|
387
376
|
async setMocksLease(sessionId, rules) {
|
|
388
377
|
const lease = this.#active.get(this.#leaseIdOf(sessionId));
|
|
389
|
-
if (lease === undefined || lease.page.installMocks === undefined)
|
|
378
|
+
if (lease === undefined || lease.page.installMocks === undefined) {
|
|
390
379
|
return false;
|
|
380
|
+
}
|
|
391
381
|
this.touch(sessionId);
|
|
392
382
|
try {
|
|
393
383
|
await lease.page.installMocks(rules);
|
|
394
384
|
// Mocks registered later are asked first, and one that lets a request through would skip
|
|
395
385
|
// the production guard. Re-asserting the guard puts it back in front.
|
|
396
|
-
if (lease.guard !== undefined)
|
|
386
|
+
if (lease.guard !== undefined) {
|
|
397
387
|
await lease.page.guardRequests?.(lease.guard);
|
|
388
|
+
}
|
|
398
389
|
return true;
|
|
399
390
|
}
|
|
400
391
|
catch {
|
|
@@ -404,8 +395,9 @@ export class BrowserPool {
|
|
|
404
395
|
/** Guard a leased page's requests, or false when this is not a lease or its page cannot. */
|
|
405
396
|
async guardLease(sessionId, decide) {
|
|
406
397
|
const lease = this.#active.get(this.#leaseIdOf(sessionId));
|
|
407
|
-
if (lease === undefined || lease.page.guardRequests === undefined)
|
|
398
|
+
if (lease === undefined || lease.page.guardRequests === undefined) {
|
|
408
399
|
return false;
|
|
400
|
+
}
|
|
409
401
|
await lease.page.guardRequests(decide);
|
|
410
402
|
lease.guard = decide;
|
|
411
403
|
return true;
|
|
@@ -447,13 +439,15 @@ export class BrowserPool {
|
|
|
447
439
|
.filter(([, lease]) => now - lease.touchedAt > this.#ttl ||
|
|
448
440
|
(lease.detachedAt !== undefined && now - lease.detachedAt >= ORPHANED_LEASE_GRACE_MS))
|
|
449
441
|
.map(([sessionId]) => sessionId);
|
|
450
|
-
for (const sessionId of expired)
|
|
442
|
+
for (const sessionId of expired) {
|
|
451
443
|
await this.#release(sessionId);
|
|
444
|
+
}
|
|
452
445
|
this.#reapedLeases += expired.length;
|
|
453
446
|
for (const sessionId of expired) {
|
|
454
447
|
this.#reapedLeaseIds.push(sessionId);
|
|
455
|
-
if (this.#reapedLeaseIds.length > MAX_REMEMBERED_REAPED_LEASES)
|
|
448
|
+
if (this.#reapedLeaseIds.length > MAX_REMEMBERED_REAPED_LEASES) {
|
|
456
449
|
this.#reapedLeaseIds.shift();
|
|
450
|
+
}
|
|
457
451
|
}
|
|
458
452
|
return expired;
|
|
459
453
|
}
|
|
@@ -489,18 +483,21 @@ export class BrowserPool {
|
|
|
489
483
|
* `'refuse'`: then it throws {@link PoolAtCapacityError} at once and claims nothing.
|
|
490
484
|
*/
|
|
491
485
|
async acquire(url, opts = {}) {
|
|
492
|
-
if (this.#closed)
|
|
486
|
+
if (this.#closed) {
|
|
493
487
|
throw new Error('browser pool is shut down');
|
|
488
|
+
}
|
|
494
489
|
const slot = true === opts.headed ? this.#headed : this.#headless;
|
|
495
490
|
// Refused before a slot is claimed, so a refusal never holds one.
|
|
496
|
-
if (slot.launch === undefined)
|
|
491
|
+
if (slot.launch === undefined) {
|
|
497
492
|
throw new Error(NO_HEADED_BROWSER);
|
|
493
|
+
}
|
|
498
494
|
// #waitForSlot claims the slot synchronously (bumps #occupied) before returning, so the cap holds
|
|
499
495
|
// even when many acquires race through the gate in the same tick. A refusing acquire claims the
|
|
500
496
|
// same way, or claims nothing and says so.
|
|
501
497
|
if ('refuse' === opts.whenFull) {
|
|
502
|
-
if (!this.#tryClaim())
|
|
498
|
+
if (!this.#tryClaim()) {
|
|
503
499
|
throw new PoolAtCapacityError(this.#max);
|
|
500
|
+
}
|
|
504
501
|
}
|
|
505
502
|
else {
|
|
506
503
|
await this.#waitForSlot(opts.signal);
|
|
@@ -516,8 +513,9 @@ export class BrowserPool {
|
|
|
516
513
|
...opts.emulate,
|
|
517
514
|
};
|
|
518
515
|
context = await browser.newContext(0 < Object.keys(contextOpts).length ? contextOpts : undefined);
|
|
519
|
-
if (opts.permissions !== undefined)
|
|
516
|
+
if (opts.permissions !== undefined) {
|
|
520
517
|
await grantLeasePermissions(context, opts.permissions, url);
|
|
518
|
+
}
|
|
521
519
|
const page = await context.newPage();
|
|
522
520
|
// A caller-supplied init script is a stub of the bindings a desktop shell injects, and it has
|
|
523
521
|
// to exist before the app's first module runs. Installed here — before goto — so the very
|
|
@@ -540,23 +538,28 @@ export class BrowserPool {
|
|
|
540
538
|
// retry backoff stretches out.
|
|
541
539
|
page.onConsole?.((text) => {
|
|
542
540
|
const dialled = unreachableUrlIn(text);
|
|
543
|
-
if (dialled === undefined)
|
|
541
|
+
if (dialled === undefined) {
|
|
544
542
|
return;
|
|
543
|
+
}
|
|
545
544
|
const active = this.#active.get(sessionId);
|
|
546
|
-
if (active !== undefined)
|
|
545
|
+
if (active !== undefined) {
|
|
547
546
|
active.dialFailureUrl = dialled;
|
|
548
|
-
|
|
549
|
-
|
|
547
|
+
}
|
|
548
|
+
else {
|
|
549
|
+
pending = dialled;
|
|
550
|
+
} // logged during goto, before the lease is registered below
|
|
550
551
|
});
|
|
551
552
|
// Same "listen before goto" reasoning as onConsole above: a dialog opened during the initial
|
|
552
553
|
// navigation (an app that confirms something in an onload handler) must still be dismissed, not
|
|
553
554
|
// just ones opened after the lease is registered.
|
|
554
555
|
page.onDialog?.((dialog) => {
|
|
555
556
|
const active = this.#active.get(sessionId);
|
|
556
|
-
if (active !== undefined)
|
|
557
|
+
if (active !== undefined) {
|
|
557
558
|
active.lastDialogMessage = dialog.message;
|
|
558
|
-
|
|
559
|
+
}
|
|
560
|
+
else {
|
|
559
561
|
pendingDialogMessage = dialog.message;
|
|
562
|
+
}
|
|
560
563
|
void dialog.dismiss();
|
|
561
564
|
});
|
|
562
565
|
const marker = leaseMarkerOf(url);
|
|
@@ -584,8 +587,9 @@ export class BrowserPool {
|
|
|
584
587
|
// #occupied. Registering the lease now would resurrect a dead entry against a crashed browser with
|
|
585
588
|
// the slot count out of sync (drifting below #active.size, eventually exceeding the cap). If we're
|
|
586
589
|
// no longer the live browser, bail — the catch below closes the context and returns the slot.
|
|
587
|
-
if (slot.browser !== browser)
|
|
590
|
+
if (slot.browser !== browser) {
|
|
588
591
|
throw new Error('browser crashed during navigation');
|
|
592
|
+
}
|
|
589
593
|
// Foreground it: a page opened beside other tabs can start hidden (#1351). Best-effort — a tab
|
|
590
594
|
// that stays hidden is reported by name at the lease, which failing here would lose.
|
|
591
595
|
await page.bringToFront?.().catch(() => undefined);
|
|
@@ -595,8 +599,9 @@ export class BrowserPool {
|
|
|
595
599
|
if ('function' === typeof s) {
|
|
596
600
|
try {
|
|
597
601
|
const code = s.call(navRes);
|
|
598
|
-
if ('number' === typeof code)
|
|
602
|
+
if ('number' === typeof code) {
|
|
599
603
|
navStatus = code;
|
|
604
|
+
}
|
|
600
605
|
}
|
|
601
606
|
catch {
|
|
602
607
|
// ignore
|
|
@@ -628,26 +633,24 @@ export class BrowserPool {
|
|
|
628
633
|
release: () => this.#release(sessionId),
|
|
629
634
|
injectReader: async () => {
|
|
630
635
|
const script = this.#zeroInstall?.();
|
|
631
|
-
if (script === undefined || page.evaluate === undefined)
|
|
636
|
+
if (script === undefined || page.evaluate === undefined) {
|
|
632
637
|
return false;
|
|
638
|
+
}
|
|
633
639
|
await page.addInitScript?.(script);
|
|
634
|
-
if (READER_NOT_HTML === (await page.evaluate(script)))
|
|
640
|
+
if (READER_NOT_HTML === (await page.evaluate(script))) {
|
|
635
641
|
throw new NotHtmlDocumentError();
|
|
642
|
+
}
|
|
636
643
|
return true;
|
|
637
644
|
},
|
|
638
|
-
isLoading:
|
|
639
|
-
if (page.evaluate === undefined)
|
|
640
|
-
return false;
|
|
641
|
-
const state = await page.evaluate('document.readyState');
|
|
642
|
-
return 'string' === typeof state && state !== 'complete';
|
|
643
|
-
},
|
|
645
|
+
isLoading: () => pageLoading(page),
|
|
644
646
|
};
|
|
645
647
|
}
|
|
646
648
|
catch (err) {
|
|
647
649
|
// Setup failed after we claimed the slot — give it back so a queued acquire isn't stuck, and
|
|
648
650
|
// close the half-open context.
|
|
649
|
-
if (context !== undefined)
|
|
651
|
+
if (context !== undefined) {
|
|
650
652
|
await settledWithin(context.close(), LEASE_CLOSE_TIMEOUT_MS);
|
|
653
|
+
}
|
|
651
654
|
this.#releaseSlot();
|
|
652
655
|
throw err;
|
|
653
656
|
}
|
|
@@ -686,22 +689,26 @@ export class BrowserPool {
|
|
|
686
689
|
}
|
|
687
690
|
const browser = slot.browser;
|
|
688
691
|
slot.browser = undefined;
|
|
689
|
-
if (browser !== undefined)
|
|
692
|
+
if (browser !== undefined) {
|
|
690
693
|
await browser.close().catch(() => undefined);
|
|
694
|
+
}
|
|
691
695
|
}
|
|
692
|
-
for (const waiter of this.#waiters.splice(0))
|
|
696
|
+
for (const waiter of this.#waiters.splice(0)) {
|
|
693
697
|
waiter();
|
|
698
|
+
}
|
|
694
699
|
}
|
|
695
700
|
async #release(sessionId) {
|
|
696
701
|
const lease = this.#active.get(sessionId);
|
|
697
|
-
if (lease === undefined)
|
|
698
|
-
return;
|
|
702
|
+
if (lease === undefined) {
|
|
703
|
+
return;
|
|
704
|
+
} // already released or lost to a crash
|
|
699
705
|
this.#active.delete(sessionId);
|
|
700
706
|
// Drop any alias pointing here, or the map grows for the life of the daemon and a later lease
|
|
701
707
|
// reusing that registered name would resolve to a context that is already closed.
|
|
702
708
|
for (const [registered, leaseId] of this.#aliases) {
|
|
703
|
-
if (leaseId === sessionId)
|
|
709
|
+
if (leaseId === sessionId) {
|
|
704
710
|
this.#aliases.delete(registered);
|
|
711
|
+
}
|
|
705
712
|
}
|
|
706
713
|
// Bounded: a close that never returned held the slot and kept the tab open after the agent left.
|
|
707
714
|
const closed = await closeLeaseTab(lease, LEASE_CLOSE_TIMEOUT_MS);
|
|
@@ -730,12 +737,14 @@ export class BrowserPool {
|
|
|
730
737
|
* nothing occupied means no acquire is between launching a browser and using it.
|
|
731
738
|
*/
|
|
732
739
|
async #closeIfIdle() {
|
|
733
|
-
if (this.#active.size > 0 || this.#occupied > 0 || this.#waiters.length > 0)
|
|
740
|
+
if (this.#active.size > 0 || this.#occupied > 0 || this.#waiters.length > 0) {
|
|
734
741
|
return;
|
|
742
|
+
}
|
|
735
743
|
for (const slot of [this.#headless, this.#headed]) {
|
|
736
744
|
const browser = slot.browser;
|
|
737
|
-
if (browser === undefined || slot.launching !== undefined)
|
|
745
|
+
if (browser === undefined || slot.launching !== undefined) {
|
|
738
746
|
continue;
|
|
747
|
+
}
|
|
739
748
|
slot.browser = undefined;
|
|
740
749
|
await settledWithin(browser.close(), LEASE_CLOSE_TIMEOUT_MS);
|
|
741
750
|
}
|
|
@@ -749,21 +758,24 @@ export class BrowserPool {
|
|
|
749
758
|
return false;
|
|
750
759
|
}
|
|
751
760
|
#waitForSlot(signal) {
|
|
752
|
-
if (this.#tryClaim())
|
|
761
|
+
if (this.#tryClaim()) {
|
|
753
762
|
return Promise.resolve();
|
|
763
|
+
}
|
|
754
764
|
return new Promise((resolve, reject) => {
|
|
755
765
|
const onFree = () => {
|
|
756
766
|
// The pool shut down while we waited → reject rather than relaunch a browser.
|
|
757
767
|
if (this.#closed) {
|
|
758
|
-
if (signal !== undefined)
|
|
768
|
+
if (signal !== undefined) {
|
|
759
769
|
signal.removeEventListener('abort', onAbort);
|
|
770
|
+
}
|
|
760
771
|
reject(new Error('browser pool is shut down'));
|
|
761
772
|
return;
|
|
762
773
|
}
|
|
763
774
|
// Claim the freed slot; if another woken waiter beat us to it, re-queue.
|
|
764
775
|
if (this.#tryClaim()) {
|
|
765
|
-
if (signal !== undefined)
|
|
776
|
+
if (signal !== undefined) {
|
|
766
777
|
signal.removeEventListener('abort', onAbort);
|
|
778
|
+
}
|
|
767
779
|
resolve();
|
|
768
780
|
}
|
|
769
781
|
else {
|
|
@@ -772,8 +784,9 @@ export class BrowserPool {
|
|
|
772
784
|
};
|
|
773
785
|
const onAbort = () => {
|
|
774
786
|
const i = this.#waiters.indexOf(onFree);
|
|
775
|
-
if (i >= 0)
|
|
787
|
+
if (i >= 0) {
|
|
776
788
|
this.#waiters.splice(i, 1);
|
|
789
|
+
}
|
|
777
790
|
reject(new Error('acquire aborted'));
|
|
778
791
|
};
|
|
779
792
|
if (signal !== undefined) {
|
|
@@ -788,20 +801,25 @@ export class BrowserPool {
|
|
|
788
801
|
}
|
|
789
802
|
/** Give back one occupied slot and hand it to the next waiter (which re-claims it synchronously). */
|
|
790
803
|
#releaseSlot() {
|
|
791
|
-
if (this.#occupied > 0)
|
|
804
|
+
if (this.#occupied > 0) {
|
|
792
805
|
this.#occupied -= 1;
|
|
806
|
+
}
|
|
793
807
|
const next = this.#waiters.shift();
|
|
794
|
-
if (next !== undefined)
|
|
808
|
+
if (next !== undefined) {
|
|
795
809
|
next();
|
|
810
|
+
}
|
|
796
811
|
}
|
|
797
812
|
async #ensureBrowser(slot) {
|
|
798
|
-
if (this.#closed)
|
|
813
|
+
if (this.#closed) {
|
|
799
814
|
throw new Error('browser pool is shut down');
|
|
800
|
-
|
|
815
|
+
}
|
|
816
|
+
if (slot.browser !== undefined && slot.browser.isConnected()) {
|
|
801
817
|
return slot.browser;
|
|
818
|
+
}
|
|
802
819
|
const launch = slot.launch;
|
|
803
|
-
if (launch === undefined)
|
|
820
|
+
if (launch === undefined) {
|
|
804
821
|
throw new Error(NO_HEADED_BROWSER);
|
|
822
|
+
}
|
|
805
823
|
// De-dupe concurrent launches: the first acquire to find no browser starts one; the rest await it.
|
|
806
824
|
if (slot.launching === undefined) {
|
|
807
825
|
// The in-flight promise MUST be cleared on failure as well as success. It used to be cleared only
|
|
@@ -826,21 +844,24 @@ export class BrowserPool {
|
|
|
826
844
|
* acquire of that kind relaunches it. The other browser's leases live on.
|
|
827
845
|
*/
|
|
828
846
|
#onCrash(slot, crashed) {
|
|
829
|
-
if (slot.browser !== crashed)
|
|
830
|
-
return;
|
|
847
|
+
if (slot.browser !== crashed) {
|
|
848
|
+
return;
|
|
849
|
+
} // a stale handler from a prior browser
|
|
831
850
|
slot.browser = undefined;
|
|
832
851
|
let freed = 0;
|
|
833
852
|
for (const [id, lease] of this.#active) {
|
|
834
|
-
if (lease.headed !== slot.headed)
|
|
853
|
+
if (lease.headed !== slot.headed) {
|
|
835
854
|
continue;
|
|
855
|
+
}
|
|
836
856
|
this.#active.delete(id);
|
|
837
857
|
freed += 1;
|
|
838
858
|
}
|
|
839
859
|
// A lease mid-acquire on the dead browser holds a slot too; it frees it in its own catch. When
|
|
840
860
|
// every lease belonged to this browser, nothing else can be holding one.
|
|
841
861
|
this.#occupied = 0 === this.#active.size ? 0 : Math.max(0, this.#occupied - freed);
|
|
842
|
-
for (const waiter of this.#waiters.splice(0))
|
|
843
|
-
waiter();
|
|
862
|
+
for (const waiter of this.#waiters.splice(0)) {
|
|
863
|
+
waiter();
|
|
864
|
+
} // let them re-claim + relaunch
|
|
844
865
|
}
|
|
845
866
|
/** Whether this lease's context skips certificate checks. A context cannot change it later. */
|
|
846
867
|
ignoresHTTPSErrors(sessionId) {
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pool's lease handle and lease record, its options, and its limits: the types and constants
|
|
3
|
+
* `BrowserPool` is built from.
|
|
4
|
+
*/
|
|
5
|
+
import type { InitScriptHandle, Launcher, PooledBrowser, PooledContext, PooledPage } from './pool-contract.js';
|
|
6
|
+
/** A leased context+page for one flow. `release` frees the slot and closes the context. */
|
|
7
|
+
export interface Lease {
|
|
8
|
+
readonly sessionId: string;
|
|
9
|
+
readonly url: string;
|
|
10
|
+
readonly navStatus?: number;
|
|
11
|
+
release(): Promise<void>;
|
|
12
|
+
/**
|
|
13
|
+
* Hand the page the zero-install reader: evaluated in the live document, and added ahead of every
|
|
14
|
+
* document this lease loads next. False when there is no reader, or the page cannot run one.
|
|
15
|
+
* Rejects with `NotHtmlDocumentError` when the page is not an HTML document.
|
|
16
|
+
*/
|
|
17
|
+
injectReader?(): Promise<boolean>;
|
|
18
|
+
/** Whether the page's document has not reached `complete` yet. False when it cannot be read. */
|
|
19
|
+
isLoading?(): Promise<boolean>;
|
|
20
|
+
}
|
|
21
|
+
/** Default lease time-to-live: a lease untouched for this long is presumed orphaned and reclaimed. */
|
|
22
|
+
export declare const DEFAULT_LEASE_TTL_MS: number;
|
|
23
|
+
/**
|
|
24
|
+
* How long a lease outlives the MCP connection that took it. Long enough for an agent whose
|
|
25
|
+
* connection only blipped to touch it again; short enough that a gone agent's window closes soon.
|
|
26
|
+
*/
|
|
27
|
+
export declare const ORPHANED_LEASE_GRACE_MS = 30000;
|
|
28
|
+
/** Default cap on how long a lease's initial navigation may take before it fails (frees the slot). */
|
|
29
|
+
export declare const DEFAULT_NAV_TIMEOUT_MS = 30000;
|
|
30
|
+
/**
|
|
31
|
+
* How many reaped lease ids to remember. Only the most recent departure is ever asked about, so
|
|
32
|
+
* this is slack for a burst sweep, not a history.
|
|
33
|
+
*/
|
|
34
|
+
export declare const MAX_REMEMBERED_REAPED_LEASES = 64;
|
|
35
|
+
export interface BrowserPoolOptions {
|
|
36
|
+
/** Max simultaneous leased contexts. Over-cap acquires queue. */
|
|
37
|
+
maxContexts: number;
|
|
38
|
+
/** Stable id generator for lease sessionIds (injected to stay clock-/random-free in logic). */
|
|
39
|
+
genSessionId: () => string;
|
|
40
|
+
/** Injected clock (ms). Defaults to Date.now; tests pass a controllable one. */
|
|
41
|
+
now?: () => number;
|
|
42
|
+
/** A lease untouched for longer than this is reclaimed by sweepExpired. */
|
|
43
|
+
leaseTtlMs?: number;
|
|
44
|
+
/** Per-lease navigation timeout — a page that won't load fails its own lease, never blocks a slot. */
|
|
45
|
+
navTimeoutMs?: number;
|
|
46
|
+
/**
|
|
47
|
+
* The zero-install reader, built where the daemon knows its own port and pairing token. A thunk so
|
|
48
|
+
* the ~450KB build is only read if some page ever needs it; undefined when there is none to give.
|
|
49
|
+
*/
|
|
50
|
+
zeroInstallScript?: () => string | undefined;
|
|
51
|
+
/**
|
|
52
|
+
* Launches a browser with a window, for a lease somebody wants to watch. Launched only when the
|
|
53
|
+
* first headed lease is asked for. Absent: a headed lease is refused.
|
|
54
|
+
*/
|
|
55
|
+
launchHeaded?: Launcher;
|
|
56
|
+
}
|
|
57
|
+
/** One browser the pool may hold, and the launch in flight for it. */
|
|
58
|
+
export interface BrowserSlot {
|
|
59
|
+
readonly launch: Launcher | undefined;
|
|
60
|
+
readonly headed: boolean;
|
|
61
|
+
browser?: PooledBrowser | undefined;
|
|
62
|
+
launching?: Promise<PooledBrowser> | undefined;
|
|
63
|
+
}
|
|
64
|
+
/** What a headed lease is refused with where no window can be opened. */
|
|
65
|
+
export declare const NO_HEADED_BROWSER = "no headed browser can be opened here";
|
|
66
|
+
/**
|
|
67
|
+
* An acquire that asked to be refused rather than queued found every slot taken (#1535). Queued,
|
|
68
|
+
* it waited behind the cap with no deadline and no status, and `reticle drive` reported the 60 s
|
|
69
|
+
* timeout as a daemon shutting down. The message names the cap and the one override for it.
|
|
70
|
+
*/
|
|
71
|
+
export declare class PoolAtCapacityError extends Error {
|
|
72
|
+
readonly max: number;
|
|
73
|
+
constructor(max: number);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* What an acquire that asked for an init script is refused with when the page cannot run one.
|
|
77
|
+
*
|
|
78
|
+
* A refusal rather than a lease: the caller asked for a stub of the bindings a desktop shell
|
|
79
|
+
* injects, and a tab opened without it would throw in the app's view and read as a broken app.
|
|
80
|
+
*/
|
|
81
|
+
export declare const INIT_SCRIPT_UNSUPPORTED = "Init script failed: this page does not support init scripts, so the requested stub would not be there";
|
|
82
|
+
export interface ActiveLease {
|
|
83
|
+
/** Whether it lives in the headed browser: that browser's death is this lease's death. */
|
|
84
|
+
headed: boolean;
|
|
85
|
+
context: PooledContext;
|
|
86
|
+
page: PooledPage;
|
|
87
|
+
url: string;
|
|
88
|
+
/** Who took it (one id per MCP attach). Only that caller is handed it again. */
|
|
89
|
+
owner?: string;
|
|
90
|
+
/** Last time an agent touched this lease (acquire or any tool call); drives orphan reclaim. */
|
|
91
|
+
touchedAt: number;
|
|
92
|
+
/** When the connection that took it closed, until it is touched again. */
|
|
93
|
+
detachedAt?: number;
|
|
94
|
+
/**
|
|
95
|
+
* The websocket address the page's SDK reported it could not reach, if it said so.
|
|
96
|
+
*
|
|
97
|
+
* One string, overwritten — not a buffer. The SDK retries and logs each failure, so accumulating
|
|
98
|
+
* them would grow without bound for exactly the app that is already misconfigured, and every entry
|
|
99
|
+
* after the first says the same thing.
|
|
100
|
+
*/
|
|
101
|
+
dialFailureUrl?: string;
|
|
102
|
+
/**
|
|
103
|
+
* The message of the most recent native dialog dismissed on this lease, if any.
|
|
104
|
+
*
|
|
105
|
+
* One string, overwritten — not a buffer, same reasoning as `dialFailureUrl`: this is diagnostic
|
|
106
|
+
* context for "why did the page look frozen for a moment", not a log an agent needs to replay.
|
|
107
|
+
*/
|
|
108
|
+
lastDialogMessage?: string;
|
|
109
|
+
/**
|
|
110
|
+
* The disposable handle of the caller's init script, when the lease was opened with one — a stub
|
|
111
|
+
* of the bindings a desktop shell injects. Disposed with the lease, so a reload or a later
|
|
112
|
+
* navigation inside the lease keeps the stub.
|
|
113
|
+
*/
|
|
114
|
+
hostStub?: InitScriptHandle;
|
|
115
|
+
/** The production guard on this page's requests, re-asserted after mocks so it is asked first. */
|
|
116
|
+
guard?: (method: string, url: string) => string | undefined;
|
|
117
|
+
/**
|
|
118
|
+
* The text of that init script, kept so a lease that asks for the SAME stub again is recognised and
|
|
119
|
+
* reused instead of being torn down and remade — see `hostStubScript` and the lease tool's reuse.
|
|
120
|
+
*/
|
|
121
|
+
hostStubScript?: string;
|
|
122
|
+
/** What this lease was granted when it opened. It never changes on an open lease. */
|
|
123
|
+
permissions: readonly string[];
|
|
124
|
+
/** Whether its context accepts any TLS certificate (#1255). Fixed when the context is made. */
|
|
125
|
+
ignoresHTTPSErrors: boolean;
|
|
126
|
+
}
|
|
127
|
+
/** Whether the page's document has not finished loading. A page that cannot be asked counts as loaded. */
|
|
128
|
+
export declare function pageLoading(page: PooledPage): Promise<boolean>;
|