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.
- package/README.md +146 -8
- package/native/abort.cjs +65 -0
- package/native/activity-journal.cjs +55 -0
- package/native/ai-queue.cjs +64 -0
- package/native/aistudio-build.cjs +21 -13
- package/native/aistudio-client.cjs +40 -20
- package/native/browser-lock.cjs +2 -2
- package/native/chatgpt-client.cjs +49 -31
- package/native/cli.cjs +352 -482
- package/native/client-transport.cjs +168 -0
- package/native/do-executor.cjs +68 -510
- package/native/do-parser.cjs +8 -249
- package/native/doctor.cjs +55 -5
- package/native/endpoint.cjs +174 -0
- package/native/file-transfer.cjs +734 -0
- package/native/gemini-client.cjs +156 -71
- package/native/grok-client.cjs +98 -89
- package/native/host-helpers.cjs +43 -26
- package/native/host-sessions.cjs +287 -0
- package/native/host.cjs +998 -620
- package/native/listener.cjs +20 -0
- package/native/mcp-server.cjs +60 -65
- package/native/network-export.cjs +116 -0
- package/native/network-store.cjs +38 -58
- package/native/perplexity-client.cjs +46 -17
- package/native/playbook-authoring.cjs +44 -0
- package/native/playbook-cli.cjs +157 -0
- package/native/playbook-client.cjs +259 -0
- package/native/playbook-receipts.cjs +109 -0
- package/native/playbook-records.cjs +208 -0
- package/native/playbook-runtime.cjs +177 -0
- package/native/playbooks.cjs +235 -0
- package/native/private-state.cjs +156 -0
- package/native/redaction.cjs +104 -0
- package/native/remote-auth.cjs +279 -0
- package/native/remote-transport.cjs +337 -0
- package/native/request-pending.cjs +148 -0
- package/native/socket-path.cjs +1 -1
- package/native/workflow-definition.cjs +368 -0
- package/native/workflow-runtime.cjs +225 -0
- package/package.json +9 -6
- package/playbooks/page/ops/read.json +22 -0
- package/playbooks/page/playbook.json +7 -0
- package/scripts/install-native-host.cjs +36 -5
- package/skills/README.md +11 -5
- package/skills/deep-x-research/SKILL.md +106 -0
- 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
|
-
|
|
|
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
|
|
199
|
-
surf type "email@example.com" --ref e12 #
|
|
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-
|
|
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
|
-
|
|
466
|
-
|
|
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 #
|
|
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
|
|
package/native/abort.cjs
ADDED
|
@@ -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
|
|
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(
|
|
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
|
-
|
|
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
|
}
|