browser-broker 0.2.0 → 0.3.1

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.
@@ -121,16 +121,55 @@ export async function runReconcileCommand(rest, options) {
121
121
  }
122
122
  return COMMAND_EXIT.accepted;
123
123
  }
124
+ /**
125
+ * Did this run decline to do the thing it was called to do?
126
+ *
127
+ * **Two facts together, and neither alone is enough.** Tabs still being
128
+ * opened block the sweep (`decideReconciliation` leaves them alone, because
129
+ * closing a page a mid-open lease is about to be handed would be worse than
130
+ * declining) — but a run that closed pages *and* skipped one did work, and
131
+ * calling that "nothing was closed" would be false. A run that closed
132
+ * nothing because there was nothing to close is not declining either; it is
133
+ * simply a clean run, and telling that caller to try again would send it
134
+ * back for an answer it already has.
135
+ *
136
+ * So the conclusion is drawn only where both hold: something was in the way,
137
+ * and nothing was swept past it.
138
+ */
139
+ function nothingClosedPendingRetry(report) {
140
+ return report.skippedOpening > 0 && report.closed === 0;
141
+ }
124
142
  /**
125
143
  * The report a person reads.
126
144
  *
127
145
  * **Every line is a count or an opaque identifier**, which is §1.4's rule
128
146
  * made true by there being nothing else available to print: the report type
129
147
  * carries no driver name, so this function could not print one if it tried.
148
+ *
149
+ * ── Why the outcome is the first line and not the last ──────────────────
150
+ *
151
+ * A caller reads the first line and acts on it. When this run declined —
152
+ * {@link nothingClosedPendingRetry} — the sentence that predicts that
153
+ * caller's next failure is the one that has to arrive first, because a
154
+ * headline of `reconciled: <browser>` above four counters reads as
155
+ * completion, and a reader who takes it at face value stops there and runs
156
+ * straight back into the state they invoked this to clear. The counters are
157
+ * still printed, unchanged and in the same order; what moves is the
158
+ * conclusion, which stops being something the reader has to derive from the
159
+ * bottom of a list.
160
+ *
161
+ * **The headline stops claiming completion on such a run** for the same
162
+ * reason. `reconciled:` is a claim about what happened, and on a run that
163
+ * closed nothing and needs invoking again it is not a true one — this is the
164
+ * defect class this repository keeps finding in itself, a call that succeeds
165
+ * while delivering less than it announced. The word is kept for the runs
166
+ * that earned it.
130
167
  */
