surf-cli 2.8.0 → 2.10.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.
Files changed (47) hide show
  1. package/README.md +146 -8
  2. package/native/abort.cjs +65 -0
  3. package/native/activity-journal.cjs +55 -0
  4. package/native/ai-queue.cjs +64 -0
  5. package/native/aistudio-build.cjs +21 -13
  6. package/native/aistudio-client.cjs +40 -20
  7. package/native/browser-lock.cjs +2 -2
  8. package/native/chatgpt-client.cjs +49 -31
  9. package/native/cli.cjs +352 -482
  10. package/native/client-transport.cjs +168 -0
  11. package/native/do-executor.cjs +68 -510
  12. package/native/do-parser.cjs +8 -249
  13. package/native/doctor.cjs +55 -5
  14. package/native/endpoint.cjs +174 -0
  15. package/native/file-transfer.cjs +734 -0
  16. package/native/gemini-client.cjs +156 -71
  17. package/native/grok-client.cjs +98 -89
  18. package/native/host-helpers.cjs +43 -26
  19. package/native/host-sessions.cjs +287 -0
  20. package/native/host.cjs +998 -620
  21. package/native/listener.cjs +20 -0
  22. package/native/mcp-server.cjs +60 -65
  23. package/native/network-export.cjs +116 -0
  24. package/native/network-store.cjs +38 -58
  25. package/native/perplexity-client.cjs +46 -17
  26. package/native/playbook-authoring.cjs +44 -0
  27. package/native/playbook-cli.cjs +157 -0
  28. package/native/playbook-client.cjs +259 -0
  29. package/native/playbook-receipts.cjs +109 -0
  30. package/native/playbook-records.cjs +208 -0
  31. package/native/playbook-runtime.cjs +177 -0
  32. package/native/playbooks.cjs +235 -0
  33. package/native/private-state.cjs +156 -0
  34. package/native/redaction.cjs +104 -0
  35. package/native/remote-auth.cjs +279 -0
  36. package/native/remote-transport.cjs +337 -0
  37. package/native/request-pending.cjs +148 -0
  38. package/native/socket-path.cjs +1 -1
  39. package/native/workflow-definition.cjs +368 -0
  40. package/native/workflow-runtime.cjs +225 -0
  41. package/package.json +9 -6
  42. package/playbooks/page/ops/read.json +22 -0
  43. package/playbooks/page/playbook.json +7 -0
  44. package/scripts/install-native-host.cjs +36 -5
  45. package/skills/README.md +11 -5
  46. package/skills/deep-x-research/SKILL.md +106 -0
  47. package/skills/surf/SKILL.md +72 -5
package/README.md CHANGED
@@ -43,7 +43,7 @@ Surf takes a different approach:
43
43
  |---------|------|-------|------------------|--------------|-------------|
44
44
  | Agent-agnostic | Yes | No (Manus only) | No (Claude only) | Partial | No (Claude skill) |
45
45
  | Zero config | Yes | No (subscription) | No (subscription) | No (MCP setup) | No (relay server) |
46
- | Local-only | Yes | No (cloud) | Partial | Yes | Partial |
46
+ | Self-hosted (local or Tailnet) | Yes | No (cloud) | Partial | Yes | Partial |
47
47
  | CLI interface | Yes | No | No | No | No |
48
48
  | Free | Yes | No | No | Yes | Yes |
49
49
  | AI via browser cookies | Yes | No | No | No | No |
@@ -108,6 +108,88 @@ surf uninstall --all # All browsers + wrapper files
108
108
  surf uninstall --target linux # Remove WSLg/Linux-browser config from WSL2
