surf-cli 2.16.1 → 2.18.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
@@ -146,6 +146,8 @@ surf remote list
146
146
 
147
147
  `surf install --listen` persists the explicit Tailnet address in the native-host wrapper. Re-run `surf install` without `--listen` to remove it. The address must be a Tailscale IPv4 or IPv6 address with a port; Surf does not bind every interface. Remote listeners currently require a POSIX browser host and are not supported by Windows native-host wrappers.
148
148
 
149
+ 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.
150
+
149
151
  Keep Tailscale policy restrictions as defense in depth. For example:
150
152
 
151
153
  ```json
@@ -188,7 +190,7 @@ surf --remote "$SURF_REMOTE" --remote-credential "$SURF_REMOTE_CREDENTIAL" \
188
190
 
189
191
  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
192
 
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.
193
+ 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
194
 
193
195
  ### Development Setup
194
196
 
@@ -378,10 +380,14 @@ SURF_SESSION=research surf screenshot # environment selector
378
380
  surf session.list --refresh # all bindings + queue state
379
381
  surf session.info research --refresh # target and scheduler details
380
382
  surf session.close research # closes Surf-created target
383
+ surf session.cleanup --idle-after 1h --dry-run # preview forgotten sessions
384
+ surf session.cleanup --idle-after 1h # remove idle bindings
381
385
  surf session.rebind research --tab-id 789 # adopt an existing tab
382
386
  surf session.reopen research # recreate from last URL
383
387
  ```
384
388
 
389
+ `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.
390
+
385
391
  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
392
 
387
393
  Recovery errors print an exact command that can be copied directly:
@@ -440,6 +446,19 @@ surf record --rect 0,200,1440,800 --output /tmp/region.gif
440
446
 
441
447
  `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
448
 
449
+ ### Local Video Recording
450
+
451
+ Capture a local tab as WebM/VP9 with ffmpeg:
452
+
453
+ ```bash
454
+ surf video start ./demo.webm --fps 30
455
+ surf video status
456
+ surf video stop
457
+ surf video restart ./take-2.webm --fps 60
458
+ ```
459
+
460
+ `--fps` defaults to 30 and is capped at 60. Video recording is local-only and requires `ffmpeg` on `PATH`.
461
+
443
462
  ### Animation Audit
444
463
 
445
464
  Sample matching elements over time and return a bounded JSON timeline for agent inspection:
@@ -526,13 +545,15 @@ surf aistudio.build "game" --keep-open --timeout 600 # Keep tab open, 1
526
545
 
527
546
  #### Oracle
528
547
 
529
- Use `surf oracle` for a durable, local ChatGPT consult instead of a quick `surf chatgpt` one-shot. It persists jobs by conversation URL, supports repeatable file-context globs, and verifies requested model and reasoning effort before submission. ChatGPT model aliases include `instant`, `thinking`, `pro`, `gpt-5.5`, and `gpt-5.6-sol`. Use `--model gpt-5.6-sol --effort pro` for GPT-5.6 Sol with Pro effort.
548
+ 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.
549
+
550
+ 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
551
 
531
552
  ```bash
532
- surf oracle ask "review this change" --files "src/**/*.ts" --model gpt-5.5 --effort pro --detach --json
553
+ surf oracle ask "review this change" --files "src/**/*.ts" --file ./design.md --model gpt-5.5 --effort pro --github --detach --json
533
554
  surf oracle status <job-id> --json
534
555
  surf oracle result <job-id> --wait --json
535
- surf oracle follow <job-id> "challenge that recommendation" --detach --json
556
+ surf oracle follow <job-id> "challenge that recommendation" --file ./follow-up.md --github --detach --json
536
557
  ```
537
558
 
538
559
  Only one oracle job can be in flight. Sensitive filename patterns and gitignored context are blocked unless `--allow-sensitive` is explicit.
@@ -807,6 +828,8 @@ SURF_REMOTE # Remote Surf endpoint as host:port (overrides SURF_SO
807
828
  SURF_REMOTE_CREDENTIAL # Client Ed25519 credential for the selected remote endpoint
808
829
  SURF_REMOTE_STATE_DIR # Host identity/authorization directory (default: ~/.surf/remote)
809
830
  SURF_LISTEN # Native-host Tailnet bind address as <tailscale-ip>:<port>
831
+ SURF_SOCKET_MODE # Advanced POSIX local socket mode: 600 (default) or 660
832
+ SURF_SOCKET_GROUP # Group name or numeric gid required with mode 660
810
833
  SURF_NODE_PATH # Path to node binary (for native host wrapper)
811
834
  SURF_HOST_PATH # Path to native/host.cjs (for native host wrapper)
812
835
  SURF_EXTENSION_PATH # Path to extension dist/ directory
@@ -820,6 +843,7 @@ SURF_EXTENSION_PATH # Path to extension dist/ directory
820
843
  - `SURF_REMOTE_CREDENTIAL`: Credential used for mutual remote authentication. `--remote-credential <path>` overrides it.
821
844
  - `SURF_REMOTE_STATE_DIR`: Advanced host-side override for the mode-0700 identity and client registry directory.
822
845
  - `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.
846
+ - `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
847
  - `SURF_NODE_PATH` / `SURF_HOST_PATH`: Package manager installs (e.g., Nix) that store binaries in non-standard locations
824
848
  - `SURF_EXTENSION_PATH`: Package managers that create stable symlinks instead of changing paths on reinstall
825
849
 
@@ -996,9 +1020,9 @@ pi -e /path/to/surf-cli/pi-extension/surf.ts
996
1020
 
997
1021
  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
1022
 
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` and `options.effort` for starts, 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. Capacity stays fail-closed: Surf returns the blocking job id instead of silently queueing a second ChatGPT job.
1023
+ 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
1024
 
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.
1025
+ 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
1026
 
1003
1027
  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
1028
 
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,