surf-cli 2.17.0 → 2.19.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
@@ -135,6 +135,30 @@ SURF_REMOTE_CREDENTIAL=~/.config/surf/agent-macbook.json \
135
135
  surf tab.list
136
136
  ```
137
137
 
138
+ For a TLS-terminating reverse proxy in front of the native host's existing clear-TCP
139
+ listener, opt in on the client:
140
+
141
+ ```bash
142
+ surf --remote surf.example.com:443 --remote-tls \
143
+ --remote-credential ~/.config/surf/agent-macbook.json tab.list
144
+
145
+ # Private CA and an IP destination with a DNS certificate identity
146
+ surf --remote 100.101.102.103:443 --remote-tls \
147
+ --remote-tls-ca ~/.config/surf/private-ca.pem \
148
+ --remote-tls-server-name surf.example.com \
149
+ --remote-credential ~/.config/surf/agent-macbook.json tab.list
150
+ ```
151
+
152
+ `--remote-tls-ca` replaces Node's system roots rather than adding to them. DNS endpoints
153
+ use their hostname for SNI and certificate validation; IP endpoints omit SNI and validate
154
+ the certificate's IP SAN unless `--remote-tls-server-name` supplies a DNS identity. TLS
155
+ certificate validation finishes before the mandatory Ed25519 authentication. Surf has no
156
+ insecure mode, downgrade, or plaintext retry. `SURF_REMOTE_TLS=1` is the only accepted
157
+ environment spelling; unset it to disable TLS. There is no CLI negation for env-enabled TLS.
158
+ CLI values override `SURF_REMOTE_TLS_CA` and `SURF_REMOTE_TLS_SERVER_NAME` independently.
159
+ `SURF_LISTEN` remains a plaintext listener behind the reverse proxy; Surf does not terminate
160
+ TLS on the browser host.
161
+
138
162
  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
163
 
140
164
  ```bash
@@ -146,6 +170,8 @@ surf remote list
146
170
 
147
171
  `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
172
 
173
+ For advanced local-only sharing, `surf install <extension-id> --socket-mode 660 --socket-group <group>` persists an opt-in group-owned socket. The default remains mode `600`; mode `660` gives every account in that group full Surf authority, so use a dedicated narrow group. Re-run `surf install` without these flags to clear persisted socket settings. Remote Surf credentials remain the revocable per-client alternative.
174
+
149
175
  Keep Tailscale policy restrictions as defense in depth. For example:
150
176
 
151
177
  ```json
@@ -160,7 +186,9 @@ Keep Tailscale policy restrictions as defense in depth. For example:
160
186
  }
161
187
  ```
162
188
 
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.
189
+ Adapt tags and ports to your Tailnet. Surf authentication does not replace Tailnet policy.
190
+ Optional outbound remote TLS protects the client-to-proxy connection; Surf does not add an
191
+ SSH tunnel or a TLS listener.
164
192
 
165
193
  **Operations and troubleshooting**
166
194
 
@@ -188,7 +216,7 @@ surf --remote "$SURF_REMOTE" --remote-credential "$SURF_REMOTE_CREDENTIAL" \
188
216
 
189
217
  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
218
 
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.
219
+ 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`, `video`, `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
220
 
193
221
  ### Development Setup
194
222
 
@@ -278,6 +306,13 @@ surf locate.role button --action click
278
306
  surf frame.main # Return to main page
279
307
  ```
280
308
 
