@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.
Files changed (115) hide show
  1. package/SKILL.md +2 -2
  2. package/dist/command/cli/answers/gate-hook-message.d.ts +2 -0
  3. package/dist/command/cli/answers/gate-hook-message.js +5 -1
  4. package/dist/command/cli/auth/cloud-session.d.ts +1 -19
  5. package/dist/command/cli/auth/device-flow.d.ts +3 -37
  6. package/dist/command/cli/cli-doctor.js +30 -23
  7. package/dist/command/cli/cli-flow-commands.js +7 -1
  8. package/dist/command/cli/cli-parse-serve.d.ts +24 -0
  9. package/dist/command/cli/cli-parse-serve.js +124 -0
  10. package/dist/command/cli/cli-parse.d.ts +1 -10
  11. package/dist/command/cli/cli-parse.js +62 -121
  12. package/dist/command/cli/cli-usage.js +5 -2
  13. package/dist/command/cli/cloud-link.d.ts +1 -20
  14. package/dist/command/cli/doctor/doctor-agent-link.d.ts +23 -0
  15. package/dist/command/cli/doctor/doctor-agent-link.js +28 -0
  16. package/dist/command/cli/ports/port-holder.js +6 -1
  17. package/dist/command/cli/project-name.d.ts +1 -7
  18. package/dist/command/cli/project-name.js +1 -0
  19. package/dist/command/daemon/daemon.d.ts +6 -0
  20. package/dist/command/daemon/daemon.js +52 -2
  21. package/dist/command/daemon/win32-escape.d.ts +32 -0
  22. package/dist/command/daemon/win32-escape.js +119 -0
  23. package/dist/command/setup/bringup/dev-server-files.d.ts +1 -9
  24. package/dist/command/setup/bringup/dev-server-wait.js +5 -1
  25. package/dist/command/version/version-skew.d.ts +2 -0
  26. package/dist/command/version/version-skew.js +7 -3
  27. package/dist/features/crawl/crawl-tools.js +2 -2
  28. package/dist/features/exhaust/ledger.d.ts +15 -96
  29. package/dist/features/exhaust/ledger.js +3 -3
  30. package/dist/features/license/license.d.ts +2 -14
  31. package/dist/hooks/hook-commands.js +19 -9
  32. package/dist/judgement/capsule/capsule-store.d.ts +2 -39
  33. package/dist/language/flows/annotate-tools.js +1 -1
  34. package/dist/language/flows/fields/flow-secret-field.js +1 -1
  35. package/dist/language/flows/flow-expect-grammar.js +14 -6
  36. package/dist/language/flows/flow-lease.d.ts +27 -0
  37. package/dist/language/flows/flow-lease.js +59 -0
  38. package/dist/language/flows/flow-replay-run.d.ts +1 -87
  39. package/dist/language/flows/flow-replay-run.js +88 -283
  40. package/dist/language/flows/flow-replayable.d.ts +1 -1
  41. package/dist/language/flows/flow-start-path.d.ts +108 -0
  42. package/dist/language/flows/flow-start-path.js +313 -0
  43. package/dist/language/flows/flow-tools.d.ts +2 -23
  44. package/dist/language/flows/flow-tools.js +34 -66
  45. package/dist/language/flows/recording/flake.d.ts +3 -27
  46. package/dist/language/flows/recording/flake.js +1 -1
  47. package/dist/language/flows/stores/assertion-tiers-store.js +1 -1
  48. package/dist/machine/git-tracks.js +1 -0
  49. package/dist/memory/cloud/cloud-sync.d.ts +2 -24
  50. package/dist/memory/intent/intent-shard.d.ts +41 -266
  51. package/dist/memory/journal/envelope-store.js +1 -1
  52. package/dist/memory/journal/envelope.d.ts +6 -154
  53. package/dist/memory/journal/on-disk/ambient-file.d.ts +1 -7
  54. package/dist/memory/journal/on-disk/ambient-file.js +1 -1
  55. package/dist/memory/journal/self-observation.js +3 -1
  56. package/dist/memory/journal/session-journal.js +39 -20
  57. package/dist/portal/input/drive-error.d.ts +6 -0
  58. package/dist/portal/input/drive-error.js +9 -0
  59. package/dist/portal/input/real-input-press.d.ts +34 -0
  60. package/dist/portal/input/real-input-press.js +150 -0
  61. package/dist/portal/input/real-input.d.ts +6 -23
  62. package/dist/portal/input/real-input.js +95 -192
  63. package/dist/portal/input/viewport-tools.d.ts +19 -15
  64. package/dist/portal/pool/browser-pool.d.ts +5 -55
  65. package/dist/portal/pool/browser-pool.js +121 -100
  66. package/dist/portal/pool/pool-lease.d.ts +128 -0
  67. package/dist/portal/pool/pool-lease.js +49 -0
  68. package/dist/portal/production/production-activation.d.ts +5 -27
  69. package/dist/portal/session/control-envelope.d.ts +1 -1
  70. package/dist/portal/session/zero-install-first-call.d.ts +3 -0
  71. package/dist/portal/session/zero-install-first-call.js +33 -12
  72. package/dist/surface/mcp/mcp.d.ts +3 -3
  73. package/dist/surface/mcp/mcp.js +4 -6
  74. package/dist/surface/tools/act/act-danger.js +32 -17
  75. package/dist/surface/tools/act/durable.js +1 -1
  76. package/dist/surface/tools/act-sequence-tool.js +3 -3
  77. package/dist/surface/tools/act-tools.js +15 -16
  78. package/dist/surface/tools/agent/compact-envelope.d.ts +2 -2
  79. package/dist/surface/tools/coverage-tools.js +1 -1
  80. package/dist/surface/tools/dynamic-tools.d.ts +8 -0
  81. package/dist/surface/tools/dynamic-tools.js +36 -16
  82. package/dist/surface/tools/empty-session-list.d.ts +5 -29
  83. package/dist/surface/tools/feedback-tools.js +1 -1
  84. package/dist/surface/tools/gap-novelty.d.ts +1 -1
  85. package/dist/surface/tools/gaps/capability-absences.js +4 -3
  86. package/dist/surface/tools/harness-gate.d.ts +1 -1
  87. package/dist/surface/tools/harness-toolset.js +3 -4
  88. package/dist/surface/tools/lease-permissions.d.ts +1 -1
  89. package/dist/surface/tools/lease-readiness.d.ts +23 -1
  90. package/dist/surface/tools/lease-readiness.js +75 -9
  91. package/dist/surface/tools/lease-tools.d.ts +1 -52
  92. package/dist/surface/tools/lease-tools.js +79 -142
  93. package/dist/surface/tools/observe-tools.js +3 -1
  94. package/dist/surface/tools/read-tools.js +5 -5
  95. package/dist/surface/tools/real-input-attempt.js +70 -31
  96. package/dist/surface/tools/session-ports.d.ts +57 -0
  97. package/dist/surface/tools/session-ports.js +102 -0
  98. package/dist/surface/tools/tool-kit.d.ts +5 -3
  99. package/dist/surface/tools/tools.js +1 -1
  100. package/dist/telemetry/daemon-telemetry.d.ts +3 -1
  101. package/dist/telemetry/daemon-telemetry.js +9 -2
  102. package/dist/telemetry/telemetry.d.ts +3 -1
  103. package/dist/telemetry/telemetry.js +2 -0
  104. package/docs/agent-cheatsheet.md +12 -0
  105. package/docs/cli/gate.mdx +1 -1
  106. package/docs/cli/verify.mdx +8 -1
  107. package/docs/first-drive.md +7 -0
  108. package/docs/multi-agent-testing.md +2 -0
  109. package/docs/packages/react.mdx +14 -2
  110. package/docs/packages/vite-plugin.mdx +1 -0
  111. package/docs/predicates.mdx +6 -0
  112. package/docs/skill.md +3 -3
  113. package/docs/troubleshooting.mdx +10 -0
  114. package/docs/usage.md +1 -1
  115. 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
- /** Default lease time-to-live: a lease untouched for this long is presumed orphaned and reclaimed. */
21
- export const DEFAULT_LEASE_TTL_MS = 5 * 60_000;
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
- if (!this.#active.has(leaseId))
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
- else
549
- pending = dialled; // logged during goto, before the lease is registered below
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
- else
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: async () => {
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; // already released or lost to a crash
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
- if (slot.browser !== undefined && slot.browser.isConnected())
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; // a stale handler from a prior browser
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(); // let them re-claim + relaunch
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>;