codex-chatgpt-control 0.2.0-alpha.1 → 0.3.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 (179) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/contracts/v1/fixtures/backend-capabilities.json +1 -0
  3. package/contracts/v1/fixtures/command-descriptors.json +33 -2
  4. package/contracts/v1/fixtures/doctor-scenario-preflight.json +12 -1
  5. package/contracts/v1/fixtures/help-root.json +1 -1
  6. package/contracts/v1/fixtures/reports-create-redacted.json +1 -1
  7. package/contracts/v1/parity-suite.json +8 -1
  8. package/dist/codex-chatgpt-control-backend.mjs +1199 -159
  9. package/dist/codex-chatgpt-control.bundle.mjs +1198 -156
  10. package/dist/src/backend/protocol.d.ts +1 -1
  11. package/dist/src/backend/protocol.js +1 -0
  12. package/dist/src/backend/session.js +2 -0
  13. package/dist/src/browser/clipboard.d.ts +11 -0
  14. package/dist/src/browser/clipboard.js +34 -7
  15. package/dist/src/browser/page-state.js +12 -2
  16. package/dist/src/client.d.ts +2 -1
  17. package/dist/src/client.js +3 -2
  18. package/dist/src/commands/context.js +3 -1
  19. package/dist/src/commands/conversation.d.ts +15 -0
  20. package/dist/src/commands/conversation.js +44 -0
  21. package/dist/src/commands/deadline.d.ts +8 -0
  22. package/dist/src/commands/deadline.js +14 -0
  23. package/dist/src/commands/doctor.js +9 -2
  24. package/dist/src/commands/files.js +62 -8
  25. package/dist/src/commands/messages.js +212 -72
  26. package/dist/src/commands/modes.d.ts +4 -1
  27. package/dist/src/commands/modes.js +215 -30
  28. package/dist/src/commands/probes.d.ts +15 -0
  29. package/dist/src/commands/probes.js +64 -0
  30. package/dist/src/commands/registry.js +34 -2
  31. package/dist/src/commands/reports.js +1 -1
  32. package/dist/src/commands/threads.js +2 -24
  33. package/dist/src/dom/generation-state.d.ts +7 -0
  34. package/dist/src/dom/generation-state.js +7 -1
  35. package/dist/src/dom/label-match.d.ts +3 -0
  36. package/dist/src/dom/label-match.js +25 -0
  37. package/dist/src/dom/locale/am.d.ts +6 -0
  38. package/dist/src/dom/locale/am.js +6 -0
  39. package/dist/src/dom/locale/ar.d.ts +7 -0
  40. package/dist/src/dom/locale/ar.js +7 -0
  41. package/dist/src/dom/locale/bg.d.ts +7 -0
  42. package/dist/src/dom/locale/bg.js +7 -0
  43. package/dist/src/dom/locale/bn.d.ts +7 -0
  44. package/dist/src/dom/locale/bn.js +7 -0
  45. package/dist/src/dom/locale/bs.d.ts +6 -0
  46. package/dist/src/dom/locale/bs.js +6 -0
  47. package/dist/src/dom/locale/ca.d.ts +6 -0
  48. package/dist/src/dom/locale/ca.js +6 -0
  49. package/dist/src/dom/locale/cs.d.ts +6 -0
  50. package/dist/src/dom/locale/cs.js +6 -0
  51. package/dist/src/dom/locale/da.d.ts +5 -0
  52. package/dist/src/dom/locale/da.js +5 -0
  53. package/dist/src/dom/locale/de.d.ts +6 -0
  54. package/dist/src/dom/locale/de.js +6 -0
  55. package/dist/src/dom/locale/el.d.ts +6 -0
  56. package/dist/src/dom/locale/el.js +6 -0
  57. package/dist/src/dom/locale/en.d.ts +14 -0
  58. package/dist/src/dom/locale/en.js +15 -0
  59. package/dist/src/dom/locale/es-419.d.ts +6 -0
  60. package/dist/src/dom/locale/es-419.js +6 -0
  61. package/dist/src/dom/locale/es-ES.d.ts +6 -0
  62. package/dist/src/dom/locale/es-ES.js +6 -0
  63. package/dist/src/dom/locale/et.d.ts +6 -0
  64. package/dist/src/dom/locale/et.js +6 -0
  65. package/dist/src/dom/locale/fa.d.ts +7 -0
  66. package/dist/src/dom/locale/fa.js +7 -0
  67. package/dist/src/dom/locale/fi.d.ts +6 -0
  68. package/dist/src/dom/locale/fi.js +6 -0
  69. package/dist/src/dom/locale/fr-CA.d.ts +6 -0
  70. package/dist/src/dom/locale/fr-CA.js +6 -0
  71. package/dist/src/dom/locale/fr-FR.d.ts +5 -0
  72. package/dist/src/dom/locale/fr-FR.js +5 -0
  73. package/dist/src/dom/locale/gu.d.ts +6 -0
  74. package/dist/src/dom/locale/gu.js +6 -0
  75. package/dist/src/dom/locale/hi.d.ts +6 -0
  76. package/dist/src/dom/locale/hi.js +6 -0
  77. package/dist/src/dom/locale/hr.d.ts +5 -0
  78. package/dist/src/dom/locale/hr.js +5 -0
  79. package/dist/src/dom/locale/hu.d.ts +6 -0
  80. package/dist/src/dom/locale/hu.js +6 -0
  81. package/dist/src/dom/locale/hy.d.ts +7 -0
  82. package/dist/src/dom/locale/hy.js +7 -0
  83. package/dist/src/dom/locale/id.d.ts +6 -0
  84. package/dist/src/dom/locale/id.js +6 -0
  85. package/dist/src/dom/locale/index.d.ts +5 -1
  86. package/dist/src/dom/locale/index.js +37 -0
  87. package/dist/src/dom/locale/is.d.ts +6 -0
  88. package/dist/src/dom/locale/is.js +6 -0
  89. package/dist/src/dom/locale/it.d.ts +6 -0
  90. package/dist/src/dom/locale/it.js +6 -0
  91. package/dist/src/dom/locale/ja.d.ts +6 -0
  92. package/dist/src/dom/locale/ja.js +6 -0
  93. package/dist/src/dom/locale/ka.d.ts +6 -0
  94. package/dist/src/dom/locale/ka.js +6 -0
  95. package/dist/src/dom/locale/kk.d.ts +6 -0
  96. package/dist/src/dom/locale/kk.js +6 -0
  97. package/dist/src/dom/locale/kn.d.ts +7 -0
  98. package/dist/src/dom/locale/kn.js +7 -0
  99. package/dist/src/dom/locale/ko.d.ts +6 -0
  100. package/dist/src/dom/locale/ko.js +6 -0
  101. package/dist/src/dom/locale/lt.d.ts +7 -0
  102. package/dist/src/dom/locale/lt.js +7 -0
  103. package/dist/src/dom/locale/lv.d.ts +6 -0
  104. package/dist/src/dom/locale/lv.js +6 -0
  105. package/dist/src/dom/locale/mk.d.ts +5 -0
  106. package/dist/src/dom/locale/mk.js +5 -0
  107. package/dist/src/dom/locale/ml.d.ts +7 -0
  108. package/dist/src/dom/locale/ml.js +7 -0
  109. package/dist/src/dom/locale/mn.d.ts +7 -0
  110. package/dist/src/dom/locale/mn.js +7 -0
  111. package/dist/src/dom/locale/mr.d.ts +7 -0
  112. package/dist/src/dom/locale/mr.js +7 -0
  113. package/dist/src/dom/locale/ms.d.ts +6 -0
  114. package/dist/src/dom/locale/ms.js +6 -0
  115. package/dist/src/dom/locale/my.d.ts +6 -0
  116. package/dist/src/dom/locale/my.js +6 -0
  117. package/dist/src/dom/locale/nb.d.ts +6 -0
  118. package/dist/src/dom/locale/nb.js +6 -0
  119. package/dist/src/dom/locale/nl.d.ts +6 -0
  120. package/dist/src/dom/locale/nl.js +6 -0
  121. package/dist/src/dom/locale/pa.d.ts +7 -0
  122. package/dist/src/dom/locale/pa.js +7 -0
  123. package/dist/src/dom/locale/pl.d.ts +6 -0
  124. package/dist/src/dom/locale/pl.js +6 -0
  125. package/dist/src/dom/locale/pt-BR.d.ts +6 -0
  126. package/dist/src/dom/locale/pt-BR.js +6 -0
  127. package/dist/src/dom/locale/pt-PT.d.ts +6 -0
  128. package/dist/src/dom/locale/pt-PT.js +6 -0
  129. package/dist/src/dom/locale/ro.d.ts +5 -0
  130. package/dist/src/dom/locale/ro.js +5 -0
  131. package/dist/src/dom/locale/ru.d.ts +3 -0
  132. package/dist/src/dom/locale/ru.js +3 -0
  133. package/dist/src/dom/locale/sk.d.ts +6 -0
  134. package/dist/src/dom/locale/sk.js +6 -0
  135. package/dist/src/dom/locale/sl.d.ts +6 -0
  136. package/dist/src/dom/locale/sl.js +6 -0
  137. package/dist/src/dom/locale/so.d.ts +6 -0
  138. package/dist/src/dom/locale/so.js +6 -0
  139. package/dist/src/dom/locale/sq.d.ts +6 -0
  140. package/dist/src/dom/locale/sq.js +6 -0
  141. package/dist/src/dom/locale/sr.d.ts +3 -0
  142. package/dist/src/dom/locale/sr.js +3 -0
  143. package/dist/src/dom/locale/sv.d.ts +6 -0
  144. package/dist/src/dom/locale/sv.js +6 -0
  145. package/dist/src/dom/locale/sw.d.ts +6 -0
  146. package/dist/src/dom/locale/sw.js +6 -0
  147. package/dist/src/dom/locale/ta.d.ts +7 -0
  148. package/dist/src/dom/locale/ta.js +7 -0
  149. package/dist/src/dom/locale/te.d.ts +7 -0
  150. package/dist/src/dom/locale/te.js +7 -0
  151. package/dist/src/dom/locale/th.d.ts +6 -0
  152. package/dist/src/dom/locale/th.js +6 -0
  153. package/dist/src/dom/locale/tr.d.ts +6 -0
  154. package/dist/src/dom/locale/tr.js +6 -0
  155. package/dist/src/dom/locale/types.d.ts +8 -1
  156. package/dist/src/dom/locale/uk.d.ts +6 -0
  157. package/dist/src/dom/locale/uk.js +6 -0
  158. package/dist/src/dom/locale/ur.d.ts +6 -0
  159. package/dist/src/dom/locale/ur.js +6 -0
  160. package/dist/src/dom/locale/vi.d.ts +6 -0
  161. package/dist/src/dom/locale/vi.js +6 -0
  162. package/dist/src/dom/locale/zh-HK.d.ts +7 -0
  163. package/dist/src/dom/locale/zh-HK.js +7 -0
  164. package/dist/src/dom/locale/zh-Hans.d.ts +7 -0
  165. package/dist/src/dom/locale/zh-Hans.js +7 -0
  166. package/dist/src/dom/locale/zh-TW.d.ts +7 -0
  167. package/dist/src/dom/locale/zh-TW.js +7 -0
  168. package/dist/src/dom/menus.js +13 -2
  169. package/dist/src/dom/wait-snapshot.d.ts +43 -0
  170. package/dist/src/dom/wait-snapshot.js +127 -0
  171. package/dist/src/safety/risk.d.ts +1 -0
  172. package/dist/src/safety/risk.js +1 -0
  173. package/dist/src/scripts/apply-intelligence-locale-captures.js +99 -13
  174. package/dist/src/types.d.ts +28 -0
  175. package/package.json +2 -2
  176. package/references/backend-protocol.md +6 -4
  177. package/references/localization.md +14 -4
  178. package/references/python-parity.md +5 -3
  179. package/references/troubleshooting.md +28 -5