131
168
  export function formatReconciliation(browser, report) {
169
+ const declined = nothingClosedPendingRetry(report);
132
170
  const lines = [
133
- `reconciled: ${browser}`,
171
+ declined ? `did not reconcile: ${browser}` : `reconciled: ${browser}`,
172
+ ...(declined ? [conclusionLine(report)] : []),
134
173
  `pages open, not counting the keeper: ${String(report.pagesSeen)}`,
135
174
  `pages closed because no live lease owned them: ${String(report.closed)}`,
136
175
  `leases ended because their page was gone: ${String(report.settled.length)}`,
@@ -148,22 +187,34 @@ export function formatReconciliation(browser, report) {
148
187
  // reader knows what it costs — memory, and not budget.
149
188
  lines.push(`${String(report.closeFailures)} page(s) would not close. That is a leaked page and not a leaked lease: the budget is unaffected, and \`broker doctor\` reports them.`);
150
189
  }
151
- if (report.skippedOpening > 0) {
152
- // Said on the run it happened on, because the alternative is a person
153
- // reading "0 closed" and concluding there was nothing to close.
154
- //
155
- // **The caution itself does not change when the caller owns the blocking
156
- // row.** Closing a page belonging to an in-flight claim would be worse
157
- // than declining, and that is true whoever the claim belongs to. What
158
- // changes is that the caller is told the remedy is in its own hands: an
159
- // operator once ran this four times against a row that was its own lease,
160
- // held open while it ran the command, with nothing in the message able to
161
- // say so.
162
- const owned = report.skippedOpeningOwnedByCaller;
163
- lines.push(`${String(report.skippedOpening)} tab(s) are still being opened, so nothing was closed on this run — a page seen now may belong to one of them.` +
164
- (owned > 0
165
- ? ` ${String(owned)} of them ${owned === 1 ? 'belongs' : 'belong'} to your own lease — release ${owned === 1 ? 'it' : 'them'}, or run this from another session.`
166
- : ' Run again once they have settled.'));
190
+ // Said on the run it happened on, because the alternative is a person
191
+ // reading "0 closed" and concluding there was nothing to close.
192
+ //
193
+ // **Printed here only when it was not already printed at the top.** The
194
+ // conclusion belongs above the counters on a run that declined, and below
195
+ // them on a run that closed pages anyway where it is a caveat on real
196
+ // work rather than the outcome. Either way it is written once, by one
197
+ // function, so the two positions cannot drift into two wordings.
198
+ if (report.skippedOpening > 0 && !declined) {
199
+ lines.push(conclusionLine(report));
167
200
  }
168
201
  return lines;
169
202
  }
203
+ /**
204
+ * The sentence that tells a caller what to do next.
205
+ *
206
+ * **The caution itself does not change when the caller owns the blocking
207
+ * row.** Closing a page belonging to an in-flight claim would be worse than
208
+ * declining, and that is true whoever the claim belongs to. What changes is
209
+ * that the caller is told the remedy is in its own hands: an operator can
210
+ * otherwise run this repeatedly against a row that is its own lease, held
211
+ * open for as long as the command keeps being run, with nothing in the
212
+ * message able to say so.
213
+ */
214
+ function conclusionLine(report) {
215
+ const owned = report.skippedOpeningOwnedByCaller;
216
+ return (`${String(report.skippedOpening)} tab(s) are still being opened, so nothing was closed on this run — a page seen now may belong to one of them.` +
217
+ (owned > 0
218
+ ? ` ${String(owned)} of them ${owned === 1 ? 'belongs' : 'belong'} to your own lease — release ${owned === 1 ? 'it' : 'them'}, or run this from another session.`
219
+ : ' Run again once they have settled.'));
220
+ }
@@ -146,39 +146,6 @@ const DECLARATIONS = [
146
146
  maximum: 3,
147
147
  browserKind: 'clean-room',
148
148
  },
149
- {
150
- /**
151
- * Which browser binary the signed-in browsers launch (§6.2).
152
- *
153
- * **One engine per kind, never per browser**, for the same reason there is
154
- * no per-entry private flag: an engine per entry reintroduces the
155
- * per-entry attribute this configuration exists without.
156
- *
157
- * The three accepted words are all Chromium over the same remote-debugging
158
- * protocol, which is what makes the choice a binary path rather than a
159
- * second driver.
160
- */
161
- key: 'BROKER_REGULAR_BROWSER_ENGINE',
162
- kind: 'enum',
163
- fallback: 'msedge',
164
- allowed: ['chrome', 'brave', 'msedge'],
165
- unit: 'a browser engine',
166
- },
167
- {
168
- /**
169
- * Which browser binary the clean-room browsers launch (§6.2).
170
- *
171
- * **May differ from the signed-in engine**, and separate variables are
172
- * what make that expressible: a person signs into the signed-in browser by
173
- * hand, so which binary that is can be a matter of what they already use,
174
- * while nobody signs into a clean-room browser at all.
175
- */
176
- key: 'BROKER_PRIVATE_BROWSER_ENGINE',
177
- kind: 'enum',
178
- fallback: 'msedge',
179
- allowed: ['chrome', 'brave', 'msedge'],
180
- unit: 'a browser engine',
181
- },
182
149
  ];
183
150
  /** Every variable this build declares. Row #9's walk test reads this. */
184
151
  export const DECLARED_VARIABLES = DECLARATIONS.map((d) => d.key);
@@ -407,15 +374,6 @@ export function readEnvironment(options = {}) {
407
374
  }
408
375
  return value;
409
376
  };
410
- // The reader already refused anything outside the declared set, so this
411
- // narrows a checked value rather than trusting one.
412
- const getEngine = (key) => {
413
- const value = resolved.get(key);
414
- if (value !== 'chrome' && value !== 'brave' && value !== 'msedge') {
415
- throw new Error(`${key} was declared as an engine but not resolved as one`);
416
- }
417
- return value;
418
- };
419
377
  const regularBrowsers = getList('BROKER_REGULAR_BROWSERS');
420
378
  const privateBrowsers = getList('BROKER_PRIVATE_BROWSERS');
421
379
  // **A name in both lists is refused rather than resolved**, because the
