codex-chatgpt-control 0.2.0-alpha.1 → 0.5.0-alpha.1

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 (233) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +42 -2
  3. package/contracts/v1/fixtures/backend-capabilities.json +11 -0
  4. package/contracts/v1/fixtures/command-descriptors.json +289 -7
  5. package/contracts/v1/fixtures/describe-runner-run.json +5 -2
  6. package/contracts/v1/fixtures/doctor-scenario-preflight.json +45 -2
  7. package/contracts/v1/fixtures/help-root.json +1 -1
  8. package/contracts/v1/fixtures/output-json-parse-success.json +9 -0
  9. package/contracts/v1/fixtures/reports-create-redacted.json +1 -1
  10. package/contracts/v1/fixtures/run-timeout-partial.json +15 -4
  11. package/contracts/v1/fixtures/stream-in-progress.ndjson +3 -0
  12. package/contracts/v1/fixtures/stream-submitted-completed.ndjson +1 -1
  13. package/contracts/v1/fixtures/surface-chat-legacy.json +35 -0
  14. package/contracts/v1/fixtures/surface-chat-simplified.json +37 -0
  15. package/contracts/v1/fixtures/surface-sidebar-false-positive.json +29 -0
  16. package/contracts/v1/fixtures/surface-work-advanced.json +45 -0
  17. package/contracts/v1/fixtures/surface-work-basic.json +38 -0
  18. package/contracts/v1/fixtures/workflow-ask-success.json +8 -0
  19. package/contracts/v1/manifest.json +32 -1
  20. package/contracts/v1/parity-suite.json +121 -1
  21. package/contracts/v1/schemas/backend-request.schema.json +15 -0
  22. package/contracts/v1/schemas/manifest.schema.json +6 -3
  23. package/contracts/v1/schemas/surface-profile.schema.json +166 -0
  24. package/dist/codex-chatgpt-control-backend.mjs +3198 -340
  25. package/dist/codex-chatgpt-control.bundle.mjs +4454 -1555
  26. package/dist/src/backend/client.d.ts +26 -1
  27. package/dist/src/backend/client.js +23 -1
  28. package/dist/src/backend/protocol.d.ts +1 -1
  29. package/dist/src/backend/protocol.js +11 -0
  30. package/dist/src/backend/session.js +22 -0
  31. package/dist/src/browser/attach.d.ts +1 -0
  32. package/dist/src/browser/attach.js +3 -3
  33. package/dist/src/browser/clipboard.d.ts +11 -0
  34. package/dist/src/browser/clipboard.js +34 -7
  35. package/dist/src/browser/page-state.js +17 -4
  36. package/dist/src/client.d.ts +32 -1
  37. package/dist/src/client.js +72 -16
  38. package/dist/src/commands/artifacts.js +1 -7
  39. package/dist/src/commands/configuration.d.ts +15 -0
  40. package/dist/src/commands/configuration.js +674 -0
  41. package/dist/src/commands/context.js +3 -1
  42. package/dist/src/commands/conversation.d.ts +15 -0
  43. package/dist/src/commands/conversation.js +44 -0
  44. package/dist/src/commands/deadline.d.ts +8 -0
  45. package/dist/src/commands/deadline.js +14 -0
  46. package/dist/src/commands/doctor.js +47 -3
  47. package/dist/src/commands/experience.d.ts +12 -0
  48. package/dist/src/commands/experience.js +288 -0
  49. package/dist/src/commands/files.js +61 -13
  50. package/dist/src/commands/messages.d.ts +2 -1
  51. package/dist/src/commands/messages.js +349 -87
  52. package/dist/src/commands/modes.d.ts +4 -1
  53. package/dist/src/commands/modes.js +243 -44
  54. package/dist/src/commands/probes.d.ts +15 -0
  55. package/dist/src/commands/probes.js +64 -0
  56. package/dist/src/commands/registry.js +102 -8
  57. package/dist/src/commands/reports.js +1 -1
  58. package/dist/src/commands/response-actions.js +1 -7
  59. package/dist/src/commands/sequence.js +24 -1
  60. package/dist/src/commands/session.d.ts +2 -0
  61. package/dist/src/commands/session.js +50 -1
  62. package/dist/src/commands/threads.js +3 -31
  63. package/dist/src/commands/work.d.ts +6 -0
  64. package/dist/src/commands/work.js +338 -0
  65. package/dist/src/dom/generation-state.d.ts +7 -0
  66. package/dist/src/dom/generation-state.js +7 -1
  67. package/dist/src/dom/label-match.d.ts +3 -0
  68. package/dist/src/dom/label-match.js +25 -0
  69. package/dist/src/dom/locale/am.d.ts +8 -1
  70. package/dist/src/dom/locale/am.js +8 -1
  71. package/dist/src/dom/locale/ar.d.ts +9 -1
  72. package/dist/src/dom/locale/ar.js +9 -1
  73. package/dist/src/dom/locale/bg.d.ts +9 -1
  74. package/dist/src/dom/locale/bg.js +9 -1
  75. package/dist/src/dom/locale/bn.d.ts +9 -1
  76. package/dist/src/dom/locale/bn.js +9 -1
  77. package/dist/src/dom/locale/bs.d.ts +8 -1
  78. package/dist/src/dom/locale/bs.js +8 -1
  79. package/dist/src/dom/locale/ca.d.ts +8 -1
  80. package/dist/src/dom/locale/ca.js +8 -1
  81. package/dist/src/dom/locale/cs.d.ts +8 -1
  82. package/dist/src/dom/locale/cs.js +8 -1
  83. package/dist/src/dom/locale/da.d.ts +7 -1
  84. package/dist/src/dom/locale/da.js +7 -1
  85. package/dist/src/dom/locale/de.d.ts +10 -3
  86. package/dist/src/dom/locale/de.js +10 -3
  87. package/dist/src/dom/locale/el.d.ts +8 -1
  88. package/dist/src/dom/locale/el.js +8 -1
  89. package/dist/src/dom/locale/en.d.ts +40 -1
  90. package/dist/src/dom/locale/en.js +42 -1
  91. package/dist/src/dom/locale/es-419.d.ts +8 -1
  92. package/dist/src/dom/locale/es-419.js +8 -1
  93. package/dist/src/dom/locale/es-ES.d.ts +8 -1
  94. package/dist/src/dom/locale/es-ES.js +8 -1
  95. package/dist/src/dom/locale/et.d.ts +8 -1
  96. package/dist/src/dom/locale/et.js +8 -1
  97. package/dist/src/dom/locale/fa.d.ts +9 -1
  98. package/dist/src/dom/locale/fa.js +9 -1
  99. package/dist/src/dom/locale/fi.d.ts +8 -1
  100. package/dist/src/dom/locale/fi.js +8 -1
  101. package/dist/src/dom/locale/fr-CA.d.ts +8 -1
  102. package/dist/src/dom/locale/fr-CA.js +8 -1
  103. package/dist/src/dom/locale/fr-FR.d.ts +8 -3
  104. package/dist/src/dom/locale/fr-FR.js +8 -3
  105. package/dist/src/dom/locale/gu.d.ts +8 -1
  106. package/dist/src/dom/locale/gu.js +8 -1
  107. package/dist/src/dom/locale/hi.d.ts +8 -1
  108. package/dist/src/dom/locale/hi.js +8 -1
  109. package/dist/src/dom/locale/hr.d.ts +7 -1
  110. package/dist/src/dom/locale/hr.js +7 -1
  111. package/dist/src/dom/locale/hu.d.ts +8 -1
  112. package/dist/src/dom/locale/hu.js +8 -1
  113. package/dist/src/dom/locale/hy.d.ts +9 -1
  114. package/dist/src/dom/locale/hy.js +9 -1
  115. package/dist/src/dom/locale/id.d.ts +8 -1
  116. package/dist/src/dom/locale/id.js +8 -1
  117. package/dist/src/dom/locale/index.d.ts +20 -1
  118. package/dist/src/dom/locale/index.js +102 -0
  119. package/dist/src/dom/locale/is.d.ts +8 -1
  120. package/dist/src/dom/locale/is.js +8 -1
  121. package/dist/src/dom/locale/it.d.ts +8 -1
  122. package/dist/src/dom/locale/it.js +8 -1
  123. package/dist/src/dom/locale/ja.d.ts +8 -1
  124. package/dist/src/dom/locale/ja.js +8 -1
  125. package/dist/src/dom/locale/ka.d.ts +8 -1
  126. package/dist/src/dom/locale/ka.js +8 -1
  127. package/dist/src/dom/locale/kk.d.ts +8 -1
  128. package/dist/src/dom/locale/kk.js +8 -1
  129. package/dist/src/dom/locale/kn.d.ts +9 -1
  130. package/dist/src/dom/locale/kn.js +9 -1
  131. package/dist/src/dom/locale/ko.d.ts +8 -1
  132. package/dist/src/dom/locale/ko.js +8 -1
  133. package/dist/src/dom/locale/lt.d.ts +9 -1
  134. package/dist/src/dom/locale/lt.js +9 -1
  135. package/dist/src/dom/locale/lv.d.ts +8 -1
  136. package/dist/src/dom/locale/lv.js +8 -1
  137. package/dist/src/dom/locale/mk.d.ts +7 -1
  138. package/dist/src/dom/locale/mk.js +7 -1
  139. package/dist/src/dom/locale/ml.d.ts +9 -1
  140. package/dist/src/dom/locale/ml.js +9 -1
  141. package/dist/src/dom/locale/mn.d.ts +9 -1
  142. package/dist/src/dom/locale/mn.js +9 -1
  143. package/dist/src/dom/locale/mr.d.ts +9 -1
  144. package/dist/src/dom/locale/mr.js +9 -1
  145. package/dist/src/dom/locale/ms.d.ts +8 -1
  146. package/dist/src/dom/locale/ms.js +8 -1
  147. package/dist/src/dom/locale/my.d.ts +8 -1
  148. package/dist/src/dom/locale/my.js +8 -1
  149. package/dist/src/dom/locale/nb.d.ts +8 -1
  150. package/dist/src/dom/locale/nb.js +8 -1
  151. package/dist/src/dom/locale/nl.d.ts +8 -1
  152. package/dist/src/dom/locale/nl.js +8 -1
  153. package/dist/src/dom/locale/pa.d.ts +9 -1
  154. package/dist/src/dom/locale/pa.js +9 -1
  155. package/dist/src/dom/locale/pl.d.ts +8 -1
  156. package/dist/src/dom/locale/pl.js +8 -1
  157. package/dist/src/dom/locale/pt-BR.d.ts +8 -1
  158. package/dist/src/dom/locale/pt-BR.js +8 -1
  159. package/dist/src/dom/locale/pt-PT.d.ts +8 -1
  160. package/dist/src/dom/locale/pt-PT.js +8 -1
  161. package/dist/src/dom/locale/ro.d.ts +7 -1
  162. package/dist/src/dom/locale/ro.js +7 -1
  163. package/dist/src/dom/locale/ru.d.ts +5 -1
  164. package/dist/src/dom/locale/ru.js +5 -1
  165. package/dist/src/dom/locale/sk.d.ts +8 -1
  166. package/dist/src/dom/locale/sk.js +8 -1
  167. package/dist/src/dom/locale/sl.d.ts +8 -1
  168. package/dist/src/dom/locale/sl.js +8 -1
  169. package/dist/src/dom/locale/so.d.ts +8 -1
  170. package/dist/src/dom/locale/so.js +8 -1
  171. package/dist/src/dom/locale/sq.d.ts +8 -1
  172. package/dist/src/dom/locale/sq.js +8 -1
  173. package/dist/src/dom/locale/sr.d.ts +5 -1
  174. package/dist/src/dom/locale/sr.js +5 -1
  175. package/dist/src/dom/locale/sv.d.ts +8 -1
  176. package/dist/src/dom/locale/sv.js +8 -1
  177. package/dist/src/dom/locale/sw.d.ts +8 -1
  178. package/dist/src/dom/locale/sw.js +8 -1
  179. package/dist/src/dom/locale/ta.d.ts +9 -1
  180. package/dist/src/dom/locale/ta.js +9 -1
  181. package/dist/src/dom/locale/te.d.ts +9 -1
  182. package/dist/src/dom/locale/te.js +9 -1
  183. package/dist/src/dom/locale/th.d.ts +8 -1
  184. package/dist/src/dom/locale/th.js +8 -1
  185. package/dist/src/dom/locale/tl.d.ts +4 -5
  186. package/dist/src/dom/locale/tl.js +4 -5
  187. package/dist/src/dom/locale/tr.d.ts +8 -1
  188. package/dist/src/dom/locale/tr.js +8 -1
  189. package/dist/src/dom/locale/types.d.ts +25 -1
  190. package/dist/src/dom/locale/uk.d.ts +8 -1
  191. package/dist/src/dom/locale/uk.js +8 -1
  192. package/dist/src/dom/locale/ur.d.ts +8 -1
  193. package/dist/src/dom/locale/ur.js +8 -1
  194. package/dist/src/dom/locale/vi.d.ts +8 -1
  195. package/dist/src/dom/locale/vi.js +8 -1
  196. package/dist/src/dom/locale/zh-HK.d.ts +9 -1
  197. package/dist/src/dom/locale/zh-HK.js +10 -2
  198. package/dist/src/dom/locale/zh-Hans.d.ts +9 -1
  199. package/dist/src/dom/locale/zh-Hans.js +9 -1
  200. package/dist/src/dom/locale/zh-TW.d.ts +9 -1
  201. package/dist/src/dom/locale/zh-TW.js +9 -1
  202. package/dist/src/dom/menus.js +13 -2
  203. package/dist/src/dom/selectors.js +6 -1
  204. package/dist/src/dom/wait-snapshot.d.ts +43 -0
  205. package/dist/src/dom/wait-snapshot.js +127 -0
  206. package/dist/src/index.d.ts +3 -0
  207. package/dist/src/index.js +3 -0
  208. package/dist/src/runner/responses.d.ts +3 -1
  209. package/dist/src/runner/responses.js +16 -1
  210. package/dist/src/runner/result.js +129 -4
  211. package/dist/src/runner/stream.d.ts +1 -1
  212. package/dist/src/runner/stream.js +6 -0
  213. package/dist/src/runner/types.d.ts +35 -1
  214. package/dist/src/safety/risk.d.ts +11 -0
  215. package/dist/src/safety/risk.js +11 -0
  216. package/dist/src/scripts/apply-intelligence-locale-captures.d.ts +6 -0
  217. package/dist/src/scripts/apply-intelligence-locale-captures.js +147 -15
  218. package/dist/src/scripts/capture-intelligence-locales.d.ts +3 -0
  219. package/dist/src/scripts/capture-intelligence-locales.js +372 -3
  220. package/dist/src/scripts/capture-surface-profile.d.ts +23 -0
  221. package/dist/src/scripts/capture-surface-profile.js +304 -0
  222. package/dist/src/scripts/live-smoke/scenarios.js +34 -1
  223. package/dist/src/scripts/live-smoke.js +1 -0
  224. package/dist/src/types.d.ts +273 -2
  225. package/package.json +4 -3
  226. package/references/2026-07-16-chat-work-surfaces.md +87 -0
  227. package/references/agents-runner.md +13 -2
  228. package/references/backend-protocol.md +19 -7
  229. package/references/language-coverage.md +40 -3
  230. package/references/localization.md +48 -10
  231. package/references/python-parity.md +16 -4
  232. package/references/responses-adapter.md +12 -1
  233. package/references/troubleshooting.md +49 -6