109
109
  ```
110
110
 
111
+ ### Remote Surf over Tailscale
112
+
113
+ Remote Surf runs the browser and native host on one Tailnet machine while the CLI runs on another. The listener is available only while the browser extension's native-messaging connection is alive. Tailnet reachability is not authorization: every remote client also needs its own Surf credential.
114
+
115
+ On the browser host, authorize a client before installing the listener:
116
+
117
+ ```bash
118
+ surf remote authorize agent-macbook --output ~/agent-macbook.surf-credential.json
119
+ surf remote list
120
+ surf install <extension-id> --listen 100.101.102.103:4321
121
+ ```
122
+
123
+ `authorize` creates a mode-0600 credential containing the client's Ed25519 private identity and the pinned host identity. Move it to that client through an existing secure channel, then remove the generated copy from the host if it is no longer needed there. The host keeps only the client's public identity in `~/.surf/remote/remote-clients.json`.
124
+
125
+ From the authorized client:
126
+
127
+ ```bash
128
+ surf --remote 100.101.102.103:4321 \
129
+ --remote-credential ~/.config/surf/agent-macbook.json \
130
+ tab.list
131
+
132
+ # Environment equivalent
133
+ SURF_REMOTE=100.101.102.103:4321 \
134
+ SURF_REMOTE_CREDENTIAL=~/.config/surf/agent-macbook.json \
135
+ surf tab.list
136
+ ```
137
+
138
+ Surf performs mutual Ed25519 challenge-response with fresh nonces and checks authorization throughout the connection. A credential grants the same browser and host-file authority as a trusted local Surf user. Give each client its own credential, do not share it, and revoke it immediately if the client or file is lost:
139
+
140
+ ```bash
141
+ surf remote revoke agent-macbook
142
+ surf remote list
143
+ ```
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.
146
+
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
+
149
+ Keep Tailscale policy restrictions as defense in depth. For example:
150
+
151
+ ```json
152
+ {
153
+ "acls": [
154
+ {
155
+ "action": "accept",
156
+ "src": ["tag:surf-agent"],
157
+ "dst": ["tag:surf-browser:4321"]
158
+ }
159
+ ]
160
+ }
161
+ ```
162
+
163
+ Adapt tags and ports to your Tailnet. Surf authentication does not replace Tailnet policy, and Surf does not add a separate TLS or SSH tunnel.
164
+
165
+ **Operations and troubleshooting**
166
+
167
+ ```bash
168
+ tailscale status
169
+ tailscale ping 100.101.102.103
170
+ surf doctor --remote 100.101.102.103:4321 \
171
+ --remote-credential ~/.config/surf/agent-macbook.json
172
+ ```
173
+
174
+ Use `tailscale status` and `tailscale ping` to confirm reachability, then use `doctor` to verify endpoint selection and authentication.
175
+
176
+ **Remote filesystem and transfer semantics**
177
+
178
+ Unprefixed paths and `local:` paths refer to the client. Only `remote:/absolute/path` refers directly to the browser host. For example:
179
+
180
+ ```bash
181
+ surf --remote "$SURF_REMOTE" --remote-credential "$SURF_REMOTE_CREDENTIAL" \
182
+ upload --ref e5 --files ./client-file.pdf
183
+ surf --remote "$SURF_REMOTE" --remote-credential "$SURF_REMOTE_CREDENTIAL" \
184
+ screenshot --output local:./shot.png
185
+ surf --remote "$SURF_REMOTE" --remote-credential "$SURF_REMOTE_CREDENTIAL" \
186
+ network.export --output remote:/var/tmp/network.har --har
187
+ ```
188
+
189
+ Client-local inputs are staged privately on the host and removed after the request. Client-local outputs are downloaded with size/hash verification and atomic destination replacement. `surf js --file` and `perf-audit --output` are handled by the client itself. `network.export` defaults to a generated client-local `.json`, `.jsonl`, or `.har` path. Gemini edits default to client-local `edited.png`. Successful remote actions transfer their automatic screenshot to a generated client-local path; `--auto-capture` on failure remains a separate screenshot and console diagnostic.
190
+
191
+ The remote single-file boundary supports one `upload` file, one ChatGPT attachment, or one Gemini attachment/edit input, plus one screenshot, network export, or Gemini image output. Transfers are limited to 256 MiB per file, 512 MiB and 32 files per connection, with 256 KiB decoded chunks. Remote `record`, `aistudio.build`, smoke screenshot directories, directory transfer, and multi-file inputs are intentionally rejected. A `remote:` path bypasses transfer and gives the trusted client direct authority over that absolute host path.
192
+
111
193
  ### Development Setup
112
194
 
113
195
  ```bash
@@ -146,6 +228,7 @@ surf read --no-text # Accessibility tree only (no text)
146
228
  surf read --depth 3 # Limit tree depth (smaller output)
147
229
  surf read --compact # Remove empty structural elements
148
230
  surf read --depth 3 --compact # Both (60% smaller output)
