codex-chatgpt-control 0.5.1-alpha.3 → 0.5.1-alpha.4

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 (90) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +16 -0
  3. package/contracts/v1/fixtures/download-blocked-by-browser.json +22 -0
  4. package/contracts/v1/fixtures/download-receipt-failed.json +22 -0
  5. package/contracts/v1/fixtures/download-receipt-timeout.json +22 -0
  6. package/contracts/v1/fixtures/journal-rpc-indeterminate.json +32 -0
  7. package/contracts/v1/fixtures/journal-rpc-unsupported-platform.json +32 -0
  8. package/contracts/v1/fixtures/journal-runtime-unavailable.json +32 -0
  9. package/contracts/v1/manifest.json +30 -0
  10. package/contracts/v1/parity-suite.json +10 -7
  11. package/dist/codex-chatgpt-control-backend.mjs +3795 -2077
  12. package/dist/codex-chatgpt-control-journal.mjs +5547 -0
  13. package/dist/codex-chatgpt-control-live-smoke.bundle.mjs +51677 -0
  14. package/dist/codex-chatgpt-control-release-canary.bundle.mjs +52104 -0
  15. package/dist/codex-chatgpt-control.bundle.mjs +3403 -1369
  16. package/dist/src/browser/chatgpt-url.d.ts +2 -0
  17. package/dist/src/browser/chatgpt-url.js +9 -0
  18. package/dist/src/browser/downloads.d.ts +15 -1
  19. package/dist/src/browser/downloads.js +150 -28
  20. package/dist/src/browser/page-state.js +6 -0
  21. package/dist/src/client.d.ts +5 -0
  22. package/dist/src/client.js +69 -12
  23. package/dist/src/commands/artifacts.js +6 -4
  24. package/dist/src/commands/chat-popover.d.ts +37 -0
  25. package/dist/src/commands/chat-popover.js +537 -0
  26. package/dist/src/commands/configuration.d.ts +5 -0
  27. package/dist/src/commands/configuration.js +121 -14
  28. package/dist/src/commands/context.js +10 -3
  29. package/dist/src/commands/doctor.js +1 -1
  30. package/dist/src/commands/experience.d.ts +5 -0
  31. package/dist/src/commands/experience.js +69 -21
  32. package/dist/src/commands/files.js +10 -6
  33. package/dist/src/commands/modes.js +36 -4
  34. package/dist/src/commands/power-discovery.js +52 -10
  35. package/dist/src/commands/session.js +12 -4
  36. package/dist/src/dom/composer-text.d.ts +6 -0
  37. package/dist/src/dom/composer-text.js +85 -0
  38. package/dist/src/operations/artifact-transfer.d.ts +1 -1
  39. package/dist/src/operations/artifact-transfer.js +3 -3
  40. package/dist/src/operations/browser-adapter.d.ts +10 -4
  41. package/dist/src/operations/browser-adapter.js +95 -15
  42. package/dist/src/operations/browser-observation.d.ts +1 -1
  43. package/dist/src/operations/browser-observation.js +97 -49
  44. package/dist/src/operations/browser-target.d.ts +3 -1
  45. package/dist/src/operations/browser-target.js +49 -7
  46. package/dist/src/operations/chatgpt-runtime.js +85 -3
  47. package/dist/src/operations/client.js +2 -0
  48. package/dist/src/operations/configuration-routing.d.ts +4 -0
  49. package/dist/src/operations/configuration-routing.js +14 -0
  50. package/dist/src/operations/index.d.ts +3 -0
  51. package/dist/src/operations/index.js +3 -0
  52. package/dist/src/operations/journal-authority.d.ts +12 -0
  53. package/dist/src/operations/journal-authority.js +1 -0
  54. package/dist/src/operations/journal-rpc-client.d.ts +12 -0
  55. package/dist/src/operations/journal-rpc-client.js +225 -0
  56. package/dist/src/operations/journal-rpc-protocol.d.ts +32 -0
  57. package/dist/src/operations/journal-rpc-protocol.js +214 -0
  58. package/dist/src/operations/journal-rpc-server.d.ts +15 -0
  59. package/dist/src/operations/journal-rpc-server.js +272 -0
  60. package/dist/src/operations/journal.d.ts +1 -0
  61. package/dist/src/operations/journal.js +28 -2
  62. package/dist/src/operations/production-attachments.d.ts +3 -1
  63. package/dist/src/operations/production-attachments.js +25 -8
  64. package/dist/src/operations/production-chatgpt-artifacts.js +3 -3
  65. package/dist/src/operations/production-chatgpt-attachments.d.ts +10 -1
  66. package/dist/src/operations/production-chatgpt-attachments.js +133 -57
  67. package/dist/src/operations/production-configuration.d.ts +1 -1
  68. package/dist/src/operations/production-configuration.js +205 -24
  69. package/dist/src/operations/production-primitives.js +35 -252
  70. package/dist/src/operations/production-work-steer.js +35 -35
  71. package/dist/src/operations/runtime-adapter.d.ts +8 -0
  72. package/dist/src/operations/runtime-adapter.js +20 -4
  73. package/dist/src/operations/service.d.ts +7 -2
  74. package/dist/src/operations/service.js +80 -59
  75. package/dist/src/operations/transactional-chat-power.d.ts +17 -0
  76. package/dist/src/operations/transactional-chat-power.js +212 -0
  77. package/dist/src/runtime/coordinated-page.d.ts +2 -0
  78. package/dist/src/runtime/coordinated-page.js +23 -6
  79. package/dist/src/safety/blockers.js +8 -1
  80. package/dist/src/scripts/capture-surface-profile.js +7 -2
  81. package/dist/src/scripts/journal-server.d.ts +2 -0
  82. package/dist/src/scripts/journal-server.js +37 -0
  83. package/dist/src/scripts/live-smoke/scenarios.d.ts +16 -3
  84. package/dist/src/scripts/live-smoke/scenarios.js +47 -22
  85. package/dist/src/scripts/live-smoke/transactional.d.ts +17 -0
  86. package/dist/src/scripts/live-smoke/transactional.js +254 -0
  87. package/package.json +8 -3
  88. package/references/2026-09-06-journal-service.md +140 -0
  89. package/references/backend-protocol.md +103 -0
  90. package/references/python-parity.md +65 -0
