browser-broker 0.1.0 → 0.2.0

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.
@@ -82,7 +82,9 @@
82
82
  * on the assumption the seam holds up its end.
83
83
  */
84
84
  export function decideReconciliation(pages, recorded) {
85
- const skippedOpening = recorded.filter((tab) => tab.driverTabId === null).map((tab) => tab.tabId);
85
+ const skippedOpening = recorded
86
+ .filter((tab) => tab.driverTabId === null)
87
+ .map((tab) => ({ tabId: tab.tabId, sessionId: tab.sessionId }));
86
88
  // Every driver name a live lease claims. Built from the rows that have one,
87
89
  // which by §1.4's check is exactly the rows that are not `opening`.
88
90
  const owned = new Set(recorded
@@ -123,7 +125,8 @@ export function readRecordedTabs(db, browserId) {
123
125
  return db
124
126
  .prepare(`SELECT tabs.id AS tabId,
125
127
  tabs.driver_tab_id AS driverTabId,
126
- tabs.claim_id AS claimId
128
+ tabs.claim_id AS claimId,
129
+ claims.session_id AS sessionId
127
130
  FROM tabs
128
131
  JOIN claims ON claims.id = tabs.claim_id
129
132
  WHERE tabs.browser_id = ?
@@ -132,6 +135,57 @@ export function readRecordedTabs(db, browserId) {
132
135
  ORDER BY tabs.id`)
133
136
  .all(browserId);
134
137
  }
138
+ /**
139
+ * Settle rows stranded at `closing` whose page the browser does not have.
140
+ *
141
+ * ── Why the vanished-tab path cannot reach these ────────────────────────
142
+ *
143
+ * {@link readRecordedTabs} requires `claims.state = 'active'`, and rightly:
144
+ * a lapsed lease's rows are the sweep's business, not reconciliation's. But
145
+ * that is exactly the population that strands. A lease ends, its tab is moved
146
+ * to `closing`, and if the answer is never written back the row is left in a
147
+ * state meaning "waiting for the tool" — attached to a lease that is no
148
+ * longer active, and therefore invisible to every later reconciliation.
149
+ *
150
+ * A store was found holding 22 such rows, the oldest two days old, every one
151
+ * with `close_attempts = 0`. The pages had been closed by hand; the rows
152
+ * could not be reached by anything.
153
+ *
154
+ * ── Why this is safe to settle without asking again ─────────────────────
155
+ *
156
+ * The caller has just asked the browser what it has open, and passes the
157
+ * driver names it answered with. A row whose name is not in that list
158
+ * describes a page this browser does not have, so there is no round trip
159
+ * outstanding and nothing to wait for — the same reasoning
160
+ * {@link applyReconciliation} uses for a vanished page, and the sweep uses
161
+ * for a tab that never opened.
162
+ *
163
+ * **A row whose name IS in the list is left alone.** Its page exists, the
164
+ * close may genuinely still be in flight, and settling it would claim an
165
+ * answer nobody has given.
166
+ */
167
+ export function settleStrandedTabs(db, browserId, openDriverTabIds, at) {
168
+ const stranded = db
169
+ .prepare(`SELECT tabs.id AS tabId, tabs.driver_tab_id AS driverTabId
170
+ FROM tabs
171
+ JOIN claims ON claims.id = tabs.claim_id
172
+ WHERE tabs.browser_id = ?
173
+ AND tabs.state = 'closing'
174
+ AND claims.state <> 'active'
175
+ ORDER BY tabs.id`)
176
+ .all(browserId);
177
+ const open = new Set(openDriverTabIds);
178
+ const gone = stranded.filter((tab) => tab.driverTabId === null || !open.has(tab.driverTabId));
179
+ if (gone.length === 0) {
180
+ return 0;
181
+ }
182
+ const placeholders = gone.map(() => '?').join(', ');
183
+ db.prepare(`UPDATE tabs
184
+ SET state = 'closed', closed_at = ?, updated_at = ?
185
+ WHERE id IN (${placeholders})
186
+ AND state = 'closing'`).run(at, at, ...gone.map((tab) => tab.tabId));
187
+ return gone.length;
188
+ }
135
189
  /**
136
190
  * Settle the rows whose pages are gone, and end the leases that held them.
137
191
  *
@@ -155,6 +209,24 @@ export function readRecordedTabs(db, browserId) {
155
209
  * | `updateSweptTabs` | The lease ended; is there a page to close? | `closing` — the tool is about to be asked |
156
210
  * | this | The page is already gone | `closed` — there is nothing to ask |
157
211
  *
212
+ * **`closing` appears in the predicate, and no row reaches it in that
213
+ * state.** The rows here come from {@link readRecordedTabs} by way of
214
+ * {@link decideReconciliation}, and that read requires `tabs.state IN
215
+ * ('opening', 'open')` — so the third value in the predicate below matches
216
+ * nothing this caller can supply. It is kept as a bound on what the write is
217
+ * permitted to touch rather than as a population it serves: the statement
218
+ * says which states this function may move a row out of, and a future caller
219
+ * that widens its own read cannot silently reopen a `closed` row through it.
220
+ *
221
+ * **The stranded-`closing` population is {@link settleStrandedTabs}'s**, not
222
+ * this function's, and the distinction is load-bearing. Those rows belong to
223
+ * leases that have already ended, which is precisely why `readRecordedTabs`
224
+ * (`claims.state = 'active'`) cannot see them and why they need their own
225
+ * pass. A store was found holding 22 of them, each still occupying its slot
226
+ * in the partial unique index on `(browser_id, driver_tab_id)`. Reading that
227
+ * story as this function's would leave the impression the gap is covered
228
+ * here, and it is not.
229
+ *
158
230
  * A vanished page has no round trip outstanding, so `closing` would assert
159
231
  * one that is not, and the row would wait forever for an answer nobody is
160
232
  * coming to give — the exact reasoning the sweep uses for a tab that never
@@ -193,7 +265,7 @@ export function applyReconciliation(db, vanished, at) {
193
265
  db.prepare(`UPDATE tabs
194
266
  SET state = 'closed', closed_at = ?, updated_at = ?
195
267
  WHERE id IN (${tabPlaceholders})
196
- AND state IN ('opening', 'open')`).run(at, at, ...tabIds);
268
+ AND state IN ('opening', 'open', 'closing')`).run(at, at, ...tabIds);
197
269
  // The lease goes with the tab, because a lease *is* a tab (§2.3): a lease
198
270
  // whose tab is gone owns nothing while still counting against the budget,
199
271
  // which §3.13 names as a state that should not exist.
@@ -1,3 +1,4 @@
1
+ import { recordTabCloseFailed, recordTabClosed } from "./arbitration.js";
1
2
  import { readEnvironment } from "../config/environment.js";
2
3
  import { prepareStore } from "../store/open.js";
3
4
  import { ArtifactStore } from "../artifacts/store.js";
@@ -70,6 +71,14 @@ export async function createRuntime(options) {
70
71
  environment,
71
72
  adapter: options.adapter,
72
73
  session: browsers.session,
74
+ // **What makes `status` able to tell the truth about a dead browser.**
75
+ // Supplied here rather than defaulted inside the broker for the reason
76
+ // the option's own comment gives: a build that cannot look must report
77
+ // `unknown` rather than claim the browser is fine. This build can look,
78
+ // so it does. Note this is the provider's `liveness`, which asks the
79
+ // operating system — not its memoised session, which is the very thing
80
+ // that keeps presenting a dead browser as a working connection.
81
+ checkBrowser: browsers.liveness,
73
82
  artifacts,
74
83
  // The same provider closes the tabs the sweep orphaned. Without one,
75
84
  // `SCHEMA.md` §2.4b's "a leaked tab is not a leaked lease" describes a
@@ -79,8 +88,24 @@ export async function createRuntime(options) {
79
88
  closeTab: async (tab) => {
80
89
  const session = await browsers.session(tab.browserId);
81
90
  const opened = await resolveDriverTab(store.db, tab.tabId);
82
- if (opened !== undefined) {
91
+ if (opened === undefined) {
92
+ return;
93
+ }
94
+ // **The answer is written down either way.** `closing` means "the tool
95
+ // was asked and has not answered", so a close that returns and is never
96
+ // recorded leaves a row saying that forever — which is what happened,
97
+ // 22 rows deep, until a person noticed his browser had filled with
98
+ // pages no lease owned.
99
+ //
100
+ // A failure is recorded rather than thrown: §2.4b's "a leaked tab is
101
+ // not a leaked lease" means the capacity is already back, and failing
102
+ // the release over the page would fail a call that did its job.
103
+ try {
83
104
  await session.closeTab({ browser: tab.browserId, driverTabId: opened });
105
+ recordTabClosed(store.db, tab.tabId, new Date().toISOString());
106
+ }
107
+ catch {
108
+ recordTabCloseFailed(store.db, tab.tabId, new Date().toISOString());
84
109
  }
85
110
  },
86
111
  });
@@ -121,3 +121,73 @@ export function recordTabOpened(db, tabId, driverTabId) {
121
121
  throw new Error(`Tab ${tabId} was not awaiting an open, so the driver name could not be recorded against it.`);
122
122
  }
123
123
  }
124
+ /**
125
+ * How many tabs on one browser are waiting on a close that will not come.
126
+ *
127
+ * ── Why this is one function and not two ────────────────────────────────
128
+ *
129
+ * `broker doctor` and the claim grant both have to answer *"is this browser
130
+ * carrying a backlog of stranded tabs"*, and a product holding two notions of
131
+ * "stranded" would be the same defect the backlog itself caused: a caller
132
+ * told one number by one surface and a different number by another has no way
133
+ * to tell which is the real one. So the predicate lives here once, and both
134
+ * read it.
135
+ *
136
+ * ── Why the threshold is a lease's own lifetime ─────────────────────────
137
+ *
138
+ * Taken from `doctor`'s existing definition rather than invented alongside
139
+ * it. `closing` means the tool was asked and has not answered — a transient
140
+ * state measured in a round trip. The honest boundary between "a close is in
141
+ * flight" and "a close is never happening" is the one the system already uses
142
+ * to decide a caller is gone: if a lease may be declared lapsed after this
143
+ * long without contact, a round trip outstanding for longer is not pending.
144
+ *
145
+ * The comparison is on `updated_at`, which is when the row was moved to
146
+ * `closing`. A round trip still inside the window is deliberately not
147
+ * counted, because reporting one would make a healthy release look like a
148
+ * fault.
149
+ */
150
+ export function countStrandedTabsFor(db, browserId, leaseSeconds, at = new Date()) {
151
+ const cutoff = new Date(at.getTime() - leaseSeconds * 1000).toISOString();
152
+ const row = db
153
+ .prepare(`SELECT COUNT(*) AS n
154
+ FROM tabs
155
+ WHERE browser_id = ?
156
+ AND state = 'closing'
157
+ AND updated_at < ?`)
158
+ .get(browserId, cutoff);
159
+ return row?.n ?? 0;
160
+ }
161
+ /**
162
+ * The stranded backlog broken down per browser, heaviest first.
163
+ *
164
+ * ── Why a breakdown rather than a total ─────────────────────────────────
165
+ *
166
+ * A total is not actionable when the remedy is per-browser. `doctor`'s
167
+ * remedy line says to run `broker reconcile` against each browser, but a
168
+ * single total cannot say which ones still need it: an operator who
169
+ * reconciled `regular` and saw the count fall from 29 to 13 reasonably
170
+ * concluded reconcile had not worked, when in fact the remaining 13 were all
171
+ * on `private` and the run had done exactly what it said.
172
+ *
173
+ * **Grouped rather than asked per browser from a configured list**, because
174
+ * the browsers are a configured list per kind and not a fixed pair — a
175
+ * breakdown assembled from the list this build happens to know about would
176
+ * silently omit a backlog on a browser that had been reconfigured away, which
177
+ * is the population most likely to be stranded.
178
+ *
179
+ * Only browsers carrying a backlog appear. A row reading zero is not a
180
+ * finding, and listing every configured browser on every healthy run would
181
+ * bury the one line that matters.
182
+ */
183
+ export function strandedTabsByBrowser(db, leaseSeconds, at = new Date()) {
184
+ const cutoff = new Date(at.getTime() - leaseSeconds * 1000).toISOString();
185
+ return db
186
+ .prepare(`SELECT browser_id AS browserId, COUNT(*) AS stranded
187
+ FROM tabs
188
+ WHERE state = 'closing'
189
+ AND updated_at < ?
190
+ GROUP BY browser_id
191
+ ORDER BY COUNT(*) DESC, browser_id`)
192
+ .all(cutoff);
193
+ }
@@ -14,13 +14,26 @@ import { TOOLS_BY_NAME, TOOL_DEFINITIONS } from "./tools.js";
14
14
  *
15
15
  * Read once at module load rather than per handshake: it cannot change while
16
16
  * the process runs, and a session serves one client.
17
+ *
18
+ * ── Why the read is allowed to fail ─────────────────────────────────────
19
+ *
20
+ * A missing or unreadable manifest must not take the surface down with it.
21
+ * This module is imported by the executable a client spawns, so a throw here
22
+ * happens *before* anything can be served and before the protocol exists to
23
+ * describe it — the client sees the process exit, not an error it can act on.
24
+ * Weighed against that, the cost of not knowing the version is a cosmetic
25
+ * field in one handshake.
26
+ *
27
+ * The command line degrades this way already, and by accident rather than by
28
+ * design: it reads the manifest inside the function behind `--version`, so
29
+ * every other command keeps working when the file is gone. Matching that
30
+ * behaviour deliberately is the point of the `catch` — the fallback is the
31
+ * same literal this constant held before it read anything.
17
32
  */
18
- const manifest = await import('../../package.json', { with: { type: 'json' } });
33
+ const manifest = await import('../../package.json', { with: { type: 'json' } }).catch(() => undefined);
19
34
  export const SERVER_INFO = {
20
35
  name: 'browser-broker',
21
- version: typeof manifest.default.version === 'string'
22
- ? manifest.default.version
23
- : '0.0.0',
36
+ version: typeof manifest?.default?.version === 'string' ? manifest.default.version : '0.0.0',
24
37
  };
25
38
  /**
26
39
  * What `initialize` returns: the negotiated revision, what this server can
@@ -1,5 +1,136 @@
1
1
  import { OPERATION_NAMES } from "../adapter/operations.js";
2
2
  import { BROWSER_CHOICE_GUIDANCE } from "../browser/driver.js";
3
+ /**
4
+ * The twelve tools, their descriptions, and their argument schemas.
5
+ *
6
+ * ── Surface area is a standing tax, and this file is where it is paid ────
7
+ *
8
+ * `SCHEMA.md` §3.1 opens with it: every description here sits in a connected
9
+ * session's context **on every turn**, whether or not anything calls the
10
+ * tool. Twelve descriptions is the whole agent-facing documentation of this
11
+ * service and it is also a per-turn cost on every session, so each one is
12
+ * written to be the shortest text that still prevents a wrong call.
13
+ *
14
+ * **The description is the only place a calling agent reliably reads.** Not
15
+ * `SCHEMA.md`, not a wiki, not a refusal it has not hit yet. So where a fact
16
+ * changes what a caller does — that `browser_status` is also the renew verb,
17
+ * that the browser choice has no default, that feedback needs no lease — the
18
+ * fact is in the description rather than only in the argument list.
19
+ *
20
+ * **`browser_sign_in`'s description is the strongest case of that rule, and
21
+ * the reason it reads as an instruction rather than a summary.** The failure
22
+ * it exists to end is a caller hitting a login wall and never learning it
23
+ * could ask — and **no refusal can reach that caller**, because it never
24
+ * makes a call to be refused. It abandons the task or fabricates a session
25
+ * instead, which is what 25 measured sessions did in a month. There is no
26
+ * second surface for that guidance to live on, so the description names the
27
+ * alternative outright, the way a refusal would.
28
+ *
29
+ * ── What is deliberately absent ─────────────────────────────────────────
30
+ *
31
+ * **There is no browser-scoped destructive verb, and there must never be
32
+ * one.** No close-browser, no kill-all, no restart. `SCHEMA.md` §3.13 makes
33
+ * that part of the contract rather than an omission: the administrative
34
+ * operations act on something every caller shares, so they are commands a
35
+ * person runs (§4.3, §5.4) and the ledger records that a person did.
36
+ * **The worst thing an agent can do through this surface is close its own
37
+ * tab**, and that ceiling is the reason this surface can be handed to an
38
+ * arbitrary caller at all.
39
+ *
40
+ * **`browser_sign_in` and `browser_sign_in_done` move a browser's state and
41
+ * do not breach that ceiling**, which is worth stating because they look like
42
+ * the exception. Both are keyed, and both act only on the lease that called
43
+ * them: the first takes the browser only when no *other* lease holds a tab on
44
+ * it, and the second is refused unless the calling lease is the one that
45
+ * asked — so a caller can neither interrupt somebody else's work nor end a
46
+ * person's sign-in command mid-password. The unkeyed pair that could do those
47
+ * things, `begin_sign_in` and `end_sign_in`, is deliberately not on this
48
+ * surface at all.
49
+ *
50
+ * `browser_tab_close` is absent for a second, separate reason (§3.1): it
51
+ * closed a caller's only tab while keeping the lease, producing a lease that
52
+ * owned nothing and still consumed budget. It is gone rather than deprecated,
53
+ * and it should not be reintroduced.
54
+ */
55
+ /**
56
+ * What a capture can and cannot be trusted to show (§3.11).
57
+ *
58
+ * ── A picture is the one result a caller cannot check against itself ─────
59
+ *
60
+ * Every other operation here returns something self-describing: a claim
61
+ * returns a key, an evaluation returns a value, a refusal explains itself. A
62
+ * capture returns a path to an image, and **an image of a half-rendered page
63
+ * looks exactly like an image of a broken one.** There is no field in the
64
+ * result to distrust, so a caller has nothing to weigh the picture against —
65
+ * which makes a wrong conclusion from a capture both easy to reach and hard
66
+ * to notice.
67
+ *
68
+ * The consequence is not hypothetical, and it is what puts this text on the
69
+ * description rather than in a document: a field session captured a canvas
70
+ * before it had drawn, read the dark frame as the page's real appearance, and
71
+ * **reported a fault against an application that did not have one.** It was
72
+ * caught only because a second measurement happened to disagree — which is
73
+ * not a mechanism anything can rely on.
74
+ *
75
+ * ── Why settling does not already cover this ─────────────────────────────
76
+ *
77
+ * Every capture settles the page first, and settling is worth exactly what
78
+ * §3.11 claims: it stops animations and transitions, hides the caret, and
79
+ * waits for web fonts, so the same page yields the same pixels run to run.
80
+ * **That is repeatability, not completeness.** It makes a moving page hold
81
+ * still; it cannot make an unfinished page finish. A canvas that has not
82
+ * drawn its first frame is not moving — it is absent — and holding it still
83
+ * is not the same as waiting for it.
84
+ *
85
+ * ── Why this names a check rather than a duration ────────────────────────
86
+ *
87
+ * The tempting sentence is *"allow the page to settle first"*, and it is
88
+ * advice a caller cannot act on: **the right wait is a property of the page,
89
+ * not of this service**, and a caller that has never seen the page rendered
90
+ * has no way to pick a number. Any figure written here would be wrong for
91
+ * some page and would be trusted anyway, which is worse than saying nothing.
92
+ *
93
+ * So the guidance is a **check** instead — capture twice and compare, which
94
+ * this tool can already express through `compare_to`. A caller can act on it
95
+ * without knowing anything about the page in advance, and it answers the
96
+ * question actually being asked, which is not *"has enough time passed"* but
97
+ * *"has this stopped changing"*. Two identical frames are evidence; one frame
98
+ * and a duration are an assumption.
99
+ */
100
+ const CAPTURE_SETTLE_CAVEAT = 'Captures are settled — animations stopped, fonts waited for — so a page yields the same ' +
101
+ 'pixels twice; that steadies a moving page but does not wait for one still drawing. A canvas ' +
102
+ 'or a deferred region can be captured before it has rendered, and the picture will look like a ' +
103
+ 'broken page rather than an early one. When a frame looks wrong, capture again with compare_to ' +
104
+ 'and check it against the first: no difference means you are seeing the page, not a moment of it.';
105
+ /**
106
+ * What this tool's unit is wrong for, so a caller with the other job does not
107
+ * reach for it anyway.
108
+ *
109
+ * ── The distinction this exists to draw ──────────────────────────────────
110
+ *
111
+ * `compare_to` answers *"did this page change since a moment ago, on this
112
+ * same tab"* — a before-and-after over time, one lease, one running build.
113
+ * That is a different question from *"how do these two builds differ"*,
114
+ * where the two things being compared are not two moments of one tab but two
115
+ * separate runs, often of separate processes. This surface's unit is one
116
+ * lease holding one tab, which is the right shape for the first question and
117
+ * the wrong one for the second: a two-build comparison wants something that
118
+ * reads the scene or the DOM directly, not pixels from whichever tab happened
119
+ * to be open.
120
+ *
121
+ * ── Why this is worth a sentence rather than leaving it to be inferred ────
122
+ *
123
+ * A caller framing its task as "compare two builds" will reach for whatever
124
+ * on this surface has the word "compare" in it, and `compare_to` is the only
125
+ * candidate. Nothing here refuses that call — a diff still runs and still
126
+ * answers a real question about the two pixels it was given — so the caller
127
+ * gets an answer that looks like the one it asked for while measuring
128
+ * something else. A routing hint at the point of the call is the only thing
129
+ * that can catch this before the wrong tool is already in use; there is no
130
+ * refusal to word better; the call succeeds.
131
+ */
132
+ const CAPTURE_BUILD_COMPARISON_CAVEAT = 'For comparing two builds — not two moments of the one tab you hold — a tool reading the scene ' +
133
+ 'or DOM directly beats this one: this surface is one lease, one tab, pixels.';
3
134
  /** Every tool takes the key except the first and the last (§3.1). */
4
135
  const LEASE_KEY = {
5
136
  name: 'lease_key',
@@ -107,7 +238,16 @@ export const TOOL_DEFINITIONS = [
107
238
  name: 'wait_ms',
108
239
  type: 'integer',
109
240
  required: false,
110
- description: 'How long to wait for the page, in milliseconds.',
241
+ description: 'How long the navigation may take before it is abandoned, in whole milliseconds. ' +
242
+ 'A bound, not a pause: the call returns as soon as the page is there, so a larger ' +
243
+ 'value costs nothing on a page that loads quickly and two calls differing only in ' +
244
+ 'this argument tell you nothing about how long the page was given to settle. ' +
245
+ 'It bounds the load only, and does not wait for work the page starts afterwards, so ' +
246
+ 'a canvas or a lazily-loaded region can still be unfinished when this returns; see ' +
247
+ 'browser_capture on how to tell. ' +
248
+ 'At most the lease lifetime, because a wait outliving the lease would hold the tab ' +
249
+ 'past the point it becomes reclaimable; the refusal names the accepted range. ' +
250
+ 'Omit it to leave the browser default in force.',
111
251
  },
112
252
  ],
113
253
  },
@@ -175,7 +315,10 @@ export const TOOL_DEFINITIONS = [
175
315
  operation: 'capture',
176
316
  description: 'Take a picture of the page — and, if you name an earlier capture, what changed since it. ' +
177
317
  'Returns paths, never the image itself. A selector and a full page cannot both be asked ' +
178
- 'for. Never refused for cost.',
318
+ 'for. Never refused for cost. ' +
319
+ CAPTURE_SETTLE_CAVEAT +
320
+ ' ' +
321
+ CAPTURE_BUILD_COMPARISON_CAVEAT,
179
322
  arguments: [
180
323
  LEASE_KEY,
181
324
  {
@@ -196,11 +339,19 @@ export const TOOL_DEFINITIONS = [
196
339
  required: false,
197
340
  description: 'An earlier capture to diff against. The diff rides here rather than being its own tool.',
198
341
  },
342
+ {
343
+ name: 'tier',
344
+ type: 'string',
345
+ required: false,
346
+ 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
+ },
199
349
  {
200
350
  name: 'reason',
201
351
  type: 'string',
202
352
  required: false,
203
- description: 'Free text, recorded, never refused — why this capture needed more than the default tier.',
353
+ description: 'Free text, 8-200 characters, recorded, never refused — why this capture needed more ' +
354
+ 'than the default tier. Required with tier="max".',
204
355
  },
205
356
  ],
206
357
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "browser-broker",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
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",
@@ -19,12 +19,13 @@
19
19
  "check:operations": "node scripts/check-operations.mjs",
20
20
  "check:argument-refusals": "node scripts/check-argument-refusals.mjs",
21
21
  "check:injected-tests": "node scripts/check-injected-tests.mjs",
22
+ "check:argument-reachability": "node scripts/check-argument-reachability.mjs",
22
23
  "typecheck": "tsc --noEmit",
23
24
  "lint": "eslint .",
24
25
  "format": "prettier --write .",
25
26
  "format:check": "prettier --check .",
26
27
  "test": "node --test \"tests/**/*.test.mjs\" \"tests/**/*.test.ts\"",
27
- "check": "npm run check:external-refs && npm run check:doc-links && npm run check:arbitration && npm run check:capture-isolation && npm run check:artifact-path && npm run typecheck && npm run lint && npm run format:check && npm test && npm run check:install && npm run check:operations && npm run check:argument-refusals && npm run check:injected-tests",
28
+ "check": "npm run check:external-refs && npm run check:doc-links && npm run check:arbitration && npm run check:capture-isolation && npm run check:artifact-path && npm run typecheck && npm run lint && npm run format:check && npm test && npm run check:install && npm run check:operations && npm run check:argument-refusals && npm run check:argument-reachability && npm run check:injected-tests && npm run check:package",
28
29
  "check:arbitration": "node scripts/check-arbitration.mjs",
29
30
  "check:artifact-path": "node scripts/check-artifact-path.mjs",
30
31
  "build": "tsc --project tsconfig.build.json",