@@ -440,7 +398,5 @@ export function readEnvironment(options = {}) {
440
398
  launchReadinessTimeoutSeconds: getNumber('BROKER_LAUNCH_READINESS_TIMEOUT_SECONDS'),
441
399
  regularBrowsers,
442
400
  privateBrowsers,
443
- regularBrowserEngine: getEngine('BROKER_REGULAR_BROWSER_ENGINE'),
444
- privateBrowserEngine: getEngine('BROKER_PRIVATE_BROWSER_ENGINE'),
445
401
  };
446
402
  }
@@ -47,6 +47,30 @@ import { StartupRefusal } from "../errors.js";
47
47
  * The memo holds the **promise**, not the resolved session, so two verbs
48
48
  * racing in the same process await one acquisition rather than starting two.
49
49
  *
50
+ * ── …but a memoised session is checked before it is handed back ──────────
51
+ *
52
+ * One session per process is right; *trusting* it for the life of the process
53
+ * is not. A session is a connection, and a connection can end while the
54
+ * browser it points at is still running — so a memo that is never revalidated
55
+ * hands a dead connection to every page verb until the process exits.
56
+ *
57
+ * That is not hypothetical and it is why this check exists. It is the
58
+ * difference between the two surfaces: the command line is one process per
59
+ * command, so its memo cannot outlive the verb that created it and the state
60
+ * is unreachable there. The tool surface serves a whole session from one
61
+ * process, so it is the surface where a stale memo is not merely possible but
62
+ * eventually certain — and a caller doing the obvious correct thing (release,
63
+ * claim again, drive the page) got the same dead connection every time, with
64
+ * no way back from that surface at all.
65
+ *
66
+ * So {@link BrowserSessions.session} asks {@link BrowserSession.isConnected}
67
+ * before returning a settled entry and drops it if the answer is no. **This
68
+ * is a different question from {@link BrowserSessions.liveness}**, which asks
69
+ * the machine whether a browser is running: in the state above the browser is
70
+ * running, so liveness says `live` and correctly evicts nothing. Both checks
71
+ * are needed because a browser and a connection can each die without the
72
+ * other.
73
+ *
50
74
  * ── A failed acquisition is not cached ───────────────────────────────────
51
75
  *
52
76
  * If acquiring throws, the memo is cleared, so the next call tries again. The
@@ -82,20 +106,69 @@ export function browserSessionProvider(options) {
82
106
  // BrowserSessionProviderOptions.artifacts} for why this is the
83
107
  // shared tree rather than one lease's.
84
108
  { outputDirectory: path.join(options.artifacts.root, 'snapshots') }),
85
- // **The signed-in engine, carried from this process's one environment
86
- // snapshot** (§6.3, `DECISIONS.md` §13i). One driver serves every
87
- // browser in this process, so the kind-specific engine cannot be chosen
88
- // per session here — see the note on `RealDriverOptions.engine` for
89
- // what the value does and does not do, which is what makes one engine
90
- // per process the honest shape rather than a shortcut.
91
- engine: options.environment.regularBrowserEngine,
92
109
  });
93
110
  const inFlight = new Map();
94
111
  const settled = new Map();