@@ -1,11 +1,16 @@
1
1
  import { resultError, resultOk } from "../errors.js";
2
2
  import { enumerateVisibleMenuItems, findUniqueMenuItem } from "../dom/menus.js";
3
3
  import { localeLabels } from "../dom/locale-labels.js";
4
+ import { isShortLatinToken, normalizeForLabelMatch, visibleLabelMatches } from "../dom/label-match.js";
4
5
  import { normalizeLabel, normalizeWhitespace } from "../dom/visible-text.js";
5
6
  import { contextFromPage } from "./context.js";
6
- import { bootstrap } from "./session.js";
7
+ import { ensurePage } from "./session.js";
7
8
  const DEFAULT_MODE_EFFORT = "Thinking";
8
- const CURRENT_MODE_LABELS = [...localeLabels.modeLabels];
9
+ const CURRENT_MODE_LABELS = dedupeLabels([
10
+ ...localeLabels.modeLabels,
11
+ ...Object.values(localeLabels.modeOptions).flat(),
12
+ ...Object.values(localeLabels.configurationOptions).flat(),
13
+ ]);
9
14
  const MODE_OPENER_LABELS = [...CURRENT_MODE_LABELS.filter(label => label !== "Pro"), ...localeLabels.modeOpenerExtra];
10
15
  const MODEL_VERSION_FAMILY_PATTERN = /^gpt[\s-]/i;
