surf-cli 2.14.0 → 2.15.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.
package/README.md CHANGED
@@ -142,7 +142,7 @@ surf remote revoke agent-macbook
142
142
  surf remote list
143
143
  ```
144
144
 
145
- `--remote <host>:<port>` takes precedence over `SURF_REMOTE`; `--remote-credential` takes precedence over `SURF_REMOTE_CREDENTIAL`. A selected remote endpoint overrides `SURF_SOCKET` and the default local socket. Local and remote requests share one bounded FIFO browser lease, so they cannot race each other. Disconnects and timeouts abort queued or in-flight work and hold the lease until request-owned cleanup drains or the hard deadline is reached. Browser side effects that already completed are not rolled back.
145
+ `--remote <host>:<port>` takes precedence over `SURF_REMOTE`; `--remote-credential` takes precedence over `SURF_REMOTE_CREDENTIAL`. A selected remote endpoint overrides `SURF_SOCKET` and the default local socket. Local and remote requests share the same host scheduler: each tab has a FIFO lane, different tabs may execute concurrently, and browser-wide writers are exclusive. Disconnects and timeouts abort queued or in-flight work and retain admission until request-owned cleanup drains or the hard deadline is reached. Browser side effects that already completed are not rolled back.
146
146
 
147
147
  `surf install --listen` persists the explicit Tailnet address in the native-host wrapper. Re-run `surf install` without `--listen` to remove it. The address must be a Tailscale IPv4 or IPv6 address with a port; Surf does not bind every interface. Remote listeners currently require a POSIX browser host and are not supported by Windows native-host wrappers.
148
148
 
@@ -348,44 +348,65 @@ surf tab.switch "dashboard" # Switch by name
348
348
  surf tab.group --name "Work" --color blue
349
349
  ```
350
350
 
351
- ### Window Isolation
351
+ ### Browser Sessions and Concurrent Agents
352
352
 
353
- Keep using your browser while the agent works in a separate window:
353
+ Give every independent agent a durable Surf session before its first browser command. `session.ensure` is idempotent: it creates a missing session, reuses a live one, and reopens a stale or closed binding.
354
354
 
355
355
  ```bash
356
- # Create a separate window for agent work
357
- surf window.new "https://example.com"
358
- # Returns: Window 123456 (tab 789)
356
+ # First command rule for every independent agent shell
357
+ export SURF_SESSION="$(basename "$PWD" | sed 's/[^A-Za-z0-9._-]/-/g')"
358
+ surf session.ensure "$SURF_SESSION" about:blank
359
359
 
360
- # Target that window or its tab from later commands
361
- surf click e5 --window-id 123456
362
- surf read --tab-id 789
363
- surf tab.new "https://other.com" --window-id 123456
360
+ # All later tab-scoped commands use that session automatically
361
+ surf go "https://example.com"
362
+ surf read
363
+ surf click e5
364
+ ```
364
365
 
365
- # Name tabs when humans or agents need stable aliases
366
- surf tab.name dashboard --tab-id 789
367
- surf tab.switch dashboard
366
+ Use a distinct worktree/directory name per agent. When several agents share one directory, append a stable agent identifier instead of reusing the same `SURF_SESSION` value.
367
+
368
+ A session owns one explicit Chrome tab. New sessions use a separate **unfocused normal window** by default, so Chrome focus changes cannot retarget another agent's commands.
369
+
370
+ ```bash
371
+ surf session.new research "https://example.com" # separate unfocused window
372
+ surf session.ensure research about:blank # safe to run repeatedly
373
+ surf session.new scout about:blank --tab # inactive tab instead
374
+
375
+ surf --session research read # explicit selector
376
+ SURF_SESSION=research surf screenshot # environment selector
368
377
 
369
- # Or manage windows directly
370
- surf window.list # List all windows
371
- surf window.list --tabs # Include tab details
372
- surf window.focus 123456 # Bring window to front
373
- surf window.close 123456 # Close window
378
+ surf session.list --refresh # all bindings + queue state
379
+ surf session.info research --refresh # target and scheduler details
380
+ surf session.close research # closes Surf-created target
381
+ surf session.rebind research --tab-id 789 # adopt an existing tab
382
+ surf session.reopen research # recreate from last URL
374
383
  ```