231
+ surf read --max-bytes 2000 # Cap visible text on a UTF-8 byte boundary
149
232
  surf page.text # Raw text content only
150
233
  surf page.state # Modals, loading state, scroll position
151
234
  ```
@@ -184,6 +267,7 @@ surf frame.switch --selector "#checkout-frame" # Switch by CSS selector
184
267
  # Now all commands target the iframe
185
268
  surf read # Read iframe content
186
269
  surf click e5 # Click in iframe
270
+ surf type "4242" --into "#card-number"
187
271
  surf locate.role button --action click
188
272
 
189
273
  surf frame.main # Return to main page
@@ -195,8 +279,9 @@ surf frame.main # Return to main page
195
279
  surf click e5 # Click by element ref
196
280
  surf click --selector ".btn" # Click by CSS selector
197
281
  surf click 100 200 # Click by coordinates
198
- surf type "hello" --submit # Type and press Enter
199
- surf type "email@example.com" --ref e12 # Type into specific element
282
+ surf type "hello" --submit # Type at the current focus with CDP events
283
+ surf type "email@example.com" --ref e12 # Fill an element from page.read
284
+ surf type "hello" --into "#message" # Fill a selector in the active frame
200
285
  surf key Escape # Press key
201
286
  surf scroll down 800 # Scroll down 800px
202
287
  surf scroll bottom # Scroll to bottom
@@ -252,6 +337,7 @@ surf tab.list
252
337
  surf tab.new "https://example.com"
253
338
  surf tab.switch 123
254
339
  surf tab.close 123
340
+ surf tab.move 123 --to-window 456 # Move one tab; use --ids 123,124 for several
255
341
  surf tab.name "dashboard" # Name current tab
256
342
  surf tab.switch "dashboard" # Switch by name
257
343
  surf tab.group --name "Work" --color blue
@@ -377,7 +463,7 @@ surf gemini "analyze" --file data.csv # Attach file
377
463
  surf gemini "a robot surfing" --generate-image /tmp/robot.png # Generate image
378
464
  surf gemini "add sunglasses" --edit-image photo.jpg --output out.jpg
379
465
  surf gemini "summarize" --youtube "https://youtube.com/..." # YouTube analysis
380
- surf gemini "hello" --model gemini-2.5-flash # Model selection
466
+ surf gemini "hello" --model gemini-3.5-flash # Model selection
381
467
 
382
468
  # Perplexity
383
469
  surf perplexity "what is quantum computing"
@@ -450,6 +536,7 @@ surf network --type json # Only JSON responses
450
536
  surf network --status 4xx,5xx # Only errors
451
537
  surf network --since 5m # Last 5 minutes
452
538
  surf network --exclude-static # Skip images/fonts/css/js
539
+ surf network -vv --body-mode text # Full entries with capped text bodies
453
540
 
454
541
  # Drill down
455
542
  surf network.get r_001 # Full request/response details
@@ -460,10 +547,12 @@ surf network.origins # List captured domains
460
547
  # Management
461
548
  surf network.clear # Clear captured data
462
549
  surf network.stats # Capture statistics
550
+ surf network.export --har --output ./trace.har
463
551
  ```
464
552
 
465
- Storage location: `/tmp/surf/` (override with `--network-path` or `SURF_NETWORK_PATH` env).
466
- Auto-cleanup: 24 hours TTL, 200MB max.
553
+ Response bodies are fetched at `Network.loadingFinished` when capture is enabled. `--body-mode none|text|all`, `--per-body-bytes`, and `--total-body-bytes` control content and caps; exports include completeness metadata.
554
+
555
+ Storage location: `~/.surf/state/network/` (override with `SURF_NETWORK_PATH` in the native host environment). Surf creates private `0700` directories and `0600` files and rejects symlink targets. Auto-cleanup: 24 hours TTL, 200MB max.
467
556
 
468
557
  ### Workflows
469
558
 
@@ -591,6 +680,48 @@ surf workflow.validate ./my-workflow.json
591
680
 
592
681
  **Supported commands:** All surf commands work in workflows. Use aliases (`go`, `snap`, `read`) or full names (`navigate`, `screenshot`, `page.read`).
593
682
 