11
16
  const MODEL_VERSION_LABEL_PATTERN = /^(?:o\d+|\d+(?:\.\d+)?)$/i;
@@ -13,9 +18,33 @@ const CANONICAL_INTELLIGENCE_ORDER = new Map([
13
18
  ["instant", 0],
14
19
  ["medium", 1],
15
20
  ["high", 2],
16
- ["extra high", 3],
21
+ ["extraHigh", 3],
17
22
  ["pro", 4],
18
23
  ]);
24
+ const MODE_OPTION_IDS = [
25
+ "latest",
26
+ "instant",
27
+ "thinking",
28
+ "extended",
29
+ "medium",
30
+ "high",
31
+ "extraHigh",
32
+ "pro",
33
+ ];
34
+ const MODE_ID_ALIASES = {
35
+ latest: ["latest"],
36
+ instant: ["instant"],
37
+ thinking: ["thinking"],
38
+ extended: ["extended"],
39
+ medium: ["medium"],
40
+ high: ["high"],
41
+ extraHigh: ["extra high", "extra-high", "extra_high", "extrahigh"],
42
+ pro: ["pro"],
43
+ };
44
+ const THREAD_ACTION_MENU_LABELS = new Set(localeLabels.threadActionMenuItems.map(normalizeForLabelMatch));
45
+ const THREAD_ACTION_PREFIXES = localeLabels.threadActionPrefixes
46
+ .map(normalizeForLabelMatch)
47
+ .filter(prefix => prefix.length > 0);
19
48
  export async function setMode(env, args) {
20
49
  const boot = await ensurePage(env);
21
50
  if (!boot.ok) {
@@ -23,9 +52,9 @@ export async function setMode(env, args) {
23
52
  }
24
53
  const page = env.page;
25
54
  try {
26
- const requested = requestedModeLabels(args);
55
+ const requested = requestedModeSelections(args);
27
56
  const requestedVersion = requestedModelVersion(args);
28
- const requestedForOpening = requestedVersion === undefined ? requested : [...requested, requestedVersion];
57
+ const requestedForOpening = requestedVersion === undefined ? requested : [...requested, requestedModeSelection(requestedVersion)];
29
58
  const opened = await waitForModeMenu(page, requestedForOpening, args.timeoutMs ?? 30000);
30
59
  if (requestedVersion === undefined && opened.alreadySelected.length === requested.length) {
31
60
  return resultOk({ selected: opened.alreadySelected, candidates: opened.modeButtons }, await contextFromPage(page));
@@ -36,15 +65,25 @@ export async function setMode(env, args) {
36
65
  await page.waitForTimeout?.(250);
37
66
  const candidates = await enumerateVisibleMenuItems(page);
38
67
  const selected = [];
39
- for (const item of requested) {
40
- const match = findModeMenuItem(candidates, item);
68
+ if (requested.length > 0 && shouldRejectAsWrongModeMenu(candidates)) {
69
+ const candidateLabels = candidates.map(candidate => candidate.label);
70
+ return {
71
+ ok: false,
72
+ status: "unsupported",
73
+ warnings: [],
74
+ blocker: selectorDriftBlocker("Visible menu appears to be a thread/action menu, not the ChatGPT mode menu.", candidateLabels),
75
+ context: await contextFromPage(page)
76
+ };
77
+ }
78
+ for (const request of requested) {
79
+ const match = findModeMenuItem(candidates, request);
41
80
  if (match === undefined) {
42
81
  const candidateLabels = candidates.map(candidate => candidate.label);
43
82
  return {
44
83
  ok: false,
45
84
  status: "unsupported",
46
85
  warnings: [],
47
- blocker: selectorDriftBlocker(`Mode option "${item}" was not found or was ambiguous.`, candidateLabels),
86
+ blocker: selectorDriftBlocker(`Mode option "${request.requested}" was not found or was ambiguous.`, candidateLabels),
48
87
  context: await contextFromPage(page)
49
88
  };
50
89
  }
@@ -68,7 +107,53 @@ export async function setMode(env, args) {
68
107
  }
69
108
  selected.push(versionResult.selected);
70
109
  }
71
- return resultOk({ selected, candidates: candidateLabels }, await contextFromPage(page));
110
+ const verificationWarnings = await modeVerificationWarnings(page, requested, selected);
111
+ return resultOk({ selected, candidates: candidateLabels }, await contextFromPage(page), verificationWarnings);
112
+ }
113
+ catch (error) {
114
+ return resultError(error instanceof Error ? error : new Error(String(error)), await contextFromPage(page));
115
+ }
116
+ }
117
+ /**
118
+ * Post-condition check: after clicking mode rows, the composer's mode-labelled controls
119
+ * should reflect the requested selection. A mismatch does not fail the command — some
120
+ * ChatGPT surfaces do not echo every mode — but it must be visible to callers so a
121
+ * modes.set "ok" is never silently treated as a verified mode (the "Pin ... Pro ..."
122
+ * incident shape). Model-version selections are not verified because versions are
123
+ * usually not echoed on the composer.
124
+ */
125
+ async function modeVerificationWarnings(page, requested, selected) {
126
+ if (requested.length === 0) {
127
+ return [];
128
+ }
129
+ await page.waitForTimeout?.(250);
130
+ const visibleButtons = await visibleModeButtonLabelList(page);
131
+ if (visibleButtons.length === 0) {
132
+ return [
133
+ `Mode selection is unverified: no mode-labelled composer control was visible after selecting ${selected.map(label => JSON.stringify(label)).join(", ")}. Use modes.get or inspect modeStep before treating this as a verified mode.`
134
+ ];
135
+ }
136
+ const unverified = requested.filter(request => findUniqueVisibleLabelForRequest(visibleButtons, request) === undefined);
137
+ if (unverified.length === 0) {
138
+ return [];
139
+ }
140
+ return [
141
+ `Mode selection is unverified: requested ${unverified.map(request => JSON.stringify(request.requested)).join(", ")} is not reflected by the visible mode controls (${visibleButtons.join(", ")}). Use modes.get or inspect modeStep before treating this as a verified mode.`
142
+ ];
143
+ }
144
+ export async function getMode(env, args = {}) {
145
+ void args;
146
+ const boot = await ensurePage(env);
147
+ if (!boot.ok) {
148
+ return boot;
149
+ }
150
+ const page = env.page;
151
+ try {
152
+ const modes = await visibleModeButtonLabelList(page);
153
+ const warnings = modes.length === 0
154
+ ? ["No mode-labelled composer control is currently visible, so the active ChatGPT mode could not be read."]
155
+ : [];
156
+ return resultOk({ modes }, await contextFromPage(page), warnings);
72
157
  }
73
158
  catch (error) {
74
159
  return resultError(error instanceof Error ? error : new Error(String(error)), await contextFromPage(page));
@@ -84,7 +169,7 @@ async function waitForModeMenu(page, requested, timeoutMs) {
84
169
  return { opened: false, alreadySelected, modeButtons };
85
170
  }
86
171
  const openMenuItems = await enumerateVisibleMenuItems(page);
87
- if (looksLikeModeMenu(openMenuItems.map(item => item.label))) {
172
+ if (looksLikeModeMenu(openMenuItems)) {
88
173
  return { opened: true, alreadySelected: [], modeButtons };
89
174
  }
90
175
  if (await clickModeOpener(page, modeButtons)) {
@@ -140,12 +225,6 @@ export async function selectTool(env, args) {
140
225
  return resultError(error instanceof Error ? error : new Error(String(error)), await contextFromPage(page));
141
226
  }
142
227
  }
143
- async function ensurePage(env) {
144
- if (env.page !== undefined) {
145
- return resultOk({}, await contextFromPage(env.page));
146
- }
147
- return bootstrap(env, { preferExistingTab: true });
148
- }
149
228
  async function clickFirstUniqueButton(page, labels) {
150
229
  for (const label of labels) {
151
230
  const roleLocator = page.getByRole?.("button", { name: label, exact: true });
@@ -165,12 +244,57 @@ async function clickModeOpener(page, modeButtons) {
165
244
  }
166
245
  return clickFirstUniqueButton(page, MODE_OPENER_LABELS);
167
246
  }
168
- function looksLikeModeMenu(labels) {
169
- return labels.some(label => {
170
- const normalized = normalizeLabel(label);
171
- return CURRENT_MODE_LABELS.some(modeLabel => visibleLabelMatches(normalized, normalizeLabel(modeLabel)));
247
+ function isThreadActionLabel(label) {
248
+ const normalized = normalizeForLabelMatch(label);
249
+ if (THREAD_ACTION_MENU_LABELS.has(normalized)) {
250
+ return true;
251
+ }
252
+ return THREAD_ACTION_PREFIXES.some(prefix => normalized.startsWith(`${prefix} `));
253
+ }
254
+ function hasStructuralModeEvidence(item) {
255
+ if (item.testId?.startsWith("model-switcher-") === true) {
256
+ return true;
257
+ }
258
+ return MODEL_VERSION_FAMILY_PATTERN.test(item.label) || MODEL_VERSION_LABEL_PATTERN.test(item.label);
259
+ }
260
+ /**
261
+ * Whether a single menu item is evidence that the visible menu is the ChatGPT mode menu.
262
+ *
263
+ * A fuzzy token hit on a short mode word such as "Pro" is NOT evidence: thread titles
264
+ * inside sidebar action menus can contain it ("Pin CopyBench Pro Consultation"), which is
265
+ * exactly the false positive that let modes.set select a pin action as the Pro mode.
266
+ * Thread-action rows can never be evidence, even when their title embeds a full mode word.
267
+ * Exact matching uses normalizeForLabelMatch (NFKC) so the evidence set and the veto set
268
+ * share identical Unicode folding — fullwidth/compatibility-form labels match both sides.
269
+ */
270
+ function isModeMenuEvidence(item) {
271
+ if (hasStructuralModeEvidence(item)) {
272
+ return true;
273
+ }
274
+ if (isThreadActionLabel(item.label)) {
275
+ return false;
276
+ }
277
+ const normalized = normalizeForLabelMatch(item.label);
278
+ return CURRENT_MODE_LABELS.some(modeLabel => {
279
+ const normalizedMode = normalizeForLabelMatch(modeLabel);
280
+ if (normalized === normalizedMode) {
281
+ return true;
282
+ }
283
+ return !isShortLatinToken(normalizedMode) && visibleLabelMatches(normalized, normalizedMode);
172
284
  });
173
285
  }
286
+ function looksLikeModeMenu(items) {
287
+ return items.some(item => isModeMenuEvidence(item));
288
+ }
289
+ function shouldRejectAsWrongModeMenu(items) {
290
+ if (items.length === 0) {
291
+ return false;
292
+ }
293
+ if (items.some(item => isModeMenuEvidence(item))) {
294
+ return false;
295
+ }
296
+ return items.some(item => isThreadActionLabel(item.label));
297
+ }
174
298
  async function clickMenuItem(page, label) {
175
299
  if (await clickModelSwitcherMenuItem(page, label)) {
176
300
  return true;
@@ -262,28 +386,81 @@ function toolLabels(tool) {
262
386
  const known = localeLabels.tools[tool];
263
387
  return known !== undefined ? [...known] : [tool];
264
388
  }
265
- function findModeMenuItem(candidates, wanted) {
266
- const exact = findUniqueMenuItem(candidates, wanted);
267
- if (exact !== undefined) {
268
- return exact;
389
+ function findModeMenuItem(candidates, request) {
390
+ const selectable = candidates.filter(candidate => !isThreadActionLabel(candidate.label));
391
+ for (const wanted of request.labels) {
392
+ const match = findUniqueModeMenuItem(selectable, wanted);
393
+ if (match !== undefined) {
394
+ return match;
395
+ }
269
396
  }
270
- const wantedIndex = CANONICAL_INTELLIGENCE_ORDER.get(normalizeLabel(wanted));
397
+ const wantedIndex = request.modeId === undefined ? undefined : CANONICAL_INTELLIGENCE_ORDER.get(request.modeId);
271
398
  if (wantedIndex === undefined) {
272
399
  return undefined;
273
400
  }
274
- const intelligenceItems = candidates.filter(candidate => candidate.role === "menuitemradio"
401
+ const intelligenceItems = selectable.filter(candidate => candidate.role === "menuitemradio"
275
402
  && !MODEL_VERSION_LABEL_PATTERN.test(candidate.label)
276
403
  && !MODEL_VERSION_FAMILY_PATTERN.test(candidate.label));
277
404
  return intelligenceItems.length >= CANONICAL_INTELLIGENCE_ORDER.size
278
405
  ? intelligenceItems[wantedIndex]
279
406
  : undefined;
280
407
  }
281
- function requestedModeLabels(args) {
408
+ /**
409
+ * Unique-match a wanted mode label. Exact normalized equality is always accepted; a
410
+ * fuzzy token hit on a short mode word such as "Pro" additionally requires structural
411
+ * mode-row evidence (a model-switcher test id or a menuitemradio role), so an arbitrary
412
+ * unique menu row whose text merely contains "Pro" cannot be selected as the mode.
413
+ * Exact matching uses normalizeForLabelMatch (NFKC), consistent with isModeMenuEvidence.
414
+ */
415
+ function findUniqueModeMenuItem(items, wanted) {
416
+ const normalizedWanted = normalizeForLabelMatch(wanted);
417
+ const exact = items.filter(item => normalizeForLabelMatch(item.label) === normalizedWanted);
418
+ if (exact.length === 1) {
419
+ return exact[0];
420
+ }
421
+ const fuzzy = items.filter(item => visibleLabelMatches(item.label, wanted));
422
+ if (fuzzy.length !== 1) {
423
+ return undefined;
424
+ }
425
+ const match = fuzzy[0];
426
+ if (!isShortLatinToken(normalizedWanted)) {
427
+ return match;
428
+ }
429
+ return hasStructuralModeEvidence(match) || match.role === "menuitemradio" ? match : undefined;
430
+ }
431
+ function requestedModeSelections(args) {
282
432
  const requested = [args.model, args.intelligence, args.effort].filter((value) => value !== undefined);
283
433
  if (requestedModelVersion(args) !== undefined && requested.length === 0) {
284
434
  return [];
285
435
  }
286
- return requested.length > 0 ? requested : [DEFAULT_MODE_EFFORT];
436
+ return (requested.length > 0 ? requested : [DEFAULT_MODE_EFFORT]).map(requestedModeSelection);
437
+ }
438
+ function requestedModeSelection(requested) {
439
+ const modeId = modeOptionIdFor(requested);
440
+ const labels = modeId === undefined ? [requested] : localeLabels.modeOptions[modeId];
441
+ const request = {
442
+ requested,
443
+ labels: labels.length > 0 ? [...labels] : [requested],
444
+ };
445
+ if (modeId !== undefined) {
446
+ request.modeId = modeId;
447
+ }
448
+ return request;
449
+ }
450
+ function modeOptionIdFor(value) {
451
+ const normalized = normalizeModeLookupKey(value);
452
+ for (const id of MODE_OPTION_IDS) {
453
+ if (MODE_ID_ALIASES[id].some(alias => normalizeModeLookupKey(alias) === normalized)) {
454
+ return id;
455
+ }
456
+ if (localeLabels.modeOptions[id].some(label => normalizeModeLookupKey(label) === normalized)) {
457
+ return id;
458
+ }
459
+ }
460
+ return undefined;
461
+ }
462
+ function normalizeModeLookupKey(value) {
463
+ return normalizeForLabelMatch(value).replace(/[_-]+/g, " ");
287
464
  }
288
465
  function requestedModelVersion(args) {
289
466
  return args.modelVersion ?? args.version;
@@ -297,12 +474,6 @@ function findUniqueVisibleLabel(labels, wanted) {
297
474
  const fuzzy = labels.filter(label => visibleLabelMatches(normalizeLabel(label), normalized));
298
475
  return fuzzy.length === 1 ? fuzzy[0] : undefined;
299
476
  }
300
- function visibleLabelMatches(label, wanted) {
301
- if (wanted.length <= 3) {
302
- return new RegExp(`(^|[^a-z0-9])${escapeRegExp(wanted)}([^a-z0-9]|$)`, "i").test(label);
303
- }
304
- return label.includes(wanted);
305
- }
306
477
  function escapeRegExp(value) {
307
478
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
308
479
  }
@@ -311,34 +482,56 @@ function escapeAttributeValue(value) {
311
482
  }
312
483
  function findAlreadySelectedModes(visibleButtons, requested) {
313
484
  return requested
314
- .map(label => findUniqueVisibleLabel(visibleButtons, label))
485
+ .map(request => findUniqueVisibleLabelForRequest(visibleButtons, request))
315
486
  .filter((label) => label !== undefined);
316
487
  }
488
+ function findUniqueVisibleLabelForRequest(labels, request) {
489
+ for (const label of request.labels) {
490
+ const found = findUniqueVisibleLabel(labels, label);
491
+ if (found !== undefined) {
492
+ return found;
493
+ }
494
+ }
495
+ return undefined;
496
+ }
317
497
  async function selectModelVersion(page, requestedVersion, currentCandidates, timeoutMs) {
318
498
  let candidates = await enumerateVisibleMenuItems(page);
319
- if (!looksLikeModeMenu(candidates.map(candidate => candidate.label))) {
320
- const opened = await waitForModeMenu(page, [requestedVersion], timeoutMs);
499
+ if (!looksLikeModeMenu(candidates)) {
500
+ const opened = await waitForModeMenu(page, [{ requested: requestedVersion, labels: [requestedVersion] }], timeoutMs);
321
501
  if (opened.opened) {
322
502
  await page.waitForTimeout?.(250);
323
503
  candidates = await enumerateVisibleMenuItems(page);
324
504
  }
325
505
  }
326
- let exact = findExactMenuItem(candidates, requestedVersion);
506
+ let exact = findExactSelectableMenuItem(candidates, requestedVersion);
327
507
  if (exact !== undefined) {
328
- return await clickMenuItem(page, exact.label)
508
+ return await clickResolvedMenuItem(page, exact)
329
509
  ? { selected: exact.label, candidates: candidates.map(candidate => candidate.label) }
330
510
  : { candidates: candidates.map(candidate => candidate.label) };
331
511
  }
332
512
  const opened = await openModelVersionSubmenu(page, currentCandidates);
333
513
  candidates = await enumerateVisibleMenuItems(page);
334
- exact = findExactMenuItem(candidates, requestedVersion);
514
+ exact = findExactSelectableMenuItem(candidates, requestedVersion);
335
515
  if (!opened || exact === undefined) {
336
516
  return { candidates: candidates.map(candidate => candidate.label) };
337
517
  }
338
- return await clickMenuItem(page, exact.label)
518
+ return await clickResolvedMenuItem(page, exact)
339
519
  ? { selected: exact.label, candidates: candidates.map(candidate => candidate.label) }
340
520
  : { candidates: candidates.map(candidate => candidate.label) };
341
521
  }
522
+ function isModelVersionSubmenuOpener(item) {
523
+ return item.hasPopup === true
524
+ || (item.role !== "menuitemradio" && MODEL_VERSION_FAMILY_PATTERN.test(item.label));
525
+ }
526
+ async function clickResolvedMenuItem(page, item) {
527
+ if (item.testId !== undefined && await clickIfUnique(page.locator?.(`[data-testid="${escapeAttributeValue(item.testId)}"]`))) {
528
+ return true;
529
+ }
530
+ if (item.role !== undefined && await clickIfUnique(page.getByRole?.(item.role, { name: item.label, exact: true }))) {
531
+ return true;
532
+ }
533
+ return clickMenuItem(page, item.label);
534
+ }
342
535
  async function openModelVersionSubmenu(page, candidates) {
343
536
  const submenuOpeners = candidates.filter(item => item.hasPopup === true || MODEL_VERSION_FAMILY_PATTERN.test(item.label));
344
537
  if (submenuOpeners.length === 0) {
@@ -419,9 +612,10 @@ async function modelVersionMenuItemsAreVisible(page) {
419
612
  return (await enumerateVisibleMenuItems(page))
420
613
  .some(candidate => candidate.role === "menuitemradio" && MODEL_VERSION_LABEL_PATTERN.test(candidate.label));
421
614
  }
422
- function findExactMenuItem(items, wanted) {
615
+ function findExactSelectableMenuItem(items, wanted) {
423
616
  const normalized = normalizeLabel(wanted);
424
- const matches = items.filter(item => item.normalized === normalized);
617
+ const matches = items.filter(item => item.normalized === normalized
618
+ && !isModelVersionSubmenuOpener(item));
425
619
  return matches.length === 1 ? matches[0] : undefined;
426
620
  }
427
621
  function dedupeLabels(labels) {
@@ -481,9 +675,12 @@ async function visibleModeButtonLabelList(page) {
481
675
  }
482
676
  return text.includes(token);
483
677
  };
678
+ const scopedRoots = Array.from(document.querySelectorAll("main form, main [data-testid*='composer' i], main [class*='composer' i]"));
484
679
  return Array.from(document.querySelectorAll("button, [role='button']"))
485
680
  .map(node => {
486
681
  const element = node;
682
+ if (scopedRoots.length > 0 && !scopedRoots.some(root => root.contains(node)))
683
+ return "";
487
684
  const visibleText = (element.innerText ?? element.textContent ?? "").replace(/\s+/g, " ").trim();
488
685
  const ariaLabel = (element.getAttribute("aria-label") ?? "").replace(/\s+/g, " ").trim();
489
686
  const label = visibleText.length > 0 ? visibleText : ariaLabel;
@@ -495,7 +692,9 @@ async function visibleModeButtonLabelList(page) {
495
692
  if (visibleText.length === 0 && /feedback|conversation options|dismiss/i.test(ariaLabel))
496
693
  return "";
497
694
  const normalized = label.toLowerCase();
498
- if (!normalizedModeLabels.some(modeLabel => tokenMatches(normalized, modeLabel)))
695
+ const structuralModelControl = /model-switcher|model-selector|mode-selector/i.test(testId)
696
+ || /\b(?:gpt|sol|luna|terra)\b/i.test(label);
697
+ if (!structuralModelControl && !normalizedModeLabels.some(modeLabel => tokenMatches(normalized, modeLabel)))
499
698
  return "";
500
699
  return label;
501
700
  })
@@ -0,0 +1,15 @@
1
+ import { type Deadline } from "./deadline.js";
2
+ export type ProbeResult<T> = {
3
+ ok: true;
4
+ value: T;
5
+ warnings: string[];
6
+ } | {
7
+ ok: false;
8
+ timedOut?: boolean;
9
+ skipped?: boolean;
10
+ warnings: string[];
11
+ };
12
+ export type ProbeOptions = {
13
+ timeoutMs: number;
14
+ };
15
+ export declare function createSingleFlightProbe<A, T>(name: string, probe: (arg: A) => Promise<T>): (arg: A, deadline: Deadline, options: ProbeOptions) => Promise<ProbeResult<T>>;
@@ -0,0 +1,64 @@
1
+ import { childTimeoutMs } from "./deadline.js";
2
+ export function createSingleFlightProbe(name, probe) {
3
+ let inFlight;
4
+ return async (arg, deadline, options) => {
5
+ if (inFlight !== undefined) {
6
+ return {
7
+ ok: false,
8
+ skipped: true,
9
+ warnings: [`Skipped ${name} DOM probe because previous browser-side work is still in flight.`]
10
+ };
11
+ }
12
+ const timeoutMs = childTimeoutMs(deadline, options.timeoutMs);
13
+ if (timeoutMs <= 0) {
14
+ return {
15
+ ok: false,
16
+ timedOut: true,
17
+ warnings: [`Skipped ${name} DOM probe because no deadline budget remained.`]
18
+ };
19
+ }
20
+ const current = probe(arg);
21
+ inFlight = current;
22
+ current.finally(() => {
23
+ if (inFlight === current) {
24
+ inFlight = undefined;
25
+ }
26
+ }).catch(() => undefined);
27
+ let timeout;
28
+ try {
29
+ const value = await Promise.race([
30
+ current,
31
+ new Promise((_resolve, reject) => {
32
+ timeout = setTimeout(() => reject(new ProbeTimeoutError(timeoutMs)), timeoutMs);
33
+ })
34
+ ]);
35
+ return { ok: true, value, warnings: [] };
36
+ }
37
+ catch (error) {
38
+ if (error instanceof ProbeTimeoutError) {
39
+ return {
40
+ ok: false,
41
+ timedOut: true,
42
+ warnings: [`Timed out waiting ${timeoutMs}ms for ${name} DOM probe; this stopped SDK waiting but did not cancel browser-side work.`]
43
+ };
44
+ }
45
+ return {
46
+ ok: false,
47
+ warnings: [`${name} DOM probe failed: ${error instanceof Error ? error.message : String(error)}`]
48
+ };
49
+ }
50
+ finally {
51
+ if (timeout !== undefined) {
52
+ clearTimeout(timeout);
53
+ }
54
+ }
55
+ };
56
+ }
57
+ class ProbeTimeoutError extends Error {
58
+ timeoutMs;
59
+ constructor(timeoutMs) {
60
+ super(`Probe timed out after ${timeoutMs}ms.`);
61
+ this.timeoutMs = timeoutMs;
62
+ this.name = "ProbeTimeoutError";
63
+ }
64
+ }