@@ -1,11 +1,15 @@
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
7
  import { bootstrap } 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
+ ]);
9
13
  const MODE_OPENER_LABELS = [...CURRENT_MODE_LABELS.filter(label => label !== "Pro"), ...localeLabels.modeOpenerExtra];
10
14
  const MODEL_VERSION_FAMILY_PATTERN = /^gpt[\s-]/i;
11
15
  const MODEL_VERSION_LABEL_PATTERN = /^(?:o\d+|\d+(?:\.\d+)?)$/i;
@@ -13,9 +17,33 @@ const CANONICAL_INTELLIGENCE_ORDER = new Map([
13
17
  ["instant", 0],
14
18
  ["medium", 1],
15
19
  ["high", 2],
16
- ["extra high", 3],
20
+ ["extraHigh", 3],
17
21
  ["pro", 4],
18
22
  ]);
23
+ const MODE_OPTION_IDS = [
24
+ "latest",
25
+ "instant",
26
+ "thinking",
27
+ "extended",
28
+ "medium",
29
+ "high",
30
+ "extraHigh",
31
+ "pro",
32
+ ];
33
+ const MODE_ID_ALIASES = {
34
+ latest: ["latest"],
35
+ instant: ["instant"],
36
+ thinking: ["thinking"],
37
+ extended: ["extended"],
38
+ medium: ["medium"],
39
+ high: ["high"],
40
+ extraHigh: ["extra high", "extra-high", "extra_high", "extrahigh"],
41
+ pro: ["pro"],
42
+ };
43
+ const THREAD_ACTION_MENU_LABELS = new Set(localeLabels.threadActionMenuItems.map(normalizeForLabelMatch));
44
+ const THREAD_ACTION_PREFIXES = localeLabels.threadActionPrefixes
45
+ .map(normalizeForLabelMatch)
46
+ .filter(prefix => prefix.length > 0);
19
47
  export async function setMode(env, args) {
20
48
  const boot = await ensurePage(env);
21
49
  if (!boot.ok) {
@@ -23,9 +51,9 @@ export async function setMode(env, args) {
23
51
  }
24
52
  const page = env.page;
25
53
  try {
26
- const requested = requestedModeLabels(args);
54
+ const requested = requestedModeSelections(args);
27
55
  const requestedVersion = requestedModelVersion(args);
28
- const requestedForOpening = requestedVersion === undefined ? requested : [...requested, requestedVersion];
56
+ const requestedForOpening = requestedVersion === undefined ? requested : [...requested, requestedModeSelection(requestedVersion)];
29
57
  const opened = await waitForModeMenu(page, requestedForOpening, args.timeoutMs ?? 30000);
30
58
  if (requestedVersion === undefined && opened.alreadySelected.length === requested.length) {
31
59
  return resultOk({ selected: opened.alreadySelected, candidates: opened.modeButtons }, await contextFromPage(page));
@@ -36,15 +64,25 @@ export async function setMode(env, args) {
36
64
  await page.waitForTimeout?.(250);
37
65
  const candidates = await enumerateVisibleMenuItems(page);
38
66
  const selected = [];
39
- for (const item of requested) {
40
- const match = findModeMenuItem(candidates, item);
67
+ if (requested.length > 0 && shouldRejectAsWrongModeMenu(candidates)) {
68
+ const candidateLabels = candidates.map(candidate => candidate.label);
69
+ return {
70
+ ok: false,
71
+ status: "unsupported",
72
+ warnings: [],
73
+ blocker: selectorDriftBlocker("Visible menu appears to be a thread/action menu, not the ChatGPT mode menu.", candidateLabels),
74
+ context: await contextFromPage(page)
75
+ };
76
+ }
77
+ for (const request of requested) {
78
+ const match = findModeMenuItem(candidates, request);
41
79
  if (match === undefined) {
42
80
  const candidateLabels = candidates.map(candidate => candidate.label);
43
81
  return {
44
82
  ok: false,
45
83
  status: "unsupported",
46
84
  warnings: [],
47
- blocker: selectorDriftBlocker(`Mode option "${item}" was not found or was ambiguous.`, candidateLabels),
85
+ blocker: selectorDriftBlocker(`Mode option "${request.requested}" was not found or was ambiguous.`, candidateLabels),
48
86
  context: await contextFromPage(page)
49
87
  };
50
88
  }
@@ -68,7 +106,53 @@ export async function setMode(env, args) {
68
106
  }
69
107
  selected.push(versionResult.selected);
70
108
  }
71
- return resultOk({ selected, candidates: candidateLabels }, await contextFromPage(page));
109
+ const verificationWarnings = await modeVerificationWarnings(page, requested, selected);
110
+ return resultOk({ selected, candidates: candidateLabels }, await contextFromPage(page), verificationWarnings);
111
+ }
112
+ catch (error) {
113
+ return resultError(error instanceof Error ? error : new Error(String(error)), await contextFromPage(page));
114
+ }
115
+ }
116
+ /**
117
+ * Post-condition check: after clicking mode rows, the composer's mode-labelled controls
118
+ * should reflect the requested selection. A mismatch does not fail the command — some
119
+ * ChatGPT surfaces do not echo every mode — but it must be visible to callers so a
120
+ * modes.set "ok" is never silently treated as a verified mode (the "Pin ... Pro ..."
121
+ * incident shape). Model-version selections are not verified because versions are
122
+ * usually not echoed on the composer.
123
+ */
124
+ async function modeVerificationWarnings(page, requested, selected) {
125
+ if (requested.length === 0) {
126
+ return [];
127
+ }
128
+ await page.waitForTimeout?.(250);
129
+ const visibleButtons = await visibleModeButtonLabelList(page);
130
+ if (visibleButtons.length === 0) {
131
+ return [
132
+ `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.`
133
+ ];
134
+ }
135
+ const unverified = requested.filter(request => findUniqueVisibleLabelForRequest(visibleButtons, request) === undefined);
136
+ if (unverified.length === 0) {
137
+ return [];
138
+ }
139
+ return [
140
+ `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.`
141
+ ];
142
+ }
143
+ export async function getMode(env, args = {}) {
144
+ void args;
145
+ const boot = await ensurePage(env);
146
+ if (!boot.ok) {
147
+ return boot;
148
+ }
149
+ const page = env.page;
150
+ try {
151
+ const modes = await visibleModeButtonLabelList(page);
152
+ const warnings = modes.length === 0
153
+ ? ["No mode-labelled composer control is currently visible, so the active ChatGPT mode could not be read."]
154
+ : [];
155
+ return resultOk({ modes }, await contextFromPage(page), warnings);
72
156
  }
73
157
  catch (error) {
74
158
  return resultError(error instanceof Error ? error : new Error(String(error)), await contextFromPage(page));
@@ -84,7 +168,7 @@ async function waitForModeMenu(page, requested, timeoutMs) {
84
168
  return { opened: false, alreadySelected, modeButtons };
85
169
  }
86
170
  const openMenuItems = await enumerateVisibleMenuItems(page);
87
- if (looksLikeModeMenu(openMenuItems.map(item => item.label))) {
171
+ if (looksLikeModeMenu(openMenuItems)) {
88
172
  return { opened: true, alreadySelected: [], modeButtons };
89
173
  }
90
174
  if (await clickModeOpener(page, modeButtons)) {
@@ -165,12 +249,57 @@ async function clickModeOpener(page, modeButtons) {
165
249
  }
166
250
  return clickFirstUniqueButton(page, MODE_OPENER_LABELS);
167
251
  }
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)));
252
+ function isThreadActionLabel(label) {
253
+ const normalized = normalizeForLabelMatch(label);
254
+ if (THREAD_ACTION_MENU_LABELS.has(normalized)) {
255
+ return true;
256
+ }
257
+ return THREAD_ACTION_PREFIXES.some(prefix => normalized.startsWith(`${prefix} `));
258
+ }
259
+ function hasStructuralModeEvidence(item) {
260
+ if (item.testId?.startsWith("model-switcher-") === true) {
261
+ return true;
262
+ }
263
+ return MODEL_VERSION_FAMILY_PATTERN.test(item.label) || MODEL_VERSION_LABEL_PATTERN.test(item.label);
264
+ }
265
+ /**
266
+ * Whether a single menu item is evidence that the visible menu is the ChatGPT mode menu.
267
+ *
268
+ * A fuzzy token hit on a short mode word such as "Pro" is NOT evidence: thread titles
269
+ * inside sidebar action menus can contain it ("Pin CopyBench Pro Consultation"), which is
270
+ * exactly the false positive that let modes.set select a pin action as the Pro mode.
271
+ * Thread-action rows can never be evidence, even when their title embeds a full mode word.
272
+ * Exact matching uses normalizeForLabelMatch (NFKC) so the evidence set and the veto set
273
+ * share identical Unicode folding — fullwidth/compatibility-form labels match both sides.
274
+ */
275
+ function isModeMenuEvidence(item) {
276
+ if (hasStructuralModeEvidence(item)) {
277
+ return true;
278
+ }
279
+ if (isThreadActionLabel(item.label)) {
280
+ return false;
281
+ }
282
+ const normalized = normalizeForLabelMatch(item.label);
283
+ return CURRENT_MODE_LABELS.some(modeLabel => {
284
+ const normalizedMode = normalizeForLabelMatch(modeLabel);
285
+ if (normalized === normalizedMode) {
286
+ return true;
287
+ }
288
+ return !isShortLatinToken(normalizedMode) && visibleLabelMatches(normalized, normalizedMode);
172
289
  });
173
290
  }
291
+ function looksLikeModeMenu(items) {
292
+ return items.some(item => isModeMenuEvidence(item));
293
+ }
294
+ function shouldRejectAsWrongModeMenu(items) {
295
+ if (items.length === 0) {
296
+ return false;
297
+ }
298
+ if (items.some(item => isModeMenuEvidence(item))) {
299
+ return false;
300
+ }
301
+ return items.some(item => isThreadActionLabel(item.label));
302
+ }
174
303
  async function clickMenuItem(page, label) {
175
304
  if (await clickModelSwitcherMenuItem(page, label)) {
176
305
  return true;
@@ -262,28 +391,81 @@ function toolLabels(tool) {
262
391
  const known = localeLabels.tools[tool];
263
392
  return known !== undefined ? [...known] : [tool];
264
393
  }
265
- function findModeMenuItem(candidates, wanted) {
266
- const exact = findUniqueMenuItem(candidates, wanted);
267
- if (exact !== undefined) {
268
- return exact;
394
+ function findModeMenuItem(candidates, request) {
395
+ const selectable = candidates.filter(candidate => !isThreadActionLabel(candidate.label));
396
+ for (const wanted of request.labels) {
397
+ const match = findUniqueModeMenuItem(selectable, wanted);
398
+ if (match !== undefined) {
399
+ return match;
400
+ }
269
401
  }
270
- const wantedIndex = CANONICAL_INTELLIGENCE_ORDER.get(normalizeLabel(wanted));
402
+ const wantedIndex = request.modeId === undefined ? undefined : CANONICAL_INTELLIGENCE_ORDER.get(request.modeId);
271
403
  if (wantedIndex === undefined) {
272
404
  return undefined;
273
405
  }
274
- const intelligenceItems = candidates.filter(candidate => candidate.role === "menuitemradio"
406
+ const intelligenceItems = selectable.filter(candidate => candidate.role === "menuitemradio"
275
407
  && !MODEL_VERSION_LABEL_PATTERN.test(candidate.label)
276
408
  && !MODEL_VERSION_FAMILY_PATTERN.test(candidate.label));
277
409
  return intelligenceItems.length >= CANONICAL_INTELLIGENCE_ORDER.size
278
410
  ? intelligenceItems[wantedIndex]
279
411
  : undefined;
280
412
  }
281
- function requestedModeLabels(args) {
413
+ /**
414
+ * Unique-match a wanted mode label. Exact normalized equality is always accepted; a
415
+ * fuzzy token hit on a short mode word such as "Pro" additionally requires structural
416
+ * mode-row evidence (a model-switcher test id or a menuitemradio role), so an arbitrary
417
+ * unique menu row whose text merely contains "Pro" cannot be selected as the mode.
418
+ * Exact matching uses normalizeForLabelMatch (NFKC), consistent with isModeMenuEvidence.
419
+ */
420
+ function findUniqueModeMenuItem(items, wanted) {
421
+ const normalizedWanted = normalizeForLabelMatch(wanted);
422
+ const exact = items.filter(item => normalizeForLabelMatch(item.label) === normalizedWanted);
423
+ if (exact.length === 1) {
424
+ return exact[0];
425
+ }
426
+ const fuzzy = items.filter(item => visibleLabelMatches(item.label, wanted));
427
+ if (fuzzy.length !== 1) {
428
+ return undefined;
429
+ }
430
+ const match = fuzzy[0];
431
+ if (!isShortLatinToken(normalizedWanted)) {
432
+ return match;
433
+ }
434
+ return hasStructuralModeEvidence(match) || match.role === "menuitemradio" ? match : undefined;
435
+ }
436
+ function requestedModeSelections(args) {
282
437
  const requested = [args.model, args.intelligence, args.effort].filter((value) => value !== undefined);
283
438
  if (requestedModelVersion(args) !== undefined && requested.length === 0) {
284
439
  return [];
285
440
  }
286
- return requested.length > 0 ? requested : [DEFAULT_MODE_EFFORT];
441
+ return (requested.length > 0 ? requested : [DEFAULT_MODE_EFFORT]).map(requestedModeSelection);
442
+ }
443
+ function requestedModeSelection(requested) {
444
+ const modeId = modeOptionIdFor(requested);
445
+ const labels = modeId === undefined ? [requested] : localeLabels.modeOptions[modeId];
446
+ const request = {
447
+ requested,
448
+ labels: labels.length > 0 ? [...labels] : [requested],
449
+ };
450
+ if (modeId !== undefined) {
451
+ request.modeId = modeId;
452
+ }
453
+ return request;
454
+ }
455
+ function modeOptionIdFor(value) {
456
+ const normalized = normalizeModeLookupKey(value);
457
+ for (const id of MODE_OPTION_IDS) {
458
+ if (MODE_ID_ALIASES[id].some(alias => normalizeModeLookupKey(alias) === normalized)) {
459
+ return id;
460
+ }
461
+ if (localeLabels.modeOptions[id].some(label => normalizeModeLookupKey(label) === normalized)) {
462
+ return id;
463
+ }
464
+ }
465
+ return undefined;
466
+ }
467
+ function normalizeModeLookupKey(value) {
468
+ return normalizeForLabelMatch(value).replace(/[_-]+/g, " ");
287
469
  }
288
470
  function requestedModelVersion(args) {
289
471
  return args.modelVersion ?? args.version;
@@ -297,12 +479,6 @@ function findUniqueVisibleLabel(labels, wanted) {
297
479
  const fuzzy = labels.filter(label => visibleLabelMatches(normalizeLabel(label), normalized));
298
480
  return fuzzy.length === 1 ? fuzzy[0] : undefined;
299
481
  }
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
482
  function escapeRegExp(value) {
307
483
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
308
484
  }
@@ -311,13 +487,22 @@ function escapeAttributeValue(value) {
311
487
  }
312
488
  function findAlreadySelectedModes(visibleButtons, requested) {
313
489
  return requested
314
- .map(label => findUniqueVisibleLabel(visibleButtons, label))
490
+ .map(request => findUniqueVisibleLabelForRequest(visibleButtons, request))
315
491
  .filter((label) => label !== undefined);
316
492
  }
493
+ function findUniqueVisibleLabelForRequest(labels, request) {
494
+ for (const label of request.labels) {
495
+ const found = findUniqueVisibleLabel(labels, label);
496
+ if (found !== undefined) {
497
+ return found;
498
+ }
499
+ }
500
+ return undefined;
501
+ }
317
502
  async function selectModelVersion(page, requestedVersion, currentCandidates, timeoutMs) {
318
503
  let candidates = await enumerateVisibleMenuItems(page);
319
- if (!looksLikeModeMenu(candidates.map(candidate => candidate.label))) {
320
- const opened = await waitForModeMenu(page, [requestedVersion], timeoutMs);
504
+ if (!looksLikeModeMenu(candidates)) {
505
+ const opened = await waitForModeMenu(page, [{ requested: requestedVersion, labels: [requestedVersion] }], timeoutMs);
321
506
  if (opened.opened) {
322
507
  await page.waitForTimeout?.(250);
323
508
  candidates = await enumerateVisibleMenuItems(page);
@@ -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
+ }
@@ -83,6 +83,7 @@ const descriptors = [
83
83
  primitive("projects.sources.add", "Append explicit local files to a visible ChatGPT Project Sources list after confirmMutation: true.", 180000),
84
84
  primitive("response.copy", "Click Copy response and return clipboard Markdown, with DOM fallback.", 5000),
85
85
  primitive("modes.set", "Select a visible model, intelligence, effort, or nested model-version candidate when unambiguous.", 30000),
86
+ primitive("modes.get", "Read the mode labels shown on the visible composer controls without changing them.", 30000),
86
87
  primitive("tools.select", "Select a visible ChatGPT tool when unambiguous.", 30000)
87
88
  ];
88
89
  export function commandDescriptors() {
@@ -232,6 +233,14 @@ function reportArgs(name) {
232
233
  return { result: "CommandResult to persist", destDir: "optional report directory" };
233
234
  }
234
235
  function primitiveArgs(name) {
236
+ if (name === "messages.wait")
237
+ return {
238
+ timeoutMs: "optional wait timeout",
239
+ stableMs: "optional stability window before completion",
240
+ pollMs: "optional polling interval",
241
+ mode: "normal or deep_research",
242
+ responseContent: "include for current behavior, metadata to omit assistant text and return chars/hash only"
243
+ };
235
244
  if (name === "messages.readLatest")
236
245
  return { role: "assistant or user", format: "markdown, normalized_text, visible_text, html, blocks, or all" };
237
246
  if (name === "artifacts.listLatest")
@@ -245,7 +254,18 @@ function primitiveArgs(name) {
245
254
  if (name.startsWith("threads.search"))
246
255
  return { query: "history search query" };
247
256
  if (name === "files.preflight")
248
- return { paths: "absolute local file paths", maxBytesPerFile: "optional local per-file byte limit", maxTotalBytes: "optional local total byte limit" };
257
+ return {
258
+ paths: "absolute local file paths",
259
+ maxBytesPerFile: "optional local per-file byte limit",
260
+ maxTotalBytes: "optional local total byte limit",
261
+ includeHashes: "optional SHA-256 metadata for diagnostics; file contents are not returned"
262
+ };
263
+ if (name === "files.attach")
264
+ return {
265
+ paths: "absolute local file paths",
266
+ includeDiagnostics: "optional preflight and browser input file-size metadata",
267
+ includeHashes: "optional SHA-256 metadata inside diagnostics; file contents are not returned"
268
+ };
249
269
  if (name.startsWith("files.attach"))
250
270
  return { paths: "absolute local file paths" };
251
271
  if (name === "projects.sources.list")
@@ -264,6 +284,11 @@ function primitiveArgs(name) {
264
284
  timeoutMs: "optional timeout for opening and selecting the visible mode menu"
265
285
  };
266
286
  }
287
+ if (name === "modes.get") {
288
+ return {
289
+ timeoutMs: "optional timeout for attaching to the ChatGPT page"
290
+ };
291
+ }
267
292
  return {};
268
293
  }
269
294
  function primitiveExamples(name) {
@@ -275,9 +300,16 @@ function primitiveExamples(name) {
275
300
  `await chatgpt.askWithFiles({ mode: { model: "Pro" }, files: ["/absolute/host/path.jpg"], prompt: "Describe this image.", wait: true });`
276
301
  ];
277
302
  }
303
+ if (name === "modes.get") {
304
+ return [
305
+ `await chatgpt.modes.get();`,
306
+ `// Verify an expensive Pro consult before submitting: const current = await chatgpt.modes.get();`
307
+ ];
308
+ }
278
309
  if (name === "files.preflight") {
279
310
  return [
280
- `await chatgpt.files.preflight({ paths: ["/absolute/host/path.md"] });`
311
+ `await chatgpt.files.preflight({ paths: ["/absolute/host/path.md"] });`,
312
+ `await chatgpt.files.preflight({ paths: ["/absolute/host/path.md"], includeHashes: true });`
281
313
  ];
282
314
  }
283
315
  if (name === "files.attach") {
@@ -23,7 +23,7 @@ export async function createRunReport(env, result, options = {}) {
23
23
  }, options);
24
24
  const report = {
25
25
  schemaVersion: 1,
26
- createdAt: new Date().toISOString(),
26
+ createdAt,
27
27
  includeContent,
28
28
  summary,
29
29
  steps: result.steps?.map(step => ({
@@ -1,10 +1,10 @@
1
1
  import { parseConversationId, readPageState } from "../browser/page-state.js";
2
2
  import { resultError, resultOk } from "../errors.js";
3
- import { countPageMessages, readLatestMessageText } from "../dom/messages.js";
4
3
  import { requiredLocator, searchChatsButton, searchChatsInput, newChatButton } from "../dom/selectors.js";
5
4
  import { anyLabelPattern, localeLabels } from "../dom/locale-labels.js";
6
5
  import { normalizeWhitespace, stripTags } from "../dom/visible-text.js";
7
6
  import { contextFromPage } from "./context.js";
7
+ import { ensureConversationTarget } from "./conversation.js";
8
8
  import { bootstrap } from "./session.js";
9
9
  const CHATGPT_HOME = "https://chatgpt.com/";
10
10
  export function extractThreadSearchResultsFromHtml(html) {
@@ -100,13 +100,7 @@ export async function openThread(env, args, previousResults) {
100
100
  context: await contextFromPage(page)
101
101
  };
102
102
  }
103
- if (target.href !== undefined && target.href.startsWith("/")) {
104
- await page.goto?.(new URL(target.href, CHATGPT_HOME).toString(), { waitUntil: "domcontentloaded", timeout: args.timeoutMs ?? 30000 });
105
- }
106
- else {
107
- await page.goto?.(target.href ?? target.url, { waitUntil: "domcontentloaded", timeout: args.timeoutMs ?? 30000 });
108
- }
109
- await waitForThreadHydrated(page, args.timeoutMs ?? 30000, parseConversationId(target.url));
103
+ await ensureConversationTarget(page, target, { timeoutMs: args.timeoutMs ?? 30000 });
110
104
  const state = await readPageState(page);
111
105
  return resultOk(openThreadData(state.url, state.conversationId, state.title ?? target.title), await contextFromPage(page));
112
106
  }
@@ -283,19 +277,3 @@ function filterResultsByQuery(results, query) {
283
277
  return haystack.includes(wanted) || wanted.includes(normalizeForMatch(result.title));
284
278
  });
285
279
  }
286
- async function waitForThreadHydrated(page, timeoutMs, expectedConversationId) {
287
- const started = Date.now();
288
- await page.waitForTimeout?.(1000);
289
- while (Date.now() - started < timeoutMs) {
290
- const url = typeof page.url === "function" ? await Promise.resolve(page.url()).catch(() => "") : "";
291
- const urlMatches = expectedConversationId === undefined || url.includes(expectedConversationId);
292
- const count = await countPageMessages(page).catch(() => 0);
293
- const latestAssistantText = await readLatestMessageText(page, "assistant").catch(() => undefined);
294
- const title = typeof page.title === "function" ? await page.title().catch(() => "") : "";
295
- if (urlMatches && ((latestAssistantText?.trim().length ?? 0) > 0 || (count > 0 && title.length > 0 && title !== "ChatGPT"))) {
296
- await page.waitForTimeout?.(250);
297
- return;
298
- }
299
- await page.waitForTimeout?.(500);
300
- }
301
- }
@@ -4,5 +4,12 @@ export type AssistantGenerationState = {
4
4
  stopped: boolean;
5
5
  signals: string[];
6
6
  };
7
+ /**
8
+ * Neutral fallback used when generation state cannot be inspected.
9
+ *
10
+ * This means "no active/stopped signal observed"; it is not evidence that a
11
+ * response is complete.
12
+ */
13
+ export declare const EMPTY_GENERATION_STATE: AssistantGenerationState;
7
14
  export declare function readAssistantGenerationState(page: PageLike): Promise<AssistantGenerationState>;
8
15
  export declare function latestAssistantTurnHasResponseActions(page: PageLike): Promise<boolean>;
@@ -1,6 +1,12 @@
1
1
  import { copyResponseButtons } from "./selectors.js";
2
2
  import { localeLabels } from "./locale-labels.js";
3
- const EMPTY_GENERATION_STATE = {
3
+ /**
4
+ * Neutral fallback used when generation state cannot be inspected.
5
+ *
6
+ * This means "no active/stopped signal observed"; it is not evidence that a
7
+ * response is complete.
8
+ */
9
+ export const EMPTY_GENERATION_STATE = {
4
10
  active: false,
5
11
  stopped: false,
6
12
  signals: []
@@ -0,0 +1,3 @@
1
+ export declare function normalizeForLabelMatch(text: string): string;
2
+ export declare function visibleLabelMatches(label: string, wanted: string): boolean;
3
+ export declare function isShortLatinToken(value: string): boolean;
@@ -0,0 +1,25 @@
1
+ import { normalizeWhitespace } from "./visible-text.js";
2
+ export function normalizeForLabelMatch(text) {
3
+ return normalizeWhitespace(text.normalize("NFKC")).toLocaleLowerCase();
4
+ }
5
+ export function visibleLabelMatches(label, wanted) {
6
+ const normalizedLabel = normalizeForLabelMatch(label);
7
+ const normalizedWanted = normalizeForLabelMatch(wanted);
8
+ if (normalizedWanted.length === 0) {
9
+ return false;
10
+ }
11
+ if (normalizedLabel === normalizedWanted) {
12
+ return true;
13
+ }
14
+ if (isShortLatinToken(normalizedWanted)) {
15
+ return new RegExp(`(^|[^\\p{L}\\p{N}])${escapeRegExp(normalizedWanted)}([^\\p{L}\\p{N}]|$)`, "iu")
16
+ .test(normalizedLabel);
17
+ }
18
+ return normalizedLabel.includes(normalizedWanted);
19
+ }
20
+ export function isShortLatinToken(value) {
21
+ return value.length <= 3 && /^[a-z0-9]+$/i.test(value);
22
+ }
23
+ function escapeRegExp(value) {
24
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
25
+ }
@@ -17,6 +17,12 @@ export declare const am: {
17
17
  addPhotosFilesMenuItem: string[];
18
18
  copyResponse: string[];
19
19
  modeLabels: string[];
20
+ modeOptions: {
21
+ instant: string[];
22
+ medium: string[];
23
+ high: string[];
24
+ extraHigh: string[];
25
+ };
20
26
  modeOpenerExtra: string[];
21
27
  tools: {
22
28
  web_search: string[];
@@ -17,6 +17,12 @@ export const am = {
17
17
  addPhotosFilesMenuItem: ["ፎቶዎችን እና ፋይሎችን ያክሉ"],
18
18
  copyResponse: ["ምላሹን ይቅዱ"],
19
19
  modeLabels: ["ፈጣን", "መካከለኛ", "ከፍተኛ", "እጅግ ከፍተኛ"],
20
+ modeOptions: {
21
+ instant: ["ፈጣን"],
22
+ medium: ["መካከለኛ"],
23
+ high: ["ከፍተኛ"],
24
+ extraHigh: ["እጅግ ከፍተኛ"],
25
+ },
20
26
  modeOpenerExtra: ["ያዋቅሩ"],
21
27
  tools: {
22
28
  web_search: ["የድር ፍለጋ"],
@@ -17,6 +17,13 @@ export declare const ar: {
17
17
  addPhotosFilesMenuItem: string[];
18
18
  copyResponse: string[];
19
19
  modeLabels: string[];
20
+ modeOptions: {
21
+ instant: string[];
22
+ medium: string[];
23
+ high: string[];
24
+ extraHigh: string[];
25
+ pro: string[];
26
+ };
20
27
  modeOpenerExtra: string[];
21
28
  tools: {
22
29
  web_search: string[];