309
+ When a selector never matches, `frame.diagnose` shows the three frame views side by side (DOM `<iframe>` elements, the extension's frames with content-script reachability, and the CDP frame tree) and explains the mismatches: `srcdoc`/`about:blank` frames (matched to their CDP frame by `name`/`id`), sandboxes without `allow-scripts`, cross-origin frames, out-of-process frames that the CDP tree does not list (`frame.js` cannot reach them; `frame.switch` and `page.read` can when the content script answers), and frames still loading. The DOM inventory walks open shadow roots, so frames rendered by custom elements are listed with their `shadowHost` path. The text report abbreviates long frame URLs; `--json` keeps them whole.
310
+
311
+ ```bash
312
+ surf frame.diagnose # Human-readable report with warnings
313
+ surf frame.diagnose --json # Full inventories
314
+ ```
315
+
281
316
  ### Interaction
282
317
 
283
318
  ```bash
@@ -378,10 +413,14 @@ SURF_SESSION=research surf screenshot # environment selector
378
413
  surf session.list --refresh # all bindings + queue state
379
414
  surf session.info research --refresh # target and scheduler details
380
415
  surf session.close research # closes Surf-created target
416
+ surf session.cleanup --idle-after 1h --dry-run # preview forgotten sessions
417
+ surf session.cleanup --idle-after 1h # remove idle bindings
381
418
  surf session.rebind research --tab-id 789 # adopt an existing tab
382
419
  surf session.reopen research # recreate from last URL
383
420
  ```
384
421
 
422
+ `session.cleanup` is an explicit, one-shot cleanup operation; `--idle-after` is required. It inspects current bindings first, removes gone or stale records, and removes live inactive bindings older than the threshold. Only Surf-created targets are closed. Adopted targets remain open while their session binding is removed. Use `--dry-run` to report the exact bindings and target actions without changing the store or browser.
423
+
385
424
  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.
386
425
 
387
426
  Recovery errors print an exact command that can be copied directly:
@@ -440,6 +479,19 @@ surf record --rect 0,200,1440,800 --output /tmp/region.gif
440
479
 
441
480
  `record` defaults to 2000ms at 10fps and writes to `/tmp/surf-record-*.gif` when no output is provided. `--duration` is capped at 10000ms and `--fps` is capped at 30. `--trigger` supports `click:<selector>`, `scroll:up|down|left|right|top|bottom`, and `scroll:<selector>` to scroll a container to the bottom before capture. `--rect` crops the GIF using `x,y,width,height`. ImageMagick must be available as `magick` or `convert`.
442
481
 
482
+ ### Local Video Recording
483
+
484
+ Capture a local tab as WebM/VP9 with ffmpeg:
485
+
486
+ ```bash
487
+ surf video start ./demo.webm --fps 30
488
+ surf video status
489
+ surf video stop
490
+ surf video restart ./take-2.webm --fps 60
491
+ ```
492
+
493
+ `--fps` defaults to 30 and is capped at 60. Video recording is local-only and requires `ffmpeg` on `PATH`.
494
+
443
495
  ### Animation Audit
444
496
 
445
497
  Sample matching elements over time and return a bounded JSON timeline for agent inspection:
@@ -526,7 +578,9 @@ surf aistudio.build "game" --keep-open --timeout 600 # Keep tab open, 1
526
578
 
527
579
  #### Oracle
528
580
 
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, one direct local attachment with `--file`, and verifies requested model and reasoning effort before submission. Add `--github` when the consult needs the ChatGPT Chat tab and connected GitHub tool. ChatGPT model aliases include `instant`, `thinking`, `pro`, `gpt-5.5`, and `gpt-5.6-sol`. Use `--model gpt-5.6-sol --effort pro` for GPT-5.6 Sol with Pro effort.
581
+ 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, one direct local attachment with `--file`, and verifies requested model and reasoning effort before submission. Add `--github` when the consult needs the ChatGPT Chat tab and connected GitHub tool. ChatGPT model aliases include `gpt-6-astra`, `latest`, `gpt-5.6-sol`, and `gpt-5.5`; `latest` is an explicit floating choice, while `gpt-6-astra` must read back as model 6 before submission. Accepted efforts are `instant`, `medium`, `high`, `xhigh`/`extra-high`, and `pro`. Use `--model gpt-6-astra --effort pro` for GPT-6 Astra with Pro effort.
582
+
583
+ ChatGPT can hide the model version at lower effort settings. Use `--model latest` if you want floating model selection there; an explicit `gpt-6-astra` request fails closed when the UI cannot verify model 6.
530
584
 
531
585
  ```bash
532
586
  surf oracle ask "review this change" --files "src/**/*.ts" --file ./design.md --model gpt-5.5 --effort pro --github --detach --json
@@ -548,12 +602,55 @@ surf wait 2 # Wait 2 seconds
548
602
  surf wait.element ".loaded" # Wait for element
549
603
  surf wait.network # Wait for network idle
550
604
  surf wait.url "/dashboard" # Wait for URL pattern
605
+ surf wait.ready --selector ".results" # Wait for content, fail fast on a bounce
606
+ surf page.readiness --json # Classify the page once
607
+ ```
608
+
609
+ `wait.ready` polls with a bounded budget and reports a typed state instead of timing out silently: `ready`, `empty` (the page showed its own no-results message, `--empty-text`), or one of the negative states `login`, `challenge` (anti-bot interstitial), `not-found`, `error`. A negative state exits non-zero with codes `page_login`, `page_challenge`, `page_not_found`, `page_error`; `page_timeout` reports the last observed state. Pass `--accept login` to return a state to the caller instead. Detection uses visible UI state (a rendered password field, a login-looking route, the page's own wording, `--url-prefix` bounces), never site-specific selectors.
610
+
611
+ ```bash
612
+ surf wait.ready --url-prefix "https://app.example.com/" --empty-text "No results"
613
+ surf wait.ready --accept login --json # {"state":"login","evidence":[...]} instead of an error
551
614
  ```
552
615
 
616
+ ### Extracting structured data
617
+
618
+ `surf extract` composes existing client-side tools: it opens an owned tab,
619
+ waits for explicit readiness, runs a page script, validates its JSON result,
620
+ and closes the tab. It prints concise Markdown by default or structured JSON
621
+ with `--json`. Return an array, or an object containing a conventional row key
622
+ such as `rows`, `items`, or `results`; use `--rows <key>` for another key.
623
+
624
+ ```bash
625
+ surf extract "https://example.com/list" --file rows.js --ready-selector ".item"
626
+ surf extract "https://example.com/search" --file rows.js --options '{"limit":20}' --empty-text "No results" --json
627
+ surf extract --tab-id 42 --code 'return [...document.querySelectorAll("h2")].map(h => ({title: h.textContent}))'
628
+ ```
629
+
630
+ Owned-tab failures always attempt cleanup. Zero rows retry unless
631
+ `--allow-empty` is set or `--empty-text` identifies the page's accepted empty
632
+ state. Fresh-tab retries are bounded (`--retry`, default 1, maximum 5) and are
633
+ limited to readiness timeouts, zero rows, and lost tab/execution-context
634
+ failures. Login, challenge, not-found, page-error, script/output, and cleanup
635
+ failures do not retry. `--tab-id` and `--session` target an existing page and
636
+ never retry or close it; `--keep-tab` preserves a successfully owned tab.
637
+
638
+ Extract is intended for read-only or otherwise idempotent caller scripts.
639
+ JavaScript is not inherently read-only: a retry can replay the script, so avoid
640
+ mutations or make them idempotent.
641
+
553
642
  ### Other
554
643
 
644
+ `js` and `frame.js` accept `--options '{"limit": 20}'` with inline code or
645
+ `--file`. This defines `SURF_OPTIONS` by parsing the JSON and freezing the
646
+ result; use an explicit `return` for the script's result. The freeze is shallow.
647
+ Invalid JSON and non-object values are rejected before sending a request;
648
+ `--options ''` defines an empty object. Without `--options`, code is unchanged.
649
+
555
650
  ```bash
556
651
  surf js "return document.title" # Execute JavaScript
652
+ surf js "piHelpers.setValue(document.querySelector('#q'), 'hello')" # Native value setter + input/change events
653
+ surf js --file script.js --options '{"limit": 20}' # Script reads SURF_OPTIONS.limit
557
654
  surf record --duration 2000 --fps 10 --output /tmp/anim.gif # Animated GIF capture
558
655
  surf animate-audit --selector ".thing" --duration 2000 --fps 10 # JSON animation timeline
559
656
  surf perf-audit --duration 3000 --output /tmp/perf.json # PerformanceObserver snapshot
@@ -790,12 +887,32 @@ Generated manifests declare provenance and authentication environment inputs. Su
790
887
  --window-id <id> # Target a specific window
791
888
  --no-wait # Return tab_busy/browser_busy instead of queueing
792
889
  --json # Raw JSON including resolved target metadata
793
- --soft-fail # Warn instead of error (exit 0) on restricted pages
890
+ --soft-fail # Host tool errors: stderr warning, exit 0, no JSON error output
794
891
  --no-lock # Bypass the legacy lock for compound client-side commands
795
892
  --no-screenshot # Skip auto-screenshot after actions
796
893
  --full # Full resolution screenshots (skip resize)
797
894
  ```
798
895
 
896
+ ### Host tool-response errors
897
+
898
+ For ordinary socket-backed commands, a host response with a top-level `error`
899
+ exits 1 and prints `Error: ...` on stderr. A supplied code is appended as `[code]`
900
+ to the first line unless already present there; subsequent recovery lines are
901
+ preserved. Without a code, no suffix is added.
902
+
903
+ `--json` additionally writes `{"error":{"code":"...","message":"...","details":{...}}}`
904
+ to stdout, while retaining stderr and exit 1. The JSON code defaults to `"error"`;
905
+ the message uses the host's message, or the first display line if absent. Optional
906
+ details retain the host's fields except redundant `code` and `message` fields.
907
+
908
+ `--soft-fail` takes precedence: the original host display text is printed as a
909
+ stderr warning, without adding a code, stdout stays empty even with `--json`,
910
+ and the command exits 0. This is **not a universal JSON error envelope**: local
911
+ validation, transport/parser failures, compound commands and errors embedded in
912
+ successful result payloads retain their existing behavior. In particular, a
913
+ connection failure still prints stderr, leaves stdout empty and exits 1 with
914
+ `--json`, even with `--soft-fail`.
915
+
799
916
  ## Environment Variables
800
917
 
801
918
  ```bash
@@ -805,8 +922,13 @@ SURF_SESSION # Default named browser session for tab-scoped command
805
922
  SURF_SOCKET # Socket path or named pipe (default: /tmp/surf.sock, Windows: //./pipe/surf)
806
923
  SURF_REMOTE # Remote Surf endpoint as host:port (overrides SURF_SOCKET)
807
924
  SURF_REMOTE_CREDENTIAL # Client Ed25519 credential for the selected remote endpoint
925
+ SURF_REMOTE_TLS # Exactly 1 enables TLS for a selected remote endpoint
926
+ SURF_REMOTE_TLS_CA # Custom CA bundle that replaces system roots
927
+ SURF_REMOTE_TLS_SERVER_NAME # DNS SNI and certificate identity override
808
928
  SURF_REMOTE_STATE_DIR # Host identity/authorization directory (default: ~/.surf/remote)
809
929
  SURF_LISTEN # Native-host Tailnet bind address as <tailscale-ip>:<port>
930
+ SURF_SOCKET_MODE # Advanced POSIX local socket mode: 600 (default) or 660
931
+ SURF_SOCKET_GROUP # Group name or numeric gid required with mode 660
810
932
  SURF_NODE_PATH # Path to node binary (for native host wrapper)
811
933
  SURF_HOST_PATH # Path to native/host.cjs (for native host wrapper)
812
934
  SURF_EXTENSION_PATH # Path to extension dist/ directory
@@ -818,8 +940,12 @@ SURF_EXTENSION_PATH # Path to extension dist/ directory
818
940
  - `SURF_SOCKET`: Advanced socket override. Set it for both the native host and CLI when separate browser/profile instances need hard isolation.
819
941
  - `SURF_REMOTE`: Remote client endpoint. `--remote <host>:<port>` overrides it; both override `SURF_SOCKET`.
820
942
  - `SURF_REMOTE_CREDENTIAL`: Credential used for mutual remote authentication. `--remote-credential <path>` overrides it.
943
+ - `SURF_REMOTE_TLS`: Set exactly `1` for TLS through a terminating reverse proxy; `--remote-tls` also enables it and cannot negate an env-enabled setting.
944
+ - `SURF_REMOTE_TLS_CA`: CA bundle for remote TLS, replacing system roots. `--remote-tls-ca <path>` overrides it.
945
+ - `SURF_REMOTE_TLS_SERVER_NAME`: DNS SNI and certificate identity override. `--remote-tls-server-name <name>` overrides it.
821
946
  - `SURF_REMOTE_STATE_DIR`: Advanced host-side override for the mode-0700 identity and client registry directory.
822
947
  - `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.
948
+ - `SURF_SOCKET_MODE` / `SURF_SOCKET_GROUP`: Advanced POSIX native-host settings. Use `surf install ... --socket-mode 660 --socket-group <group>` to persist group access; mode `660` grants full Surf authority to every member of that group.
823
949
  - `SURF_NODE_PATH` / `SURF_HOST_PATH`: Package manager installs (e.g., Nix) that store binaries in non-standard locations
824
950
  - `SURF_EXTENSION_PATH`: Package managers that create stable symlinks instead of changing paths on reinstall
825
951
 
@@ -914,11 +1040,11 @@ echo '{"type":"tool_request","method":"execute_tool","params":{"tool":"tab.list"
914
1040
  | `window.*` | `new`, `list`, `focus`, `close`, `resize` |
915
1041
  | `tab.*` | `list`, `new`, `switch`, `close`, `name`, `unname`, `named`, `group`, `ungroup`, `groups`, `reload` |
916
1042
  | `scroll.*` | `top`, `bottom`, `to`, `info` |
917
- | `page.*` | `read`, `text`, `state` |
1043
+ | `page.*` | `read`, `text`, `state`, `readiness` |
918
1044
  | `locate.*` | `role`, `text`, `label` |
919
1045
  | `element.*` | `styles` |
920
- | `frame.*` | `list`, `switch`, `main`, `js` |
921
- | `wait.*` | `element`, `network`, `url`, `dom`, `load` |
1046
+ | `frame.*` | `list`, `diagnose`, `switch`, `main`, `js` |
1047
+ | `wait.*` | `element`, `network`, `url`, `dom`, `load`, `ready` |
922
1048
  | `cookie` / `cookie.*` | `list`, `get`, `set`, `clear`, `delete` |
923
1049
  | `bookmark.*` | `add`, `remove`, `list` |
924
1050
  | `history.*` | `list`, `search` |
@@ -996,9 +1122,9 @@ pi -e /path/to/surf-cli/pi-extension/surf.ts
996
1122
 
997
1123
  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.
998
1124
 
999
- The extension also registers a `surf-oracle` external-job provider when a Pi runtime exposes that provider bridge. The provider implements pi-subagents' external-job contract: `start`, `status`, `result`, and `reattach` operations that return `providerJobId`, a contract state (`queued`, `running`, `completed`, `failed`), the durable conversation URL, the captured result text as `output`, and failure code and message when present. It reads `options.model`, `options.effort`, `options.file`, and `options.github` for starts and follow-ups, so a Pi profile can request `model: gpt-5.6-sol` plus `effort: pro` and reach ChatGPT GPT-5.6 Sol with Pro effort through Surf, while `github: true` requires Chat mode and the connected GitHub tool. Capacity stays fail-closed: Surf returns the blocking job id instead of silently queueing a second ChatGPT job.
1125
+ The extension also registers a `surf-oracle` external-job provider when a Pi runtime exposes that provider bridge. The provider implements pi-subagents' external-job contract: `start`, `status`, `result`, and `reattach` operations that return `providerJobId`, a contract state (`queued`, `running`, `completed`, `failed`), the durable conversation URL, the captured result text as `output`, and failure code and message when present. It reads `options.model`, `options.effort`, `options.file`, and `options.github` for starts and follow-ups, so a Pi profile can request `model: gpt-6-astra` plus `effort: pro` and reach ChatGPT GPT-6 Astra with Pro effort through Surf, while `github: true` requires Chat mode and the connected GitHub tool. Capacity stays fail-closed: Surf returns the blocking job id instead of silently queueing a second ChatGPT job.
1000
1126
 
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`, `options.model: gpt-5.6-sol`, and `options.effort: 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.
1127
+ 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`, `options.model: gpt-6-astra`, and `options.effort: 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.
1002
1128
 
1003
1129
  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.
1004
1130
 
package/agents/gpt-pro.md CHANGED
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: gpt-pro
3
- description: Surf GPT Pro advisor through ChatGPT GPT-5.6 Sol Pro web mode
3
+ description: Surf GPT Pro advisor through ChatGPT GPT-6 Astra Pro web mode
4
4
  runner:
5
5
  type: external-job
6
6
  provider: surf-oracle
7
7
  options:
8
- model: gpt-5.6-sol
8
+ model: gpt-6-astra
9
9
  effort: pro
10
10
  async: true
11
11
  systemPromptMode: replace
@@ -9,6 +9,24 @@ const { surfError } = require("./surf-error.cjs");
9
9
 
10
10
  const STORE_VERSION = 1;
11
11
  const SESSION_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
12
+ const SESSION_DURATION_PATTERN = /^(\d+(?:\.\d+)?)(s|m|h|d)?$/i;
13
+
14
+ function parseDurationMs(value) {
15
+ if (typeof value === "boolean" || value === undefined || value === null) {
16
+ throw new Error("--idle-after requires a positive duration such as 30s, 5m, 1h, or 1d");
17
+ }
18
+ const match = String(value).trim().match(SESSION_DURATION_PATTERN);
19
+ if (!match) {
20
+ throw new Error("--idle-after must be a duration such as 30s, 5m, 1h, or 1d (plain seconds are also accepted)");
21
+ }
22
+ const amount = Number(match[1]);
23
+ const multiplier = { s: 1000, m: 60000, h: 3600000, d: 86400000 }[(match[2] || "s").toLowerCase()];
24
+ const milliseconds = amount * multiplier;
25
+ if (!Number.isFinite(milliseconds) || milliseconds <= 0) {
26
+ throw new Error("--idle-after must be a positive duration");
27
+ }
28
+ return Math.max(1, Math.round(milliseconds));
29
+ }
12
30
 
13
31
  function normalizeName(name) {
14
32
  return String(name || "").toLowerCase();
@@ -114,6 +132,7 @@ class BrowserSessionStore {
114
132
  browserEpoch: identity.browserEpoch,
115
133
  createdAt: timestamp,
116
134
  updatedAt: timestamp,
135
+ lastAccessedAt: values.lastAccessedAt || timestamp,
117
136
  lastValidatedAt: values.lastValidatedAt || timestamp,
118
137
  };
119
138
  bucket.sessions[key] = record;
@@ -137,6 +156,7 @@ class BrowserSessionStore {
137
156
  browserEpoch: identity.browserEpoch,
138
157
  createdAt: existing?.createdAt || timestamp,
139
158
  updatedAt: timestamp,
159
+ lastAccessedAt: values.lastAccessedAt || timestamp,
140
160
  lastValidatedAt: values.lastValidatedAt || timestamp,
141
161
  };
142
162
  delete record.invalidReason;
@@ -149,7 +169,13 @@ class BrowserSessionStore {
149
169
  update(identity, name, patch) {
150
170
  const existing = this.get(identity, name);
151
171
  if (!existing) throw surfError("session_unknown", `unknown session: ${name}`, { session: name });
152
- return this.replace(identity, name, { ...existing, ...patch, updatedAt: this.now() });
172
+ const timestamp = this.now();
173
+ return this.replace(identity, name, {
174
+ ...existing,
175
+ ...patch,
176
+ updatedAt: timestamp,
177
+ lastAccessedAt: patch?.lastAccessedAt || timestamp,
178
+ });
153
179
  }
154
180
 
155
181
  remove(identity, name) {
@@ -212,7 +238,8 @@ class BrowserSessionStore {
212
238
  let changed = false;
213
239
  for (const record of Object.values(bucket.sessions || {})) {
214
240
  if (record.tabId !== tabId) continue;
215
- Object.assign(record, patch, { updatedAt: this.now() });
241
+ const timestamp = this.now();
242
+ Object.assign(record, patch, { updatedAt: timestamp, lastAccessedAt: patch?.lastAccessedAt || timestamp });
216
243
  changed = true;
217
244
  }
218
245
  if (changed) this.save(state);
@@ -266,6 +293,7 @@ module.exports = {
266
293
  BrowserSessionStore,
267
294
  SESSION_NAME_PATTERN,
268
295
  STORE_VERSION,
296
+ parseDurationMs,
269
297
  normalizeName,
270
298
  validateSessionName,
271
299
  };
@@ -1,11 +1,19 @@
1
- const CHATGPT_EFFORT_CHOICES = ["light", "standard", "extended", "heavy", "pro"];
1
+ const CHATGPT_EFFORT_CHOICES = ["instant", "medium", "high", "xhigh", "pro"];
2
+ const CHATGPT_EFFORT_VALUE = new Map([
3
+ ["instant", 0],
4
+ ["medium", 1],
5
+ ["high", 2],
6
+ ["xhigh", 3],
7
+ ["pro", 4],
8
+ ]);
9
+
2
10
  const CHATGPT_MODEL_ALIASES = new Map([
3
- ["instant", "instant"],
4
- ["gpt53", "instant"],
5
- ["thinking", "thinking"],
6
- ["gpt54thinking", "thinking"],
7
- ["pro", "pro"],
8
- ["gpt54pro", "pro"],
11
+ ["latest", "latest"],
12
+ ["gpt6", "gpt6astra"],
13
+ ["chatgpt6", "gpt6astra"],
14
+ ["gpt6astra", "gpt6astra"],
15
+ ["chatgpt6astra", "gpt6astra"],
16
+ ["6", "gpt6astra"],
9
17
  ["55", "gpt55"],
10
18
  ["gpt55", "gpt55"],
11
19
  ["chatgpt55", "gpt55"],
@@ -14,6 +22,15 @@ const CHATGPT_MODEL_ALIASES = new Map([
14
22
  ["chatgpt56sol", "gpt56sol"],
15
23
  ]);
16
24
 
25
+ const CHATGPT_EFFORT_ALIASES = new Map([
26
+ ["instant", "instant"],
27
+ ["medium", "medium"],
28
+ ["high", "high"],
29
+ ["xhigh", "xhigh"],
30
+ ["extrahigh", "xhigh"],
31
+ ["pro", "pro"],
32
+ ]);
33
+
17
34
  function normalizeChatGPTModelChoice(desiredModel) {
18
35
  const normalized = String(desiredModel || "")
19
36
  .toLowerCase()
@@ -23,43 +40,68 @@ function normalizeChatGPTModelChoice(desiredModel) {
23
40
  }
24
41
 
25
42
  function normalizeChatGPTEffortChoice(desiredEffort) {
26
- const normalized = String(desiredEffort || "").toLowerCase().trim();
27
- return CHATGPT_EFFORT_CHOICES.includes(normalized) ? normalized : null;
43
+ const normalized = String(desiredEffort || "")
44
+ .toLowerCase()
45
+ .replace(/[^a-z0-9]/g, "");
46
+ return CHATGPT_EFFORT_ALIASES.get(normalized) || null;
28
47
  }
29
48
 
30
49
  function normalizedWords(value) {
31
50
  return String(value || "")
32
51
  .toLowerCase()
33
- .replace(/[^a-z0-9]+/g, " ")
52
+ .replace(/[^a-z0-9.]+/g, " ")
34
53
  .trim()
35
54
  .split(/\s+/)
36
55
  .filter(Boolean);
37
56
  }
38
57
 
58
+ function normalizedText(value) {
59
+ return normalizedWords(value).join(" ");
60
+ }
61
+
62
+ function modelKeyFromValue(value) {
63
+ const text = normalizedText(value);
64
+ if (!text) return null;
65
+ if (/^latest$/.test(text)) return "latest";
66
+ if (/\b5\.6\b/.test(text) && /\bsol\b/.test(text)) return "gpt56sol";
67
+ if (/\b5\.5\b/.test(text)) return "gpt55";
68
+ if (/\bgpt\s*6\b/.test(text) || /\bchatgpt\s*6\b/.test(text) || /^6(?:\s|$)/.test(text)) {
69
+ return "gpt6astra";
70
+ }
71
+ return null;
72
+ }
73
+
39
74
  function modelCandidateMatches(item, targetModel) {
40
- const values = [item?.label, item?.testId?.replace(/^model-switcher-/, "")].filter(Boolean);
41
- return values.some((value) => {
42
- const normalizedValue = normalizeChatGPTModelChoice(value);
43
- if (normalizedValue === targetModel) return true;
44
- if (targetModel.startsWith("gpt") && normalizedValue.includes(targetModel)) return true;
45
- const variants = ["instant", "thinking", "pro"].filter((variant) =>
46
- normalizedWords(value).includes(variant),
47
- );
48
- return variants.length === 1 && variants[0] === targetModel;
49
- });
75
+ if (item?.modelKey === targetModel) return true;
76
+ const values = [item?.label, item?.displayLabel, item?.testId?.replace(/^model-switcher-/, "")]
77
+ .filter(Boolean);
78
+ return values.some((value) => modelKeyFromValue(value) === targetModel);
50
79
  }
51
80
 
52
- function effortCandidateMatches(item, targetEffort) {
53
- const labelVariants = new Set(
54
- normalizedWords(item?.label).filter((word) => CHATGPT_EFFORT_CHOICES.includes(word)),
55
- );
56
- if (labelVariants.size > 0) {
57
- return labelVariants.size === 1 && labelVariants.has(targetEffort);
81
+ function modelSelectableCandidateMatches(item, targetModel) {
82
+ if (modelCandidateMatches(item, targetModel)) return true;
83
+ if (targetModel === "gpt6astra") {
84
+ return [item?.label, item?.displayLabel].filter(Boolean).some((value) => modelKeyFromValue(value) === "latest");
58
85
  }
59
- const testIdVariants = new Set(
60
- normalizedWords(item?.testId).filter((word) => CHATGPT_EFFORT_CHOICES.includes(word)),
61
- );
62
- return testIdVariants.size === 1 && testIdVariants.has(targetEffort);
86
+ return false;
87
+ }
88
+
89
+ function effortKeyFromValue(value) {
90
+ const text = normalizedText(value);
91
+ if (!text) return null;
92
+ if (/\bextra\s+high\b/.test(text) || /\bxhigh\b/.test(text)) return "xhigh";
93
+ if (/\binstant\b/.test(text)) return "instant";
94
+ if (/\bmedium\b/.test(text)) return "medium";
95
+ if (/\bhigh\b/.test(text)) return "high";
96
+ if (/\bpro\b/.test(text)) return "pro";
97
+ return null;
98
+ }
99
+
100
+ function effortCandidateMatches(item, targetEffort) {
101
+ const labelKey = item?.effortKey || effortKeyFromValue(item?.label || item?.displayLabel);
102
+ if (!labelKey || labelKey !== targetEffort) return false;
103
+ if (item?.value === undefined || item?.value === null) return true;
104
+ return Number(item.value) === CHATGPT_EFFORT_VALUE.get(targetEffort);
63
105
  }
64
106
 
65
107
  function uniqueMatch(items, matches) {
@@ -75,7 +117,7 @@ function resolveChatGPTModelMenuOption(items, desiredModel) {
75
117
  items,
76
118
  (item) =>
77
119
  ["button", "menuitem", "menuitemradio", "radio"].includes(item?.role) &&
78
- modelCandidateMatches(item, targetModel),
120
+ modelSelectableCandidateMatches(item, targetModel),
79
121
  );
80
122
  }
81
123
 
@@ -85,7 +127,8 @@ function verifyChatGPTModelSelection(items, desiredModel) {
85
127
  return uniqueMatch(
86
128
  items,
87
129
  (item) =>
88
- (typeof item?.label === "string" || typeof item?.testId === "string") &&
130
+ (!["menuitemradio", "radio"].includes(item?.role) || item.selected === true) &&
131
+ (typeof item?.label === "string" || typeof item?.displayLabel === "string" || typeof item?.testId === "string") &&
89
132
  modelCandidateMatches(item, targetModel),
90
133
  );
91
134
  }
@@ -96,7 +139,7 @@ function resolveChatGPTEffortMenuOption(items, desiredEffort) {
96
139
  return uniqueMatch(
97
140
  items,
98
141
  (item) =>
99
- ["button", "menuitem", "menuitemradio"].includes(item?.role) &&
142
+ ["slider", "button", "menuitem", "menuitemradio"].includes(item?.role) &&
100
143
  effortCandidateMatches(item, targetEffort),
101
144
  );
102
145
  }
@@ -107,7 +150,7 @@ function verifyChatGPTEffortSelection(items, desiredEffort) {
107
150
  return uniqueMatch(
108
151
  items,
109
152
  (item) =>
110
- (typeof item?.label === "string" || typeof item?.testId === "string") &&
153
+ (typeof item?.label === "string" || typeof item?.displayLabel === "string" || typeof item?.testId === "string") &&
111
154
  effortCandidateMatches(item, targetEffort),
112
155
  );
113
156
  }
@@ -115,7 +158,7 @@ function verifyChatGPTEffortSelection(items, desiredEffort) {
115
158
  function boundedOptionLabels(items) {
116
159
  if (!Array.isArray(items)) return [];
117
160
  return items
118
- .map((item) => String(item?.label || "").replace(/\s+/g, " ").trim().slice(0, 80))
161
+ .map((item) => String(item?.displayLabel || item?.label || "").replace(/\s+/g, " ").trim().slice(0, 80))
119
162
  .filter(Boolean)
120
163
  .filter((label, index, labels) => labels.indexOf(label) === index)
121
164
  .slice(0, 10);
@@ -123,6 +166,7 @@ function boundedOptionLabels(items) {
123
166
 
124
167
  module.exports = {
125
168
  CHATGPT_EFFORT_CHOICES,
169
+ CHATGPT_EFFORT_VALUE,
126
170
  boundedOptionLabels,
127
171
  normalizeChatGPTEffortChoice,
128
172
  normalizeChatGPTModelChoice,