@@ -6,6 +6,7 @@ import { tabIdFromPage } from "../browser/attach.js";
6
6
  import { contextFromPage } from "./context.js";
7
7
  import { detectExperience, openExperience } from "./experience.js";
8
8
  import { setMode } from "./modes.js";
9
+ import { closeChatPopover, inspectChatPopover, readChatPopover, selectChatPopoverEffort, selectChatPopoverModel, selectChatPopoverSpeed } from "./chat-popover.js";
9
10
  import { ensurePage } from "./session.js";
10
11
  const WORK_AXES = ["model", "effort", "speed"];
11
12
  const CONFIGURATION_CONTROL_DISCOVERY_TIMEOUT_MS = 5_000;
@@ -13,11 +14,11 @@ const CONFIGURATION_CONTROL_POLL_MS = 250;
13
14
  const CONFIGURATION_SELECTION_MAX_ATTEMPTS = 6;
14
15
  const CONFIGURATION_SELECTION_RETRY_MS = 400;
15
16
  const CONFIGURATION_AXIS_ORDER = [
17
+ "modelVersion",
16
18
  "model",
17
19
  "intelligence",
18
20
  "effort",
19
21
  "speed",
20
- "modelVersion",
21
22
  ];
22
23
  export async function inspectConfiguration(env, args = {}) {
23
24
  const boot = await ensurePage(env);
@@ -49,11 +50,25 @@ export async function inspectConfiguration(env, args = {}) {
49
50
  };
50
51
  }
51
52
  const experience = detected.data.experience;
53
+ const initialChatPopover = experience !== "unknown" ? (await readChatPopover(page)).snapshot : undefined;
52
54
  const initialPanel = await readConfigurationPanel(page);
53
55
  const rootOpened = experience !== "unknown" && await waitForConfigurationRoot(page, experience, args.timeoutMs);
54
56
  if (rootOpened) {
55
57
  await page.waitForTimeout?.(150);
56
58
  }
59
+ if (experience !== "unknown" && (await readChatPopover(page)).snapshot !== undefined) {
60
+ try {
61
+ const chat = await inspectChatPopover(page);
62
+ const data = configurationInspectionFromPopover(experience, detected.data.evidence, chat);
63
+ if (args.includeOptions === false)
64
+ data.options = {};
65
+ return resultOk(data, await contextFromPage(page, { experience, selectorProfile: data.selectorProfile }), data.verified ? [] : ["The owned configuration views did not provide unique active model and effort evidence."]);
66
+ }
67
+ finally {
68
+ if (initialChatPopover === undefined)
69
+ await closeConfigurationMenus(page);
70
+ }
71
+ }
57
72
  const workAdvancedOpened = experience !== "work"
58
73
  || (rootOpened && await ensureWorkAdvancedPanel(page));
59
74
  if (experience === "work" && workAdvancedOpened) {
@@ -95,6 +110,32 @@ export async function inspectConfiguration(env, args = {}) {
95
110
  return resultError(error instanceof Error ? error : new Error(String(error)), await contextFromPage(page));
96
111
  }
97
112
  }