683
+ ### Playbooks
684
+
685
+ Use `surf do` for a direct sequence of browser commands. Use a playbook for a reusable site capability that can try a browser-session network request and fall back to a workflow when the endpoint drifts.
686
+
687
+ ```bash
688
+ surf playbook list
689
+ surf pb show page
690
+ surf pb ops page
691
+ surf use page read --json
692
+
693
+ # Write ops require explicit authorization and a durable duplicate-safety receipt.
694
+ surf use <site> <write-op> --write --resource-id 123
695
+ ```
696
+
697
+ Project playbooks in `./.surf/playbooks/` override user playbooks in `~/.surf/playbooks/`; built-ins are the final fallback. `show` reports the selected source. Provider compatibility commands continue to use their validated command paths until provider playbooks have real login-flow validation.
698
+
699
+ Author a playbook from redacted recent activity or an explicit evidence record:
700
+
701
+ ```bash
702
+ surf pb suggest --since 1h
703
+ surf pb save example --op read --from-recent 1h
704
+ surf pb record start example --op read --network --watch
705
+ surf pb record mark "loaded results"
706
+ surf pb record stop --draft
707
+ surf pb save --from-record <record-id>
708
+ surf pb trace export --from-record <record-id> --har ./trace.har
709
+ surf pb export example --out ./example-playbook
710
+ surf pb import ./example-playbook
711
+ ```
712
+
713
+ Records, traces, receipts, and recent-use journals live under private Surf state. Input values and authentication headers are redacted by default; `--include-input-values` is an explicit recording choice.
714
+
715
+ Generate a standalone client only from an observed or validated read endpoint:
716
+
717
+ ```bash
718
+ surf pb client derive example --op read --from-record <record-id> --request-id <request-id> --out ./client
719
+ surf pb client export example --op read --out ./client
720
+ surf pb client verify ./client
721
+ ```
722
+
723
+ Generated manifests declare provenance and authentication environment inputs. Surf excludes cookies, bearer tokens, and captured credentials and does not export write-capable clients without explicit review.
724
+
594
725
  ## Global Options
595
726
 
596
727
  ```bash
@@ -601,14 +732,17 @@ surf workflow.validate ./my-workflow.json
601
732
  --no-lock # Bypass the per-socket browser request lock
602
733
  --no-screenshot # Skip auto-screenshot after actions
603
734
  --full # Full resolution screenshots (skip resize)
604
- --network-path <path> # Custom path for network logs (default: /tmp/surf, or SURF_NETWORK_PATH env)
605
735
  ```
606
736
 
607
737
  ## Environment Variables
608
738
 
609
739
  ```bash
610
- SURF_NETWORK_PATH # Path for network capture logs (default: /tmp/surf)
740
+ SURF_NETWORK_PATH # Native-host network state root (default: ~/.surf/state/network)
611
741
  SURF_SOCKET # Socket path or named pipe (default: /tmp/surf.sock, Windows: //./pipe/surf)
742
+ SURF_REMOTE # Remote Surf endpoint as host:port (overrides SURF_SOCKET)
743
+ SURF_REMOTE_CREDENTIAL # Client Ed25519 credential for the selected remote endpoint
744
+ SURF_REMOTE_STATE_DIR # Host identity/authorization directory (default: ~/.surf/remote)
745
+ SURF_LISTEN # Native-host Tailnet bind address as <tailscale-ip>:<port>
612
746
  SURF_NODE_PATH # Path to node binary (for native host wrapper)
613
747
  SURF_HOST_PATH # Path to native/host.cjs (for native host wrapper)
614
748
  SURF_EXTENSION_PATH # Path to extension dist/ directory
@@ -616,6 +750,10 @@ SURF_EXTENSION_PATH # Path to extension dist/ directory
616
750
 
617
751
  **Use cases:**
618
752
  - `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.
753
+ - `SURF_REMOTE`: Remote client endpoint. `--remote <host>:<port>` overrides it; both override `SURF_SOCKET`.
754
+ - `SURF_REMOTE_CREDENTIAL`: Credential used for mutual remote authentication. `--remote-credential <path>` overrides it.
755
+ - `SURF_REMOTE_STATE_DIR`: Advanced host-side override for the mode-0700 identity and client registry directory.
756
+ - `SURF_LISTEN`: Native-host listener address on the browser machine. Use `surf install ... --listen <tailscale-ip>:<port>` to persist it in that host's wrapper.
619
757
  - `SURF_NODE_PATH` / `SURF_HOST_PATH`: Package manager installs (e.g., Nix) that store binaries in non-standard locations