375
384
 
376
- `window.new`, `--window-id`, `--tab-id`, and named tabs are Surf's supported coordination tools for parallel workflows. They help agents avoid accidentally driving the same visible tab.
385
+ Commands for the same session tab run FIFO. Commands for different session tabs can overlap. Browser-wide mutations—such as creating, moving, closing, or focusing tabs/windows and writing cookies—wait for active tab lanes to drain. Add `--no-wait` to return `tab_busy` or `browser_busy` immediately instead of queueing.
377
386
 
378
- Surf also serializes non-streaming browser CLI requests per socket with a file-based lock, so two agents sharing the same native host wait instead of interleaving browser commands. Use `--no-lock` only when you intentionally want to bypass the guard for a command.
387
+ Recovery errors print an exact command that can be copied directly:
379
388
 
380
- For hard isolation, run separate browser instances/profiles with separate Surf native hosts and socket paths, then point each shell at the matching socket. Each socket has its own independent lock:
389
+ ```text
390
+ Error: The tab for session research is gone.
391
+ Recovery: surf session.reopen research
392
+ ```
393
+
394
+ `session.info` distinguishes work queued on the session's own tab, activity on other tabs, and an active or waiting browser-wide writer. Browser-login provider commands such as `surf chatgpt`, `surf gemini`, and `surf oracle ask` print a warning before taking exclusive browser access, so a queued provider flow is not mistaken for a hung command.
395
+
396
+ Sessions share the same Chrome profile. Cookies, authentication, same-origin storage, downloads, history, bookmarks, and other profile state are shared. For hard isolation, use separate browser profiles/instances with separate native hosts and `SURF_SOCKET` values.
397
+
398
+ ### Explicit Tabs and Windows
399
+
400
+ Session targeting is the recommended coordination mechanism. Explicit IDs and named tabs remain available for one-off work:
381
401
 
382
402
  ```bash
383
- SURF_SOCKET=/tmp/surf-agent-a.sock surf tab.list
384
- SURF_SOCKET=/tmp/surf-agent-b.sock surf tab.list
403
+ surf window.new "https://example.com"
404
+ surf read --tab-id 789
405
+ surf click e5 --window-id 123456
406
+ surf tab.name dashboard --tab-id 789
407
+ surf tab.switch dashboard
385
408
  ```
386
409
 
387
- Surf does not yet provide `session.new`, session IDs, or independent per-agent CDP sessions.
388
-
389
410
  ### Device Emulation
390
411
 
391
412
  Test responsive designs and mobile layouts:
@@ -505,7 +526,7 @@ surf aistudio.build "game" --keep-open --timeout 600 # Keep tab open, 1
505
526
 
506
527
  #### Oracle
507
528
 
508
- Use `surf oracle` for a durable, local ChatGPT consult instead of a quick `surf chatgpt` one-shot. It persists jobs by conversation URL, supports repeatable file-context globs, and verifies requested model and reasoning effort before submission. ChatGPT model aliases include `instant`, `thinking`, `pro`, `gpt-5.5`, and `gpt-5.6-sol`.
529
+ Use `surf oracle` for a durable, local ChatGPT consult instead of a quick `surf chatgpt` one-shot. It persists jobs by conversation URL, supports repeatable file-context globs, and verifies requested model and reasoning effort before submission. ChatGPT model aliases include `instant`, `thinking`, `pro`, `gpt-5.5`, and `gpt-5.6-sol`; `pro` selects the current ChatGPT GPT-5.6 Sol Pro web mode.
509
530
 
510
531
  ```bash
511
532
  surf oracle ask "review this change" --files "src/**/*.ts" --model gpt-5.5 --effort pro --detach --json
@@ -764,11 +785,13 @@ Generated manifests declare provenance and authentication environment inputs. Su
764
785
  ## Global Options
765
786
 
766
787
  ```bash