113
+ /** Shared owned popover, with surface-specific model semantics and observed axes only. */
114
+ export function configurationInspectionFromPopover(experience, evidence, popover) {
115
+ const data = { experience, selectorProfile: experience === "work" ? "work_basic_v1" : "chat_simplified_v1",
116
+ availableAxes: [], active: {}, options: {}, verified: false, evidence };
117
+ if (popover?.slider !== undefined) {
118
+ data.availableAxes.push("effort");
119
+ if (popover.effort !== undefined) {
120
+ data.active.effort = popover.effort;
121
+ data.options.effort = [{ id: normalizeConfigurationId(popover.effort), label: popover.effort, selected: true }];
122
+ }
123
+ }
124
+ if (popover !== undefined && popover.modelOptions.length > 0) {
125
+ const axis = experience === "work" ? "model" : "modelVersion";
126
+ data.availableAxes.push(axis);
127
+ if (popover.activeModel !== undefined)
128
+ data.active[axis] = popover.activeModel;
129
+ data.options[axis] = popover.modelOptions.map(option => ({ id: normalizeConfigurationId(option.label), label: option.label, selected: option.checked }));
130
+ }
131
+ if (experience === "work" && popover?.speed !== undefined) {
132
+ data.availableAxes.push("speed");
133
+ data.active.speed = popover.speed;
134
+ data.options.speed = ["Standard", "Fast"].map(label => ({ id: normalizeConfigurationId(label), label, selected: label === popover.speed }));
135
+ }
136
+ data.verified = popover?.effort !== undefined && popover.activeModel !== undefined;
137
+ return data;
138
+ }
98
139
  async function waitForConfigurationRoot(page, experience, timeoutMs) {
99
140
  const discoveryMs = Math.min(timeoutMs ?? CONFIGURATION_CONTROL_DISCOVERY_TIMEOUT_MS, CONFIGURATION_CONTROL_DISCOVERY_TIMEOUT_MS);
100
141
  const attempts = Math.max(1, Math.ceil(Math.max(0, discoveryMs) / CONFIGURATION_CONTROL_POLL_MS));
@@ -154,7 +195,15 @@ export async function applyConfiguration(env, args) {
154
195
  }
155
196
  const selected = [];
156
197
  for (const [axis, requested] of selectionEntries(desired)) {
157
- const active = activeConfigurationValue(before, axis);
198
+ // A model change can reset effort and alter its available range.
199
+ // Re-observe between axes instead of trusting the initial snapshot.
200
+ const currentResult = selected.length === 0 ? beforeResult : await inspectConfiguration(env, { includeOptions: false });
201
+ if (!currentResult.ok || currentResult.data === undefined)
202
+ return forwardFailure(currentResult);
203
+ const current = currentResult.data;
204
+ if (current.experience !== before.experience)
205
+ return configurationFailure(page, before, desired, selected, "The composer experience changed during configuration selection.", "experience_mismatch");
206
+ const active = activeConfigurationValue(current, axis);
158
207
  if (active !== undefined && configurationValueMatches(active, requested)) {
159
208
  selected.push({ axis, requested, selected: active });
160
209
  continue;
@@ -228,22 +277,37 @@ export function configurationInspectionFromSurface(experience, detectedProfile,
228
277
  const simplified = chatMenuLooksSimplified(menuItems);
229
278
  selectorProfile = simplified ? "chat_simplified_v1" : detectedProfile;
230
279
  const axis = simplified ? "intelligence" : "effort";
231
- if (menuItems.length > 0 || panel.openerLabel !== undefined) {
232
- availableAxes.push(axis);
233
- }
234
- if (panel.openerLabel !== undefined) {
235
- active[axis] = panel.openerLabel;
236
- }
280
+ const modelRows = menuItems.filter(item => item.role === "menuitemradio"
281
+ && item.hasPopup !== true
282
+ && (/^(?:gpt[\s-]|o\d+(?:\b|$)|\d+(?:\.\d+)?$)/i.test(item.label)
283
+ || localeLabels.modeOptions.latest.some(label => visibleLabelMatches(item.label, label))));
237
284
  const chatOptions = menuItems
238
- .filter(item => !isConfigurationAxisRow(item.label))
285
+ .filter(item => !isConfigurationAxisRow(item.label)
286
+ && !modelRows.includes(item)
287
+ && item.hasPopup !== true
288
+ && !/^gpt[\s-]/i.test(item.label)
289
+ && item.ariaLabel !== "Select model")
239
290
  .map(menuItemToOption);
240
- if (chatOptions.length > 0) {
291
+ const selectedEffort = chatOptions.filter(option => option.selected === true);
292
+ const openerValue = panel.openerValue ?? panel.openerLabel;
293
+ if (chatOptions.length > 0 || openerValue !== undefined)
294
+ availableAxes.push(axis);
295
+ if (selectedEffort.length === 1)
296
+ active[axis] = selectedEffort[0].label;
297
+ else if (openerValue !== undefined
298
+ && !isConfigurationAxisRow(openerValue)
299
+ && !/^(?:thinking effort|select model|power)$/i.test(openerValue))
300
+ active[axis] = openerValue;
301
+ if (chatOptions.length > 0)
241
302
  options[axis] = chatOptions;
242
- }
243
- const modelRows = menuItems.filter(item => /^gpt[\s-]/i.test(item.label) || item.hasPopup === true);
244
- if (modelRows.length > 0) {
303
+ const modelOpeners = menuItems.filter(item => item.role !== "menuitemradio"
304
+ && /^gpt[\s-]/i.test(item.label));
305
+ if (modelRows.length > 0 || modelOpeners.length > 0) {
245
306
  availableAxes.push("modelVersion");
246
- options.modelVersion = modelRows.map(menuItemToOption);
307
+ options.modelVersion = [...modelRows, ...modelOpeners].map(menuItemToOption);
308
+ const checked = modelRows.filter(item => item.checked === true);
309
+ if (checked.length === 1)
310
+ active.modelVersion = checked[0].label;
247
311
  }
248
312
  }
249
313
  return {
@@ -268,6 +332,24 @@ async function selectWorkAxis(env, axis, requested, timeoutMs) {
268
332
  if (!WORK_AXES.includes(axis)) {
269
333
  return undefined;
270
334
  }
335
+ const initialPopover = (await readChatPopover(page)).snapshot;
336
+ await openConfigurationRoot(page, "work");
337
+ if ((await readChatPopover(page)).snapshot !== undefined) {
338
+ try {
339
+ const labels = configurationSemanticLabels(requested);
340
+ if (axis === "model")
341
+ return await selectChatPopoverModel(page, labels);
342
+ if (axis === "effort")
343
+ return await selectChatPopoverEffort(page, labels);
344
+ if (axis === "speed")
345
+ return await selectChatPopoverSpeed(page, labels);
346
+ return undefined;
347
+ }
348
+ finally {
349
+ if (initialPopover === undefined)
350
+ await closeConfigurationMenus(page);
351
+ }
352
+ }
271
353
  const retryWindowMs = Math.min(timeoutMs ?? CONFIGURATION_CONTROL_DISCOVERY_TIMEOUT_MS, CONFIGURATION_CONTROL_DISCOVERY_TIMEOUT_MS);
272
354
  const attempts = Math.max(2, Math.min(CONFIGURATION_SELECTION_MAX_ATTEMPTS, Math.ceil(Math.max(0, retryWindowMs) / CONFIGURATION_SELECTION_RETRY_MS) + 1));
273
355
  for (let attempt = 0; attempt < attempts; attempt += 1) {
@@ -424,6 +506,22 @@ function workAxisOptionLabelMatches(axis, label) {
424
506
  return candidates.some(candidate => visibleLabelMatches(label, candidate));
425
507
  }
426
508
  async function selectChatAxis(env, axis, requested, timeoutMs) {
509
+ const page = env.page;
510
+ const initialPopover = (await readChatPopover(page)).snapshot;
511
+ await openConfigurationRoot(page, "chat");
512
+ if ((await readChatPopover(page)).snapshot !== undefined) {
513
+ try {
514
+ if (axis === "modelVersion")
515
+ return await selectChatPopoverModel(page, configurationSemanticLabels(requested));
516
+ if (axis === "effort" || axis === "intelligence" || axis === "model")
517
+ return await selectChatPopoverEffort(page, configurationSemanticLabels(requested));
518
+ return undefined;
519
+ }
520
+ finally {
521
+ if (initialPopover === undefined)
522
+ await closeConfigurationMenus(page);
523
+ }
524
+ }
427
525
  const legacyArgs = axis === "modelVersion"
428
526
  ? { modelVersion: requested }
429
527
  : axis === "intelligence"
@@ -443,6 +541,8 @@ async function selectChatAxis(env, axis, requested, timeoutMs) {
443
541
  return result.ok ? result.data?.selected.at(-1) : undefined;
444
542
  }
445
543
  async function openConfigurationRoot(page, experience) {
544
+ if (experience !== "unknown" && (await readChatPopover(page)).snapshot !== undefined)
545
+ return true;
446
546
  const existing = await readConfigurationPanel(page);
447
547
  if (existing.axisRows.length > 0) {
448
548
  return true;
@@ -654,6 +754,7 @@ async function readConfigurationPanel(page) {
654
754
  const html = control;
655
755
  return {
656
756
  label: normalize(control.getAttribute("aria-label") ?? html.innerText ?? control.textContent ?? ""),
757
+ value: normalize(html.innerText ?? control.textContent ?? ""),
657
758
  testId: control.getAttribute("data-testid") ?? ""
658
759
  };
659
760
  })
@@ -666,6 +767,8 @@ async function readConfigurationPanel(page) {
666
767
  };
667
768
  if (openerCandidates.length === 1 && openerCandidates[0]?.label.length) {
668
769
  result.openerLabel = openerCandidates[0].label;
770
+ if (openerCandidates[0].value.length > 0)
771
+ result.openerValue = openerCandidates[0].value;
669
772
  }
670
773
  return result;
671
774
  }, localeLabels.configurationAxes).catch(() => ({ axisRows: [], advancedVisible: false }));
@@ -832,6 +935,10 @@ function normalizeConfigurationId(value) {
832
935
  .trim();
833
936
  }
834
937
  async function closeConfigurationMenus(page) {
938
+ if ((await readChatPopover(page)).snapshot !== undefined) {
939
+ await closeChatPopover(page);
940
+ return;
941
+ }
835
942
  if (!await pressConfigurationEscape(page))
836
943
  return;
837
944
  await page.waitForTimeout?.(50);
@@ -1,11 +1,18 @@
1
1
  import { countPageMessages } from "../dom/messages.js";
2
2
  import { parseConversationId } from "../browser/page-state.js";
3
3
  import { withTimeout } from "./timeouts.js";
4
+ import { chatGPTAttachmentContextUrl } from "../browser/chatgpt-url.js";
4
5
  export async function contextFromPage(page, partial = {}, options = {}) {
6
+ const partialAttachmentUrl = chatGPTAttachmentContextUrl(partial.url);
7
+ const safePartial = partialAttachmentUrl === undefined ? partial : { ...partial, url: partialAttachmentUrl };
5
8
  if (page === undefined || options.minimal === true) {
6
- return { timestamp: new Date().toISOString(), ...partial };
9
+ return { timestamp: new Date().toISOString(), ...safePartial };
10
+ }
11
+ const url = typeof page.url === "function" ? await Promise.resolve().then(() => page.url()).catch(() => safePartial.url) : safePartial.url;
12
+ const attachmentUrl = chatGPTAttachmentContextUrl(url);
13
+ if (attachmentUrl !== undefined) {
14
+ return { timestamp: new Date().toISOString(), ...safePartial, url: attachmentUrl };
7
15
  }
8
- const url = typeof page.url === "function" ? await Promise.resolve(page.url()).catch(() => partial.url) : partial.url;
9
16
  const title = typeof page.title === "function"
10
17
  ? await withTimeout(page.title(), 1000, "Timed out while reading page title.").catch(() => partial.title)
11
18
  : partial.title;
@@ -16,7 +23,7 @@ export async function contextFromPage(page, partial = {}, options = {}) {
16
23
  const conversationId = url !== undefined ? parseConversationId(url) : partial.conversationId;
17
24
  const context = {
18
25
  timestamp: new Date().toISOString(),
19
- ...partial
26
+ ...safePartial
20
27
  };
21
28
  if (url !== undefined) {
22
29
  context.url = url;
@@ -161,7 +161,7 @@ function downloadCheck(env) {
161
161
  if (page === undefined)
162
162
  return unknown("Download readiness requires a bootstrapped ChatGPT page.");
163
163
  return typeof page.waitForEvent === "function"
164
- ? ok("Browser download events are available.")
164
+ ? ok("The browser exposes a download event API; a completed download receipt has not been verified.")
165
165
  : unsupported("The active browser page does not expose download events.");
166
166
  }
167
167
  async function clipboardCheck() {
@@ -3,6 +3,11 @@ type SurfaceSnapshot = {
3
3
  url: string;
4
4
  composerLabels: string[];
5
5
  mainControls: string[];
6
+ /** Controls outside overlays, scoped to the composer (or main fallback). */
7
+ composerControls?: string[];
8
+ /** Separate visible control roots; unrelated overlays cannot combine axes. */
9
+ controlGroups?: string[][];
10
+ rootBudgetExceeded?: true;
6
11
  mainText: string;
7
12
  selectedSurfaceLabels?: string[];
8
13
  };
@@ -7,15 +7,30 @@ import { ensurePage } from "./session.js";
7
7
  const CHATGPT_HOME = "https://chatgpt.com/";
8
8
  const EXPERIENCE_CONTROL_DISCOVERY_TIMEOUT_MS = 15_000;
9
9
  const EXPERIENCE_POLL_MS = 250;
10
+ const EXPERIENCE_READINESS_TIMEOUT_MS = 1500;
10
11
  export async function detectExperience(env, args = {}) {
11
- void args;
12
12
  const boot = await ensurePage(env);
13
13
  if (!boot.ok) {
14
14
  return boot;
15
15
  }
16
16
  const page = env.page;
17
17
  try {
18
- const data = detectExperienceFromSnapshot(await readSurfaceSnapshot(page));
18
+ const readinessMs = Math.max(0, Math.min(args.timeoutMs ?? EXPERIENCE_READINESS_TIMEOUT_MS, EXPERIENCE_CONTROL_DISCOVERY_TIMEOUT_MS));
19
+ const readinessDeadline = Date.now() + readinessMs;
20
+ let data = detectExperienceFromSnapshot(await readSurfaceSnapshot(page));
21
+ // An empty post-navigation capture is loading evidence, not a final
22
+ // classification. Never wait out a known blocker or a genuine score tie.
23
+ for (let attempt = 1; data.experience === "unknown" && data.evidence.length === 0
24
+ && Date.now() < readinessDeadline
25
+ && attempt < pollAttempts(readinessMs, EXPERIENCE_POLL_MS); attempt += 1) {
26
+ const blocker = await experiencePageBlocker(page, data);
27
+ if (blocker !== undefined)
28
+ return blocker;
29
+ if (page.waitForTimeout === undefined || Date.now() >= readinessDeadline)
30
+ break;
31
+ await page.waitForTimeout(Math.min(EXPERIENCE_POLL_MS, readinessDeadline - Date.now()));
32
+ data = detectExperienceFromSnapshot(await readSurfaceSnapshot(page));
33
+ }
19
34
  return resultOk(data, await contextFromPage(page, {
20
35
  experience: data.experience,
21
36
  selectorProfile: data.selectorProfile
@@ -177,9 +192,14 @@ async function navigateConversationToSurfaceHome(page, timeoutMs) {
177
192
  return true;
178
193
  }
179
194
  export function detectExperienceFromSnapshot(snapshot) {
195
+ if (snapshot.rootBudgetExceeded === true) {
196
+ return {
197
+ experience: "unknown", selectorProfile: "unknown", confidence: "low",
198
+ evidence: [{ source: "control", label: "Experience surface root budget exceeded" }]
199
+ };
200
+ }
180
201
  const evidence = [];
181
202
  const composerLabels = snapshot.composerLabels.map(normalizeForLabelMatch);
182
- const controls = snapshot.mainControls.map(normalizeForLabelMatch);
183
203
  const mainText = normalizeForLabelMatch(snapshot.mainText);
184
204
  const selectedSurfaceLabels = (snapshot.selectedSurfaceLabels ?? []).map(normalizeForLabelMatch);
185
205
  const url = snapshot.url.toLowerCase();
@@ -201,13 +221,18 @@ export function detectExperienceFromSnapshot(snapshot) {
201
221
  for (const label of chatComposer) {
202
222
  evidence.push({ source: "composer", label });
203
223
  }
204
- const workAxisCount = ["model", "effort", "speed"]
205
- .filter(axis => hasAnyLabel(controls, localeLabels.configurationAxes[axis]))
206
- .length;
207
- if (workAxisCount >= 2) {
208
- evidence.push({ source: "control", label: `Work configuration axes (${workAxisCount}/3)` });
224
+ // Model and effort also occur in the Chat composer popover. Only the
225
+ // complete Work axis group, including speed in the same visible pane,
226
+ // supplies Work evidence. Do not combine labels across unrelated overlays.
227
+ const workAxes = (snapshot.controlGroups ?? [snapshot.mainControls]).some(group => {
228
+ const groupLabels = group.map(normalizeForLabelMatch);
229
+ return ["model", "effort", "speed"].every(axis => hasAnyLabel(groupLabels, localeLabels.configurationAxes[axis]));
230
+ });
231
+ if (workAxes) {
232
+ evidence.push({ source: "control", label: "Work configuration axes (3/3)" });
209
233
  }
210
- const workConfigurationOpener = controls.some(label => /\b(?:gpt[\s-]?\d|\d+(?:\.\d+)+|sol|luna|terra)\b/i.test(label)
234
+ const composerControls = (snapshot.composerControls ?? snapshot.mainControls).map(normalizeForLabelMatch);
235
+ const workConfigurationOpener = composerControls.some(label => /\b(?:gpt[\s-]?\d|\d+(?:\.\d+)+|sol|luna|terra)\b/i.test(label)
211
236
  && hasAnyLabel([label], [
212
237
  ...localeLabels.configurationOptions.light,
213
238
  ...localeLabels.configurationOptions.medium,
@@ -227,7 +252,7 @@ export function detectExperienceFromSnapshot(snapshot) {
227
252
  }
228
253
  const workScore = workComposer.length * 4
229
254
  + (workSurfaceSelected ? 10 : 0)
230
- + (workAxisCount >= 2 ? 4 : 0)
255
+ + (workAxes ? 6 : 0)
231
256
  // The active Work task drops the Chat/Work radio and keeps the shared
232
257
  // "Chat with ChatGPT" textbox name. Its compound model + effort opener is
233
258
  // therefore strong enough to disambiguate that continuation surface.
@@ -288,7 +313,19 @@ export async function readSurfaceSnapshot(page) {
288
313
  const normalize = (value) => value.replace(/\s+/g, " ").trim();
289
314
  const normalizeComparable = (value) => normalize(value).toLocaleLowerCase();
290
315
  const wantedSurfaceLabels = new Set(surfaceOptionLabels.map(normalizeComparable));
291
- const composerRoots = Array.from(document.querySelectorAll("main form, main [data-testid*='composer' i], main [class*='composer' i]"));
316
+ const composerRoots = Array.from(document.querySelectorAll("main form, main [data-testid*='composer' i], main [class*='composer' i]")).filter(visible);
317
+ const main = document.querySelector("main");
318
+ const overlayRoots = Array.from(document.querySelectorAll("[role='menu'], [role='listbox'], [data-radix-popper-content-wrapper], [data-radix-menu-content]")).filter(visible);
319
+ const composerControlRoots = composerRoots.length > 0 ? composerRoots : main === null ? [] : [main];
320
+ const effectiveControlRoots = Array.from(new Set([...composerControlRoots, ...overlayRoots]));
321
+ // Reject oversized captures before expanding overlapping subtrees. A
322
+ // truncated prefix could omit contradictory surface evidence.
323
+ if (effectiveControlRoots.length > 32) {
324
+ return {
325
+ composerLabels: [], mainControls: [], composerControls: [], controlGroups: [],
326
+ mainText: "", selectedSurfaceLabels: [], rootBudgetExceeded: true
327
+ };
328
+ }
292
329
  const composerNodes = composerRoots.flatMap(root => [
293
330
  root,
294
331
  ...Array.from(root.querySelectorAll("textarea, [contenteditable='true'], [role='textbox'], input"))
@@ -299,18 +336,29 @@ export async function readSurfaceSnapshot(page) {
299
336
  .map(normalize)
300
337
  .filter(Boolean)))
301
338
  .slice(0, 16);
302
- const main = document.querySelector("main");
303
- const overlayRoots = Array.from(document.querySelectorAll("[role='menu'], [role='listbox'], [data-radix-popper-content-wrapper], [data-radix-menu-content]")).filter(visible);
304
- const controlRoots = Array.from(new Set([...composerRoots, ...overlayRoots]));
305
- const effectiveControlRoots = controlRoots.length > 0
306
- ? controlRoots
307
- : main === null ? [] : [main];
308
- const mainControls = Array.from(new Set(effectiveControlRoots.flatMap(root => Array.from(root.querySelectorAll("button, [role='button'], [role='menuitem'], [role='menuitemradio'], [role='option']")))
339
+ const overlayRootSet = new Set(overlayRoots);
340
+ const owningOverlay = (node) => {
341
+ let current = node;
342
+ while (current !== null) {
343
+ if (overlayRootSet.has(current))
344
+ return current;
345
+ current = current.parentElement;
346
+ }
347
+ return undefined;
348
+ };
349
+ const controlsFor = (root) => Array.from(new Set(Array.from(root.querySelectorAll("button, [role='button'], [role='menuitem'], [role='menuitemradio'], [role='option']"))
309
350
  .filter(visible)
351
+ // An ancestor composer/popover must not aggregate independent or
352
+ // nested menus. Each overlay contributes only its own nearest scope.
353
+ .filter(node => owningOverlay(node) === (overlayRootSet.has(root) ? root : undefined))
310
354
  .map(labelFor)
311
355
  .map(normalize)
312
- .filter(Boolean)))
313
- .slice(0, 120);
356
+ .filter(Boolean))).slice(0, 120);
357
+ const rootGroups = new Map(effectiveControlRoots.map(root => [root, controlsFor(root)]));
358
+ const controlGroups = [...rootGroups.values()];
359
+ const mainControls = Array.from(new Set(controlGroups.flat())).slice(0, 120);
360
+ const composerControls = Array.from(new Set(composerControlRoots
361
+ .flatMap(root => rootGroups.get(root) ?? []))).slice(0, 120);
314
362
  const surfaceTextNodes = main === null ? [] : Array.from(main.querySelectorAll("h1, h2, h3, form, [data-testid*='composer' i], [class*='composer' i]"))
315
363
  .filter(visible)
316
364
  .slice(0, 32);
@@ -321,7 +369,7 @@ export async function readSurfaceSnapshot(page) {
321
369
  .map(normalize)
322
370
  .filter(label => wantedSurfaceLabels.has(normalizeComparable(label)))))
323
371
  .slice(0, 4);
324
- return { composerLabels, mainControls, mainText, selectedSurfaceLabels };
372
+ return { composerLabels, mainControls, composerControls, controlGroups, mainText, selectedSurfaceLabels };
325
373
  }, [
326
374
  ...localeLabels.experienceOptions.chat,
327
375
  ...localeLabels.experienceOptions.work,
@@ -4,7 +4,7 @@ import { createHash } from "node:crypto";
4
4
  import path from "node:path";
5
5
  import { downloadLatestArtifact, locatorCountWithTimeout } from "./artifacts.js";
6
6
  import { ACTIVE_COMPOSER_FILE_INPUT_CLICK_EXPRESSION } from "../browser/active-composer-file-input.js";
7
- import { waitForDownloadFromClick } from "../browser/downloads.js";
7
+ import { isTerminalDownloadCode, isTerminalDownloadError, waitForDownloadFromClick } from "../browser/downloads.js";
8
8
  import { nodeErrorCode, resultError, resultOk } from "../errors.js";
9
9
  import { addFilesButton, cssSelectors, requiredLocator } from "../dom/selectors.js";
10
10
  import { escapeRegExp, localeLabels } from "../dom/locale-labels.js";
@@ -987,7 +987,7 @@ export async function downloadLatestFile(env, args) {
987
987
  }
988
988
  if (count === 0) {
989
989
  const artifactDownload = await downloadLatestArtifact(env, args);
990
- if (artifactDownload.ok) {
990
+ if (artifactDownload.ok || isTerminalDownloadCode(artifactDownload.blocker?.code)) {
991
991
  return artifactDownload;
992
992
  }
993
993
  return {
@@ -1008,7 +1008,7 @@ export async function downloadLatestFile(env, args) {
1008
1008
  return resultOk(downloaded, await contextFromPage(page));
1009
1009
  }
1010
1010
  catch (error) {
1011
- return resultError(error instanceof Error ? error : new Error(String(error)), await contextFromPage(page));
1011
+ return resultError(error instanceof Error ? error : new Error(String(error)), await contextFromPage(page, {}, { minimal: isTerminalDownloadError(error) }));
1012
1012
  }
1013
1013
  }
1014
1014
  async function tryGeneratedFilePreviewDownload(page, args) {
@@ -1039,8 +1039,12 @@ async function tryGeneratedFilePreviewDownload(page, args) {
1039
1039
  await affordance.click({ timeoutMs: localGuardTimeout(timeoutMs, 10000) });
1040
1040
  const labelledPreview = requiredLocator(page, `section[aria-label="${escapeCssAttribute(selected.filename)}"]`);
1041
1041
  const workbookPreviews = requiredLocator(page, "section[data-testid^='popcorn-']");
1042
- const workbookPreview = workbookPreviews.filter?.({ hasText: selected.filename }) ?? workbookPreviews;
1043
- const download = await waitForPreviewDownloadControl(page, [labelledPreview, workbookPreview], timeoutMs);
1042
+ // A provider without locator filtering cannot tie a workbook preview to
1043
+ // the selected filename. Keep the explicitly labelled preview usable.
1044
+ const workbookPreview = typeof workbookPreviews.filter === "function"
1045
+ ? workbookPreviews.filter({ hasText: selected.filename })
1046
+ : undefined;
1047
+ const download = await waitForPreviewDownloadControl(page, workbookPreview === undefined ? [labelledPreview] : [labelledPreview, workbookPreview], timeoutMs);
1044
1048
  if (download === undefined) {
1045
1049
  throw new Error(`The artifact preview for ${selected.filename} did not expose a visible Download control.`);
1046
1050
  }
@@ -1048,7 +1052,7 @@ async function tryGeneratedFilePreviewDownload(page, args) {
1048
1052
  return resultOk(downloaded, await contextFromPage(page));
1049
1053
  }
1050
1054
  catch (error) {
1051
- return resultError(error instanceof Error ? error : new Error(String(error)), await contextFromPage(page));
1055
+ return resultError(error instanceof Error ? error : new Error(String(error)), await contextFromPage(page, {}, { minimal: isTerminalDownloadError(error) }));
1052
1056
  }
1053
1057
  }
1054
1058
  async function inspectGeneratedFileAffordances(page, timeoutMs) {
@@ -6,6 +6,7 @@ import { normalizeLabel, normalizeWhitespace } from "../dom/visible-text.js";
6
6
  import { contextFromPage } from "./context.js";
7
7
  import { discoverPowerSlider, observedPowerSlider, resolvePowerTarget } from "./power-discovery.js";
8
8
  import { ensurePage } from "./session.js";
9
+ import { closeChatPopover, readChatPopover, selectChatPopoverEffort, selectChatPopoverModel, setChatPopoverView } from "./chat-popover.js";
9
10
  const DEFAULT_MODE_EFFORT = "Thinking";
10
11
  const CURRENT_MODE_LABELS = dedupeLabels([
11
12
  ...localeLabels.modeLabels,
@@ -14,7 +15,7 @@ const CURRENT_MODE_LABELS = dedupeLabels([
14
15
  ]);
15
16
  const MODE_OPENER_LABELS = [...CURRENT_MODE_LABELS.filter(label => label !== "Pro"), ...localeLabels.modeOpenerExtra];
16
17
  const MODEL_VERSION_FAMILY_PATTERN = /^gpt[\s-]/i;
17
- const MODEL_VERSION_LABEL_PATTERN = /^(?:o\d+|\d+(?:\.\d+)?)$/i;
18
+ const MODEL_VERSION_LABEL_PATTERN = /^(?:gpt[\s-].+|o\d+|\d+(?:\.\d+)?|latest)$/i;
18
19
  const CANONICAL_INTELLIGENCE_ORDER = new Map([
19
20
  ["instant", 0],
20
21
  ["medium", 1],
@@ -53,6 +54,7 @@ export async function setMode(env, args) {
53
54
  }
54
55
  const page = env.page;
55
56
  try {
57
+ const initialPopover = (await readChatPopover(page)).snapshot;
56
58
  const requested = requestedModeSelections(args);
57
59
  const requestedVersion = requestedModelVersion(args);
58
60
  const requestedForOpening = requestedVersion === undefined ? requested : [...requested, requestedModeSelection(requestedVersion)];
@@ -64,6 +66,34 @@ export async function setMode(env, args) {
64
66
  return selectorDrift(page, "No unique ChatGPT mode menu opener was found.");
65
67
  }
66
68
  await page.waitForTimeout?.(250);
69
+ const scopedPopover = (await readChatPopover(page)).snapshot;
70
+ if (scopedPopover !== undefined) {
71
+ try {
72
+ const selected = [];
73
+ const candidates = [];
74
+ if (requestedVersion !== undefined) {
75
+ const model = await selectChatPopoverModel(page, [requestedVersion]);
76
+ if (model === undefined)
77
+ return selectorDrift(page, `Model version "${requestedVersion}" did not verify in the owned Chat model view.`);
78
+ selected.push(model);
79
+ candidates.push(model);
80
+ }
81
+ for (const request of requested) {
82
+ const effort = await selectChatPopoverEffort(page, request.labels);
83
+ if (effort === undefined)
84
+ return selectorDrift(page, `Mode option "${request.requested}" did not verify in the owned Chat effort control.`);
85
+ selected.push(effort);
86
+ candidates.push(effort);
87
+ }
88
+ return resultOk({ selected, candidates }, await contextFromPage(page));
89
+ }
90
+ finally {
91
+ if (initialPopover !== undefined)
92
+ await setChatPopoverView(page, initialPopover.view);
93
+ else
94
+ await closeChatPopover(page);
95
+ }
96
+ }
67
97
  let candidates = await enumerateVisibleMenuItems(page);
68
98
  const observedCandidates = [...candidates];
69
99
  const selected = [];
@@ -288,6 +318,8 @@ async function waitForModeMenu(page, requested, timeoutMs) {
288
318
  const deadline = Date.now() + timeoutMs;
289
319
  let modeButtons = [];
290
320
  do {
321
+ if ((await readChatPopover(page)).snapshot !== undefined)
322
+ return { opened: true, alreadySelected: [], modeButtons };
291
323
  modeButtons = await visibleModeButtonLabelList(page);
292
324
  const alreadySelected = findAlreadySelectedModes(modeButtons, requested);
293
325
  if (alreadySelected.length === requested.length) {
@@ -668,8 +700,8 @@ async function selectModelVersion(page, requestedVersion, currentCandidates, tim
668
700
  : { candidates: candidates.map(candidate => candidate.label) };
669
701
  }
670
702
  function isModelVersionSubmenuOpener(item) {
671
- return item.hasPopup === true
672
- || (item.role !== "menuitemradio" && MODEL_VERSION_FAMILY_PATTERN.test(item.label));
703
+ return item.role !== "menuitemradio"
704
+ && (MODEL_VERSION_FAMILY_PATTERN.test(item.label) || item.ariaLabel === "Select model");
673
705
  }
674
706
  async function clickResolvedMenuItem(page, item) {
675
707
  if (item.testId !== undefined && await clickIfUniqueMenuControl(page.locator?.(`[data-testid="${escapeAttributeValue(item.testId)}"]`), item)) {
@@ -681,7 +713,7 @@ async function clickResolvedMenuItem(page, item) {
681
713
  return clickMenuItem(page, item);
682
714
  }
683
715
  async function openModelVersionSubmenu(page, candidates) {
684
- const submenuOpeners = candidates.filter(item => item.hasPopup === true || MODEL_VERSION_FAMILY_PATTERN.test(item.label));
716
+ const submenuOpeners = candidates.filter(isModelVersionSubmenuOpener);
685
717
  if (submenuOpeners.length === 0) {
686
718
  return false;
687
719
  }