620
758
  - `SURF_EXTENSION_PATH`: Package managers that create stable symlinks instead of changing paths on reinstall
621
759
 
@@ -0,0 +1,65 @@
1
+ class RequestAbortError extends Error {
2
+ constructor(message = "Request cancelled") {
3
+ super(message);
4
+ this.name = "RequestAbortError";
5
+ this.code = "SURF_REQUEST_ABORTED";
6
+ }
7
+ }
8
+
9
+ function abortError(signal, fallback = "Request cancelled") {
10
+ if (signal?.reason instanceof Error && signal.reason.name !== "AbortError") return signal.reason;
11
+ return new RequestAbortError(fallback);
12
+ }
13
+
14
+ function throwIfAborted(signal, fallback) {
15
+ if (signal?.aborted) throw abortError(signal, fallback);
16
+ }
17
+
18
+ function raceAbort(promiseOrFactory, signal, fallback) {
19
+ if (!signal) return typeof promiseOrFactory === "function" ? promiseOrFactory() : promiseOrFactory;
20
+ throwIfAborted(signal, fallback);
21
+ const promise = typeof promiseOrFactory === "function" ? promiseOrFactory() : promiseOrFactory;
22
+ if (signal.aborted) return Promise.reject(abortError(signal, fallback));
23
+ return new Promise((resolve, reject) => {
24
+ let settled = false;
25
+ const onAbort = () => {
26
+ if (settled) return;
27
+ settled = true;
28
+ signal.removeEventListener("abort", onAbort);
29
+ reject(abortError(signal, fallback));
30
+ };
31
+ signal.addEventListener("abort", onAbort, { once: true });
32
+ Promise.resolve(promise).then(
33
+ (value) => {
34
+ if (settled) return;
35
+ settled = true;
36
+ signal.removeEventListener("abort", onAbort);
37
+ resolve(value);
38
+ },
39
+ (error) => {
40
+ if (settled) return;
41
+ settled = true;
42
+ signal.removeEventListener("abort", onAbort);
43
+ reject(error);
44
+ },
45
+ );
46
+ });
47
+ }
48
+
49
+ function abortableDelay(ms, signal, fallback) {
50
+ throwIfAborted(signal, fallback);
51
+ return new Promise((resolve, reject) => {
52
+ const onAbort = () => {
53
+ clearTimeout(timer);
54
+ signal.removeEventListener("abort", onAbort);
55
+ reject(abortError(signal, fallback));
56
+ };
57
+ const timer = setTimeout(() => {
58
+ signal?.removeEventListener("abort", onAbort);
59
+ resolve();
60
+ }, ms);
61
+ signal?.addEventListener("abort", onAbort, { once: true });
62
+ });
63
+ }
64
+
65
+ module.exports = { RequestAbortError, abortError, abortableDelay, raceAbort, throwIfAborted };
@@ -0,0 +1,55 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const { appendPrivateJsonLine, atomicWriteFile, ensurePrivateDir, getPrivateStateRoot, readPrivateFile } = require("./private-state.cjs");
4
+ const { commandMetadata, redactCommandArgs } = require("./workflow-definition.cjs");
5
+
6
+ const MAX_JOURNAL_BYTES = 1024 * 1024;
7
+ const MAX_JOURNAL_EVENTS = 500;
8
+
9
+ function journalPath(root = getPrivateStateRoot()) {
10
+ return path.join(root, "activity-journal", "events.jsonl");
11
+ }
12
+
13
+ function compactJournal(filePath, root) {
14
+ const stat = fs.statSync(filePath);
15
+ if (stat.size <= MAX_JOURNAL_BYTES) return;
16
+ const lines = readPrivateFile(filePath, { root, encoding: "utf8" }).trim().split("\n").filter(Boolean).slice(-MAX_JOURNAL_EVENTS);
17
+ atomicWriteFile(filePath, `${lines.join("\n")}\n`, { root, encoding: "utf8" });
18
+ }
19
+
20
+ function appendActivity(event, { root = getPrivateStateRoot() } = {}) {
21
+ const filePath = journalPath(root);
22
+ ensurePrivateDir(path.dirname(filePath), root);
23
+ appendPrivateJsonLine(filePath, { version: 1, ...event }, { root });
24
+ compactJournal(filePath, root);
25
+ }
26
+
27
+ function journalCommand(command, args, options = {}) {
28
+ const metadata = commandMetadata(command);
29
+ if (!metadata.recordable) return;
30
+ appendActivity({
31
+ type: "tool.issued",
32
+ command: metadata.name,
33
+ argsRedacted: redactCommandArgs(command, args, options.includeInputValues === true),
34
+ effect: metadata.effect,
35
+ ...(options.tabId ? { tabId: options.tabId } : {}),
36
+ ...(options.origin ? { origin: options.origin } : {}),
37
+ startedAt: new Date().toISOString(),
38
+ }, options);
39
+ }
40
+
41
+ function sinceMilliseconds(value) {
42
+ if (!value) return 60 * 60 * 1000;
43
+ const match = String(value).match(/^(\d+)(m|h|d)$/);
44
+ if (!match) throw new Error("--since must be a duration such as 30m, 1h, or 2d");
45
+ return Number(match[1]) * { m: 60000, h: 3600000, d: 86400000 }[match[2]];
46
+ }
47
+
48
+ function readRecent({ since = "1h", root = getPrivateStateRoot() } = {}) {
49
+ const filePath = journalPath(root);
50
+ const content = readPrivateFile(filePath, { root, allowMissing: true, fallback: "", encoding: "utf8" });
51
+ const cutoff = Date.now() - sinceMilliseconds(since);
52
+ return content.split("\n").filter(Boolean).map((line) => JSON.parse(line)).filter((event) => Date.parse(event.startedAt || event.timestamp) >= cutoff);
53
+ }
54
+
55
+ module.exports = { appendActivity, journalCommand, journalPath, readRecent };
@@ -0,0 +1,64 @@
1
+ const { abortError } = require("./abort.cjs");
2
+
3
+ class BoundedAiQueue {
4
+ constructor({ maxQueued = 8, spacingMs = 2000, audit = () => {}, run = (handler) => handler() } = {}) {
5
+ this.maxQueued = maxQueued;
6
+ this.spacingMs = spacingMs;
7
+ this.audit = audit;
8
+ this.run = run;
9
+ this.items = [];
10
+ this.active = false;
11
+ }
12
+
13
+ enqueue(handler, request) {
14
+ return new Promise((resolve, reject) => {
15
+ if (request?.signal.aborted) {
16
+ reject(abortError(request.signal));
17
+ return;
18
+ }
19
+ if (this.items.length >= this.maxQueued) {
20
+ reject(new Error("AI request queue is full"));
21
+ return;
22
+ }
23
+ const item = { handler, request, resolve, reject, abortCleanup: null };
24
+ const onAbort = () => {
25
+ const index = this.items.indexOf(item);
26
+ if (index === -1) return;
27
+ this.items.splice(index, 1);
28
+ item.abortCleanup = null;
29
+ reject(abortError(request.signal));
30
+ this.audit({ event: "request", context: request.context, request, outcome: "queued-cancel" });
31
+ };
32
+ if (request?.signal) {
33
+ request.signal.addEventListener("abort", onAbort, { once: true });
34
+ item.abortCleanup = () => request.signal.removeEventListener("abort", onAbort);
35
+ }
36
+ this.items.push(item);
37
+ this.#process();
38
+ });
39
+ }
40
+
41
+ get queued() {
42
+ return this.items.length;
43
+ }
44
+
45
+ async #process() {
46
+ if (this.active || this.items.length === 0) return;
47
+ this.active = true;
48
+ const item = this.items.shift();
49
+ item.abortCleanup?.();
50
+ try {
51
+ if (item.request?.signal.aborted) throw abortError(item.request.signal);
52
+ const result = await this.run(item.handler, item.request);
53
+ if (item.request?.signal.aborted) throw abortError(item.request.signal);
54
+ item.resolve(result);
55
+ } catch (error) {
56
+ item.reject(error);
57
+ } finally {
58
+ this.active = false;
59
+ setTimeout(() => this.#process(), this.spacingMs);
60
+ }
61
+ }
62
+ }
63
+
64
+ module.exports = { BoundedAiQueue };
@@ -1,4 +1,5 @@
1
1
  const fs = require("fs");