767
- --tab-id <id> # Target specific tab
768
- --window-id <id> # Target specific window (isolate agent from your browsing)
769
- --json # Output raw JSON
788
+ --session <name> # Target a durable browser session (or set SURF_SESSION)
789
+ --tab-id <id> # Target a specific tab
790
+ --window-id <id> # Target a specific window
791
+ --no-wait # Return tab_busy/browser_busy instead of queueing
792
+ --json # Raw JSON including resolved target metadata
770
793
  --soft-fail # Warn instead of error (exit 0) on restricted pages
771
- --no-lock # Bypass the per-socket browser request lock
794
+ --no-lock # Bypass the legacy lock for compound client-side commands
772
795
  --no-screenshot # Skip auto-screenshot after actions
773
796
  --full # Full resolution screenshots (skip resize)
774
797
  ```
@@ -777,6 +800,8 @@ Generated manifests declare provenance and authentication environment inputs. Su
777
800
 
778
801
  ```bash
779
802
  SURF_NETWORK_PATH # Native-host network state root (default: ~/.surf/state/network)
803
+ SURF_STATE_DIR # Private Surf state root, including browser sessions (default: ~/.surf/state)
804
+ SURF_SESSION # Default named browser session for tab-scoped commands
780
805
  SURF_SOCKET # Socket path or named pipe (default: /tmp/surf.sock, Windows: //./pipe/surf)
781
806
  SURF_REMOTE # Remote Surf endpoint as host:port (overrides SURF_SOCKET)
782
807
  SURF_REMOTE_CREDENTIAL # Client Ed25519 credential for the selected remote endpoint