95
112
  const session = (browser) => {
96
113
  const existing = inFlight.get(browser);
97
114
  if (existing !== undefined) {
98
- return existing;
115
+ // ── The memo is checked before it is trusted ──────────────────────
116
+ //
117
+ // **This is what stops a dead connection being handed out for the life
118
+ // of a long-running process.** A settled session is a live connection
119
+ // over the debugging protocol, and a connection can end while the
120
+ // browser it points at carries on perfectly well — a browser restart, a
121
+ // closed target, a dropped protocol socket. When that happens the
122
+ // session object is still here, still resolved, and every page verb
123
+ // performed over it fails with `Target page, context or browser has
124
+ // been closed`.
125
+ //
126
+ // Nothing else in this file can catch that state, which is why it
127
+ // survived a previous fix. The `.catch` below clears only a *rejected*
128
+ // acquisition, and a session that resolved and later died never
129
+ // rejects. {@link BrowserSessions.liveness} clears a dead entry, but it
130
+ // asks the machine whether a browser is **running** — which in this
131
+ // state is `true` — and it is reached only from `status`, never from a
132
+ // page verb.
133
+ //
134
+ // ── Why it is safe to consult on the hot path ─────────────────────
135
+ //
136
+ // `isConnected` reads a flag the connection already maintains. It
137
+ // performs no input/output and cannot throw, so this adds no round trip
138
+ // to a call that is about to make several.
139
+ //
140
+ // ── Every session answers, because the member is required ─────────
141
+ //
142
+ // It was optional first, so that a source unable to observe its
143
+ // connection could stay silent and be assumed usable. That let the one
144
+ // production session omit it entirely while `tsc` stayed quiet, and
145
+ // this guard then took the assume-usable branch on every real call —
146
+ // shipping a fix that changed nothing. A source that cannot tell now
147
+ // returns `true` explicitly instead, so the permissive answer is a
148
+ // decision in the source rather than a hole in it.
149
+ //
150
+ // ── Only a settled session can be judged ──────────────────────────
151
+ //
152
+ // An entry still in flight has no session to ask yet, and it is
153
+ // returned untouched: two verbs racing must await one acquisition
154
+ // rather than starting two, which is the property the promise-valued
155
+ // memo exists for. An acquisition in progress cannot be stale.
156
+ const open = settled.get(browser);
157
+ if (open === undefined || open.isConnected()) {
158
+ return existing;
159
+ }
160
+ // ── Dropped, not detached, and nothing is launched here ───────────
161
+ //
162
+ // There is nothing to detach from: the connection is the thing that
163
+ // ended. Dropping both entries sends this very call through `acquire`,
164
+ // which makes its own observation and wins or loses the launch race in
165
+ // the store like any other caller — the same recovery `liveness` takes,
166
+ // for the same reason it takes it that way. **No browser is ended**
167
+ // (`browser_scoped.never`, §7.3) and none is started from here: a
168
+ // second process launching against one profile directory is the
169
+ // measured silent-collision failure the race exists to prevent.
170
+ settled.delete(browser);
171
+ inFlight.delete(browser);
99
172
  }
100
173
  const acquiring = acquire(driver, browser, options)
101
174
  .then((acquired) => {
@@ -4,13 +4,31 @@ import { reconcileGeometry } from "../diff/geometry.js";
4
4
  import { decodePng, encodePng } from "../diff/image.js";
5
5
  import { computeMask } from "../diff/mask.js";
6
6
  import { extractRegions } from "../diff/regions.js";
7
- /** A result carrying no diff, with the sentence saying why. */
8
- function noDiff(settings, explanation, extra = {}) {
7
+ /**
8
+ * A result carrying no diff, with the sentence saying why.
9
+ *
10
+ * **`changed`, `changedPixels` and `changedRatio` are not set here at all** —
11
+ * not to `false`, not to `0`. Every path into this function is a path on which
12
+ * no comparison ran, so there is no finding to report, and the shape says so by
13
+ * having no field rather than by having a field whose value happens to be the
14
+ * one a real all-clear also produces. `false` and `0` are what a comparison
15
+ * that ran and found nothing returns; emitting them here would make the two
16
+ * cases identical to anything reading the fields directly.
17
+ *
18
+ * `regions` stays an empty array and `truncated` stays `false` because those
19
+ * describe the *output listing* rather than a finding about the page: an empty
20
+ * list of regions is honest about a call that produced no regions, and neither
21
+ * can be misread as an assertion that the page is unchanged.
22
+ */
23
+ function noDiff(settings, explanation,
24
+ // **Typed to exclude the three findings**, rather than a bare
25
+ // `Partial<ComparisonResult>`. The spread below is the one way a caller could
26
+ // put `changed` back into a no-diff result, so the parameter that feeds it
27
+ // does not accept those keys and a future call site trying to pass one is a
28
+ // build failure instead of a silently restored defect.
29
+ extra = {}) {
9
30
  return {
10
31
  diffed: false,
11
- changed: false,
12
- changedPixels: 0,
13
- changedRatio: 0,
14
32
  regions: [],
15
33
  overlayPath: null,
16
34
  truncated: false,
@@ -8,6 +8,7 @@ import { seedRecord } from "../storage-seed.js";
8
8
  import { BrokerError } from "../../errors.js";
9
9
  import { sanitiseLabel, stampFromInstant } from "../../artifacts/names.js";
10
10
  import { takeCapture } from "../../capture/pipeline.js";
11
+ import { describeReduction } from "../../capture/tiers.js";
11
12
  import { capturesTakenBy, recordCapture } from "../capture-store.js";
12
13
  import { captureSource } from "../capture-seam.js";
13
14
  import { insertComparison } from "../comparison-store.js";
@@ -307,9 +308,37 @@ export function decideNavigate(scope, input) {
307
308
  // check was honoured.
308
309
  detail: { url, ...(waitMs === undefined ? {} : { waitMs }) },
309
310
  });
310
- const work = afterCommitWork(scope, input, tab, (session, page) => session.navigate(page, url, waitMs), lease.claimId);
311
+ // Where the page actually ended up, filled by the after-commit closure
312
+ // below. Undefined until the browser has answered — and permanently so on a
313
+ // build with no browser, which is what the getters' fallbacks are for.
314
+ let arrived;
315
+ const work = afterCommitWork(scope, input, tab, async (session, page) => {
316
+ // **The driver's answer is kept, not discarded.** It reports
317
+ // `page.url()` read after the load settles, plus the title and status,
318
+ // and this assignment is the whole of the redirect fix: the value was
319
+ // always available here and was being thrown away.
320
+ arrived = await session.navigate(page, url, waitMs);
321
+ }, lease.claimId);
311
322
  return {
312
- value: withPageDriven({ claimId: lease.claimId, tabId: tab.tabId, expiresAt, url }, work),
323
+ value: withPageDriven({
324
+ claimId: lease.claimId,
325
+ tabId: tab.tabId,
326
+ expiresAt,
327
+ // Getters, for the reason `pageDriven` is one — see
328
+ // {@link withPageDriven}. Read eagerly they would always report the
329
+ // request and no title, because nothing has run yet. That eager read
330
+ // is precisely the defect this fix removes, so spelling these as
331
+ // plain properties would restore it while looking correct.
332
+ get url() {
333
+ return arrived?.url ?? url;
334
+ },
335
+ get title() {
336
+ return arrived?.title;
337
+ },
338
+ get status() {
339
+ return arrived?.status;
340
+ },
341
+ }, work),
313
342
  afterCommit: work.afterCommit,
314
343
  };
315
344
  }
@@ -527,6 +556,20 @@ export function decideCapture(scope, input) {
527
556
  // written reason — that rule is not duplicated here, only the one the type
528
557
  // system cannot make on text arriving from a surface.
529
558
  const tier = validateCaptureTier(input.tier);
559
+ // **Typed as the pipeline's options, not the driver's `CaptureRequest`.**
560
+ //
561
+ // This annotation is the defect's whole mechanism, so it is worth naming.
562
+ // `CaptureRequest` is the *driver* seam — what the browser is told — and it
563
+ // has no `tier` and no `reason`, correctly: a rung is a decision about the
564
+ // picture after the shutter, not something a browser is asked for. The
565
+ // literal below nevertheless packed both in, and the excess-property check
566
+ // that would ordinarily catch that **does not apply to conditionally spread
567
+ // properties**, so it compiled silently. The value then had nowhere to go,
568
+ // and the one call site downstream quietly took only the two fields the
569
+ // driver type admits.
570
+ //
571
+ // Annotating with the type that actually consumes these fields is what makes
572
+ // the same mistake a compile error next time rather than an inert argument.
530
573
  const request = {
531
574
  fullPage,
532
575
  ...(input.selector === undefined ? {} : { selector: input.selector }),
@@ -590,16 +633,56 @@ export function decideCapture(scope, input) {
590
633
  const taken = await takeCapture({ tabs: session, artifacts }, lease.claimId, page, {
591
634
  fullPage,
592
635
  ...(request.selector === undefined ? {} : { selector: request.selector }),
636
+ // **The rung and its justification, which used to stop here.**
637
+ //
638
+ // `request` was built with both a dozen lines above — `tier`
639
+ // validated by `validateCaptureTier`, `reason` carried whenever it
640
+ // was given — and then this call site spread only `fullPage` and
641
+ // `selector`, so both died one line before the pipeline that
642
+ // honours them. Every capture was consequently taken at the default
643
+ // rung no matter what the caller asked for.
644
+ //
645
+ // That is the inert-argument defect `check:argument-reachability`
646
+ // exists to prevent, one layer below where that check looks: its
647
+ // rule is that a declared argument is read *at the bridge*, and
648
+ // `tier` is read there, so the check passed while the value went
649
+ // nowhere. It is worse than the `wait_ms` case that motivated the
650
+ // check, because `tier="max"` charges the caller a written
651
+ // justification first — the caller pays for the escalation, is told
652
+ // it was accepted, and receives the unescalated picture.
653
+ ...(request.tier === undefined ? {} : { tier: request.tier }),
654
+ ...(request.reason === undefined ? {} : { reason: request.reason }),
593
655
  }, takenBefore);
594
656
  // The row last, describing a file that is already on disk. See
595
657
  // `capture-store.ts` for why that order is the rule and not a preference.
596
658
  recordCapture(scope.db, lease.claimId, tab.tabId, taken.telemetry);
659
+ // **What the browser produced, and by how much it was shrunk to fit the
660
+ // rung** — present exactly when the two differ.
661
+ //
662
+ // The pipeline has computed `sourceWidth`/`sourceHeight` all along and
663
+ // `captures` has stored them all along; this object simply never passed
664
+ // them on, so the one layer that talks to the caller was the one layer
665
+ // that could not tell a reduced picture from an unreduced one. A
666
+ // `full_page` capture of a long article consequently came back at about
667
+ // sixteen per cent, complete and undistorted and entirely illegible,
668
+ // with nothing in the response saying so.
669
+ const reduction = describeReduction({ width: taken.sourceWidth, height: taken.sourceHeight }, { width: taken.width, height: taken.height }, taken.tier);
597
670
  written = {
598
671
  captureId: taken.captureId,
599
672
  path: taken.path,
600
673
  width: taken.width,
601
674
  height: taken.height,
602
675
  bytes: taken.bytes,
676
+ // Echoed on every capture, reduced or not, because "what did the page
677
+ // actually measure" is a fact a caller may want either way — and
678
+ // because a field that appears only on the bad case is a field nobody
679
+ // learns to read.
680
+ sourceWidth: taken.sourceWidth,
681
+ sourceHeight: taken.sourceHeight,
682
+ tier: taken.tier,
683
+ // Absent when nothing was shrunk. Its **presence** is the signal, which
684
+ // is why it is not a `scale: 1` that a caller would learn to skip.
685
+ ...(reduction === undefined ? {} : { reduced: reduction }),
603
686
  compareHint: `to diff a later capture against this one, pass compare_to: ${taken.captureId}`,
604
687
  };
605
688
  // ── The diff, when one was asked for (§3.11, §1.9) ──────────────────
@@ -222,9 +222,10 @@ export const TOOL_DEFINITIONS = [
222
222
  {
223
223
  name: 'browser_navigate',
224
224
  operation: 'navigate',
225
- description: 'Point your tab at an address. Returns the final address after redirects, the title, the ' +
226
- 'status, and a path to the accessibility snapshot taken on arrival a path, because a ' +
227
- 'snapshot is thousands of tokens and you usually want one part of it.',
225
+ description: 'Point your tab at an address. Returns the final address after redirects which is not ' +
226
+ 'always the address you asked for, and is the field to check when you need to know ' +
227
+ 'whether something sent you elsewhere plus the title and the status. ' +
228
+ 'It does NOT take a snapshot: use browser_read for one.',
228
229
  arguments: [
229
230
  LEASE_KEY,
230
231
  {
@@ -344,7 +345,11 @@ export const TOOL_DEFINITIONS = [
344
345
  type: 'string',
345
346
  required: false,
346
347
  description: '"detail" or "max" for a higher resolution. Omit for the default — there is no way to ' +
347
- 'ask for the default by name. "max" also requires reason.',
348
+ 'ask for the default by name. "max" also requires reason. **A tier raises the LONGEST ' +
349
+ 'edge, not the width**, so on a full_page capture of a page taller than it is wide the ' +
350
+ 'height takes the whole budget and the width stays small at every rung — a 1030px-wide ' +
351
+ 'page 6400px tall is about 165px wide by default and about 414px wide at "max". For ' +
352
+ 'legible text on a tall page, capture a selector instead, or read the page as text.',
348
353
  },
349
354
  {
350
355
  name: 'reason',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "browser-broker",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "license": "MIT",
5
5
  "description": "Leases over tabs in a fixed set of browsers: bounded capacity, a queue, reclamation, and an enforced capture policy.",
6
6
  "type": "module",
@@ -53,7 +53,6 @@
53
53
  "dist/",
54
54
  ".env.example",
55
55
  "README.md",
56
- "RELEASES.md",
57
56
  "LICENSE"
58
57
  ]
59
58
  }