2
+ const { raceAbort, throwIfAborted } = require("./abort.cjs");
2
3
  const { execFileSync } = require("child_process");
3
4
  const {
4
5
  delay,
@@ -483,26 +484,29 @@ async function build({
483
484
  cdpCommand,
484
485
  searchDownloads,
485
486
  log = () => {},
487
+ signal,
486
488
  }) {
489
+ throwIfAborted(signal);
490
+ const guardedSearchDownloads = (...args) => raceAbort(() => searchDownloads(...args), signal);
487
491
  const startedAt = Date.now();
488
492
  const timeoutMs = Number.isFinite(timeout) ? timeout : 600000;
489
493
  const requestedModel = normalizeModelString(model || "");
490
494
  const resolvedModel = requestedModel || DEFAULT_MODEL;
491
495
 
492
- const cookieResult = await getCookies();
496
+ const cookieResult = await raceAbort(getCookies, signal);
493
497
  const cookies = cookieResult?.cookies || [];
494
498
  if (!hasRequiredCookies(cookies)) {
495
499
  throw new Error("Sign into Google in Chrome first.");
496
500
  }
497
501
 
498
- const tabInfo = await createTab(AISTUDIO_APPS_URL);
502
+ const tabInfo = await raceAbort(() => createTab(AISTUDIO_APPS_URL), signal);
499
503
  const tabId = tabInfo?.tabId;
500
504
  if (!tabId) {
501
505
  throw new Error(`Failed to create AI Studio tab: ${JSON.stringify(tabInfo)}`);
502
506
  }
503
507
 
504
- const cdp = (expression) => cdpEvaluate(tabId, expression);
505
- const inputCdp = (method, params) => cdpCommand(tabId, method, params);
508
+ const cdp = (expression) => raceAbort(() => cdpEvaluate(tabId, expression), signal);
509
+ const inputCdp = (method, params) => raceAbort(() => cdpCommand(tabId, method, params), signal);
506
510
 
507
511
  let buildDuration = null;
508
512
  let zipPath = null;
@@ -510,7 +514,7 @@ async function build({
510
514
  let modelUsed = resolvedModel;
511
515
 
512
516
  try {
513
- await waitForBuildPageReady(cdp, 30000);
517
+ await raceAbort(waitForBuildPageReady(cdp, 30000), signal);
514
518
 
515
519
  if (requestedModel) {
516
520
  const applied = await selectModelInAdvancedSettings(cdp, inputCdp, requestedModel, log);
@@ -518,20 +522,20 @@ async function build({
518
522
  }
519
523
 
520
524
  await typePromptAndBuild(cdp, inputCdp, prompt);
521
- const completion = await waitForBuildCompletion(cdp, timeoutMs, log);
525
+ const completion = await raceAbort(waitForBuildCompletion(cdp, timeoutMs, log), signal);
522
526
  buildDuration = completion.buildDuration;
523
527
 
524
- await activateCodeTab(cdp, inputCdp);
525
- await waitForDownloadButton(cdp, 5000);
528
+ await raceAbort(activateCodeTab(cdp, inputCdp), signal);
529
+ await raceAbort(waitForDownloadButton(cdp, 5000), signal);
526
530
 
527
- const beforeDownloads = await searchDownloads({
531
+ const beforeDownloads = await guardedSearchDownloads({
528
532
  limit: 1,
529
533
  orderBy: ["-startTime"],
530
534
  });
531
535
  const latestIdBefore = beforeDownloads?.[0]?.id || 0;
532
536
 
533
- await clickDownloadButton(cdp);
534
- zipPath = await waitForDownloadComplete(searchDownloads, latestIdBefore, 30000);
537
+ await raceAbort(clickDownloadButton(cdp), signal);
538
+ zipPath = await waitForDownloadComplete(guardedSearchDownloads, latestIdBefore, 30000);
535
539
 
536
540
  if (output) {
537
541
  const outputDir = String(output);
@@ -553,8 +557,12 @@ async function build({
553
557
  tookMs: Date.now() - startedAt,
554
558
  };
555
559
  } finally {
556
- if (!keepOpen) {
557
- await closeTab(tabId).catch(() => {});
560
+ if (!keepOpen || signal?.aborted) {
561
+ try {
562
+ await closeTab(tabId);
563
+ } catch (error) {
564
+ log(`Failed to close AI Studio Build tab ${tabId}: ${error?.message || error}`);
565
+ }
558
566
  }
559
567
  }
560
568
  }