@@ -788,7 +813,9 @@ SURF_EXTENSION_PATH # Path to extension dist/ directory
788
813
  ```
789
814
 
790
815
  **Use cases:**
791
- - `SURF_SOCKET`: Advanced socket override. Set it for both the native host and CLI if you need a non-default socket, including separate sockets for separate browser/profile instances in hard-isolated multi-agent workflows. Each socket gets an independent request lock.
816
+ - `SURF_SESSION`: Per-shell default session. Give each independent agent a unique value and run `surf session.ensure "$SURF_SESSION" about:blank` before its first browser command.
817
+ - `SURF_STATE_DIR`: Private mode-0700 state root for durable browser-session bindings and other Surf state.
818
+ - `SURF_SOCKET`: Advanced socket override. Set it for both the native host and CLI when separate browser/profile instances need hard isolation.
792
819
  - `SURF_REMOTE`: Remote client endpoint. `--remote <host>:<port>` overrides it; both override `SURF_SOCKET`.
793
820
  - `SURF_REMOTE_CREDENTIAL`: Credential used for mutual remote authentication. `--remote-credential <path>` overrides it.
794
821
  - `SURF_REMOTE_STATE_DIR`: Advanced host-side override for the mode-0700 identity and client registry directory.
@@ -969,9 +996,11 @@ pi -e /path/to/surf-cli/pi-extension/surf.ts
969
996
 
970
997
  It registers `surf_read`, `surf_screenshot`, `surf_click`, `surf_type`, `surf_tool`, and the `surf_oracle_*` tools. Browser calls use Surf's native-host socket, not shell commands. If `pi-subagents/background-work` is installed, the extension also reports active oracle jobs started by that Pi session. Pi still loads the browser tools when pi-subagents is not installed.
971
998
 
972
- The extension also registers a `surf-oracle` external-job provider when a Pi runtime exposes that provider bridge. The provider has `start`, `status`, `result`, `reattach`, and `follow` operations. Each operation returns Surf job metadata with the durable conversation URL, requested and verified ChatGPT model and effort, prompt digest, result text when captured, and failure details when present. Capacity stays fail-closed: Surf returns the blocking job id instead of silently queueing a second ChatGPT job.
999
+ The extension also registers a `surf-oracle` external-job provider when a Pi runtime exposes that provider bridge. The provider has `start`, `status`, `result`, `reattach`, and `follow` operations. It reads `options.model` and `options.effort` for starts and follows, so a Pi profile can request `model: pro` and reach the ChatGPT GPT-5.6 Sol Pro web mode through Surf. Each operation returns Surf job metadata with the durable conversation URL, requested and verified ChatGPT model and effort, prompt digest, result text when captured, and failure details when present. Capacity stays fail-closed: Surf returns the blocking job id instead of silently queueing a second ChatGPT job.
1000
+
1001
+ When Surf is installed as a Pi package, it also exposes an optional `gpt-pro` package agent for `pi-subagents`. That profile uses `runner.type: external-job`, provider `surf-oracle`, and `options.model: pro`. Surf remains useful without Pi or `pi-subagents`; the package agent only wires Surf's browser-backed model alias into Pi's agent picker.
973
1002
 
974
- Surf agents share one browser session. Use read tools for parallel scouts when possible. `surf_click` and `surf_type` can interfere with another agent's browser actions. Browser leases are not available yet.
1003
+ Shell-based agents should select a unique session with `SURF_SESSION` and call `surf session.ensure` before their first browser command. The optional Pi extension still uses its existing socket-tool interface; callers that coordinate several Pi workers should pass explicit tab targets until session selection is exposed by that integration.
975
1004
 
976
1005
  ## Development
977
1006
 
@@ -0,0 +1,19 @@
1
+ ---
2
+ name: gpt-pro
3
+ description: Surf GPT Pro advisor through ChatGPT GPT-5.6 Sol Pro web mode
4
+ runner:
5
+ type: external-job
6
+ provider: surf-oracle
7
+ options:
8
+ model: pro
9
+ async: true
10
+ systemPromptMode: replace
11
+ inheritProjectContext: false
12
+ inheritSkills: false
13
+ ---
14
+
15
+ You are a read-only GPT Pro advisor reached through Surf Oracle.
16
+
17
+ Review the supplied task and context.
18
+ Return clear advice, risks, and recommended next steps.
19
+ Do not claim you edited files or ran local tools.
@@ -0,0 +1,348 @@
1
+ const { surfError } = require("./surf-error.cjs");
2
+
3
+ const DEFAULT_MAX_QUEUED = 64;
4
+ const DEFAULT_MAX_PER_LANE = 16;
5
+ const DEFAULT_QUEUE_TIMEOUT_MS = 60000;
6
+ const WRITE_SCOPES = new Set(["browser-write", "provider"]);
7
+
8
+ function normalizeResourceKeys(resourceKeys) {
9
+ if (!Array.isArray(resourceKeys)) return [];
10
+ return [...new Set(resourceKeys.filter((key) => typeof key === "string" && key).map(String))].sort();
11
+ }
12
+
13
+ class BrowserScheduler {
14
+ constructor({
15
+ maxQueued = DEFAULT_MAX_QUEUED,
16
+ maxPerLane = DEFAULT_MAX_PER_LANE,
17
+ queueTimeoutMs = DEFAULT_QUEUE_TIMEOUT_MS,
18
+ audit = () => {},
19
+ } = {}) {
20
+ this.maxQueued = maxQueued;
21
+ this.maxPerLane = maxPerLane;
22
+ this.queueTimeoutMs = queueTimeoutMs;
23
+ this.audit = audit;
24
+ this.queue = [];
25
+ this.activeTabs = new Map();
26
+ this.activeReaders = new Set();
27
+ this.activeWriter = null;
28
+ this.activeResources = new Map();
29
+ this.sequence = 0;
30
+ }
31
+
32
+ acquire({ scope, laneKey, resourceKeys = [], wait = true, signal, request, session } = {}) {
33
+ const normalizedResources = normalizeResourceKeys(resourceKeys);
34
+ if (scope === "host" && normalizedResources.length === 0) {
35
+ return Promise.resolve(this.#token({
36
+ scope,
37
+ laneKey,
38
+ resourceKeys: normalizedResources,
39
+ request,
40
+ session,
41
+ queuedAt: Date.now(),
42
+ }));
43
+ }
44
+ if (scope === "tab" && !laneKey) {
45
+ return Promise.reject(surfError("target_required", "tab-scoped command requires a resolved tab"));
46
+ }
47
+ if (!scope || !["host", "tab", "browser-read", "browser-write", "provider"].includes(scope)) {
48
+ return Promise.reject(surfError("scheduler_scope_invalid", `invalid browser scheduler scope: ${scope}`));
49
+ }
50
+ if (signal?.aborted) return Promise.reject(signal.reason || surfError("request_cancelled", "Request cancelled"));
51
+
52
+ const entry = {
53
+ id: ++this.sequence,
54
+ scope,
55
+ laneKey,
56
+ resourceKeys: normalizedResources,
57
+ wait,
58
+ signal,
59
+ request,
60
+ session,
61
+ queuedAt: Date.now(),
62
+ resolve: null,
63
+ reject: null,
64
+ timer: null,
65
+ abortCleanup: null,
66
+ };
67
+
68
+ if (this.#canGrantImmediately(entry)) {
69
+ return Promise.resolve(this.#grant(entry));
70
+ }
71
+ if (!wait) {
72
+ const blockedResources = this.#blockedResourceKeys(entry);
73
+ if (blockedResources.length > 0) {
74
+ return Promise.reject(surfError("resource_busy", `shared resource is busy: ${blockedResources.join(", ")}`, {
75
+ laneKey,
76
+ session,
77
+ resourceKeys: blockedResources,
78
+ retryable: true,
79
+ queue: this.stats({ laneKey, resourceKeys: normalizedResources }),
80
+ recoveryCommand: session ? `surf session.info ${session}` : "surf session.list --refresh",
81
+ }));
82
+ }
83
+ const browserBlocked = Boolean(this.activeWriter) || this.#hasQueuedWriter() || WRITE_SCOPES.has(scope);
84
+ const code = browserBlocked ? "browser_busy" : "tab_busy";
85
+ const message = browserBlocked
86
+ ? "a browser-wide writer is active or waiting"
87
+ : `tab lane is busy: ${laneKey}`;
88
+ return Promise.reject(surfError(code, message, {
89
+ laneKey,
90
+ session,
91
+ retryable: true,
92
+ queue: this.stats({ laneKey, resourceKeys: normalizedResources }),
93
+ recoveryCommand: session ? `surf session.info ${session}` : "surf session.list --refresh",
94
+ }));
95
+ }
96
+ if (this.queue.length >= this.maxQueued) {
97
+ return Promise.reject(surfError("queue_full", "browser scheduler queue is full", { retryable: true }));
98
+ }
99
+ if (scope === "tab") {
100
+ const laneDepth = this.queue.filter((queued) => queued.scope === "tab" && queued.laneKey === laneKey).length;
101
+ if (laneDepth >= this.maxPerLane) {
102
+ return Promise.reject(surfError("queue_full", `tab lane queue is full: ${laneKey}`, {
103
+ laneKey,
104
+ retryable: true,
105
+ }));
106
+ }
107
+ }
108
+
109
+ return new Promise((resolve, reject) => {
110
+ entry.resolve = resolve;
111
+ entry.reject = reject;
112
+ entry.timer = setTimeout(() => {
113
+ this.#removeQueued(entry);
114
+ reject(surfError("queue_timeout", "timed out waiting for browser admission", {
115
+ laneKey,
116
+ session,
117
+ resourceKeys: normalizedResources,
118
+ retryable: true,
119
+ queue: this.stats({ laneKey, resourceKeys: normalizedResources }),
120
+ recoveryCommand: session ? `surf session.info ${session}` : "surf session.list --refresh",
121
+ }));
122
+ this.audit({
123
+ event: "scheduler",
124
+ outcome: "queue-timeout",
125
+ request,
126
+ scope,
127
+ laneKey,
128
+ resourceKeys: normalizedResources,
129
+ });
130
+ this.#drain();
131
+ }, this.queueTimeoutMs);
132
+ if (signal) {
133
+ const onAbort = () => {
134
+ if (!this.#removeQueued(entry)) return;
135
+ reject(signal.reason || surfError("request_cancelled", "Request cancelled"));
136
+ this.audit({
137
+ event: "scheduler",
138
+ outcome: "queue-cancel",
139
+ request,
140
+ scope,
141
+ laneKey,
142
+ resourceKeys: normalizedResources,
143
+ });
144
+ this.#drain();
145
+ };
146
+ signal.addEventListener("abort", onAbort, { once: true });
147
+ entry.abortCleanup = () => signal.removeEventListener("abort", onAbort);
148
+ }
149
+ this.queue.push(entry);
150
+ this.audit({
151
+ event: "scheduler",
152
+ outcome: "queued",
153
+ request,
154
+ scope,
155
+ laneKey,
156
+ resourceKeys: normalizedResources,
157
+ queueDepth: this.queue.length,
158
+ });
159
+ this.#drain();
160
+ });
161
+ }
162
+
163
+ stats({ laneKey, resourceKeys = [] } = {}) {
164
+ const queuedByLane = {};
165
+ for (const entry of this.queue) {
166
+ if (entry.scope !== "tab") continue;
167
+ queuedByLane[entry.laneKey] = (queuedByLane[entry.laneKey] || 0) + 1;
168
+ }
169
+ const writer = this.activeWriter
170
+ ? {
171
+ scope: this.activeWriter.scope,
172
+ session: this.activeWriter.session || null,
173
+ acquiredAt: this.activeWriter.acquiredAt,
174
+ }
175
+ : null;
176
+ const queuedWriters = this.queue.filter((entry) => WRITE_SCOPES.has(entry.scope));
177
+ const normalizedResources = normalizeResourceKeys(resourceKeys);
178
+ const activeResources = [...this.activeResources.entries()].map(([key, token]) => ({
179
+ key,
180
+ scope: token.scope,
181
+ laneKey: token.laneKey || null,
182
+ session: token.session || null,
183
+ acquiredAt: token.acquiredAt,
184
+ }));
185
+ return {
186
+ activeTabLanes: [...this.activeTabs.entries()].map(([key, token]) => ({
187
+ laneKey: key,
188
+ session: token.session || null,
189
+ acquiredAt: token.acquiredAt,
190
+ })),
191
+ activeReaders: this.activeReaders.size,
192
+ writerActive: Boolean(writer),
193
+ writer,
194
+ activeResources,
195
+ blockedResourceKeys: normalizedResources.filter((key) => this.activeResources.has(key)),
196
+ queued: this.queue.length,
197
+ queuedWriters: queuedWriters.length,
198
+ queuedWriterSessions: queuedWriters.map((entry) => entry.session || null),
199
+ queuedByLane,
200
+ lane: laneKey ? {
201
+ laneKey,
202
+ active: this.activeTabs.has(laneKey),
203
+ queued: queuedByLane[laneKey] || 0,
204
+ blockedBy: this.activeWriter || queuedWriters.length > 0
205
+ ? "browser-writer"
206
+ : this.activeTabs.has(laneKey)
207
+ ? "own-tab"
208
+ : null,
209
+ } : undefined,
210
+ };
211
+ }
212
+
213
+ #blockedResourceKeys(entry) {
214
+ return entry.resourceKeys.filter((key) => this.activeResources.has(key));
215
+ }
216
+
217
+ #hasQueuedWriter() {
218
+ return this.queue.some((entry) => WRITE_SCOPES.has(entry.scope));
219
+ }
220
+
221
+ #canGrantImmediately(entry) {
222
+ if (this.queue.length > 0) return false;
223
+ if (this.#blockedResourceKeys(entry).length > 0) return false;
224
+ if (entry.scope === "host") return true;
225
+ if (WRITE_SCOPES.has(entry.scope)) {
226
+ return !this.activeWriter && this.activeReaders.size === 0 && this.activeTabs.size === 0;
227
+ }
228
+ if (this.activeWriter || this.#hasQueuedWriter()) return false;
229
+ if (entry.scope === "browser-read") return true;
230
+ return !this.activeTabs.has(entry.laneKey);
231
+ }
232
+
233
+ #grant(entry) {
234
+ if (entry.timer) clearTimeout(entry.timer);
235
+ entry.abortCleanup?.();
236
+ const token = this.#token(entry);
237
+ if (WRITE_SCOPES.has(entry.scope)) this.activeWriter = token;
238
+ else if (entry.scope === "browser-read") this.activeReaders.add(token);
239
+ else if (entry.scope === "tab") this.activeTabs.set(entry.laneKey, token);
240
+ for (const key of entry.resourceKeys) this.activeResources.set(key, token);
241
+ this.audit({
242
+ event: "scheduler",
243
+ outcome: "acquired",
244
+ request: entry.request,
245
+ scope: entry.scope,
246
+ laneKey: entry.laneKey,
247
+ resourceKeys: entry.resourceKeys,
248
+ queueMs: Date.now() - entry.queuedAt,
249
+ });
250
+ return token;
251
+ }
252
+
253
+ #token(entry) {
254
+ let released = false;
255
+ const token = {
256
+ scope: entry.scope,
257
+ laneKey: entry.laneKey,
258
+ resourceKeys: entry.resourceKeys,
259
+ session: entry.session,
260
+ queuedAt: entry.queuedAt,
261
+ acquiredAt: Date.now(),
262
+ release: () => {
263
+ if (released) return;
264
+ released = true;
265
+ if (WRITE_SCOPES.has(entry.scope)) {
266
+ if (this.activeWriter === token) this.activeWriter = null;
267
+ } else if (entry.scope === "browser-read") this.activeReaders.delete(token);
268
+ else if (entry.scope === "tab" && this.activeTabs.get(entry.laneKey) === token) {
269
+ this.activeTabs.delete(entry.laneKey);
270
+ }
271
+ for (const key of entry.resourceKeys) {
272
+ if (this.activeResources.get(key) === token) this.activeResources.delete(key);
273
+ }
274
+ this.audit({
275
+ event: "scheduler",
276
+ outcome: "released",
277
+ request: entry.request,
278
+ scope: entry.scope,
279
+ laneKey: entry.laneKey,
280
+ resourceKeys: entry.resourceKeys,
281
+ session: entry.session,
282
+ });
283
+ this.#drain();
284
+ },
285
+ };
286
+ return token;
287
+ }
288
+
289
+ #removeQueued(entry) {
290
+ const index = this.queue.indexOf(entry);
291
+ if (index === -1) return false;
292
+ this.queue.splice(index, 1);
293
+ if (entry.timer) clearTimeout(entry.timer);
294
+ entry.abortCleanup?.();
295
+ return true;
296
+ }
297
+
298
+ #drain() {
299
+ for (let index = 0; index < this.queue.length;) {
300
+ const entry = this.queue[index];
301
+ if (entry.scope !== "host" || this.#blockedResourceKeys(entry).length > 0) {
302
+ index += 1;
303
+ continue;
304
+ }
305
+ this.queue.splice(index, 1);
306
+ entry.resolve(this.#grant(entry));
307
+ }
308
+
309
+ if (this.activeWriter) return;
310
+ const writerIndex = this.queue.findIndex((entry) => WRITE_SCOPES.has(entry.scope));
311
+ if (writerIndex !== -1) {
312
+ const writer = this.queue[writerIndex];
313
+ if (
314
+ this.activeReaders.size > 0 ||
315
+ this.activeTabs.size > 0 ||
316
+ this.#blockedResourceKeys(writer).length > 0
317
+ ) return;
318
+ this.queue.splice(writerIndex, 1);
319
+ writer.resolve(this.#grant(writer));
320
+ return;
321
+ }
322
+
323
+ for (let index = 0; index < this.queue.length;) {
324
+ const entry = this.queue[index];
325
+ if (this.#blockedResourceKeys(entry).length > 0) {
326
+ index += 1;
327
+ continue;
328
+ }
329
+ let grant = false;
330
+ if (entry.scope === "browser-read") grant = true;
331
+ else if (entry.scope === "tab") grant = !this.activeTabs.has(entry.laneKey);
332
+ if (!grant) {
333
+ index += 1;
334
+ continue;
335
+ }
336
+ this.queue.splice(index, 1);
337
+ entry.resolve(this.#grant(entry));
338
+ }
339
+ }
340
+ }
341
+
342
+ module.exports = {
343
+ BrowserScheduler,
344
+ DEFAULT_MAX_PER_LANE,
345
+ DEFAULT_MAX_QUEUED,
346
+ DEFAULT_QUEUE_TIMEOUT_MS,
347
+ normalizeResourceKeys,
348
+ };