@steipete/oracle 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/README.md +1 -1
  2. package/dist/bin/oracle-cli.js +65 -63
  3. package/dist/bin/oracle-mcp.js +0 -0
  4. package/dist/src/browser/actions/archiveConversation.js +31 -7
  5. package/dist/src/browser/actions/assistantResponse.js +43 -1
  6. package/dist/src/browser/actions/attachmentContext.js +239 -0
  7. package/dist/src/browser/actions/attachmentDataTransfer.js +19 -1
  8. package/dist/src/browser/actions/attachmentEvidence.js +78 -0
  9. package/dist/src/browser/actions/attachmentProgress.js +41 -0
  10. package/dist/src/browser/actions/attachmentPrompt.js +130 -0
  11. package/dist/src/browser/actions/attachments.js +306 -355
  12. package/dist/src/browser/actions/deepResearch.js +2 -1
  13. package/dist/src/browser/actions/modelSelection.js +13 -3
  14. package/dist/src/browser/actions/navigation.js +1 -1
  15. package/dist/src/browser/actions/promptComposer.js +361 -186
  16. package/dist/src/browser/actions/remoteFileTransfer.js +4 -2
  17. package/dist/src/browser/actions/thinkingStatus.js +2 -5
  18. package/dist/src/browser/actions/thinkingTime.js +119 -16
  19. package/dist/src/browser/attachRunning.js +46 -2
  20. package/dist/src/browser/attachmentValidation.js +53 -0
  21. package/dist/src/browser/cancellation.js +105 -0
  22. package/dist/src/browser/chatgptImages.js +2 -0
  23. package/dist/src/browser/chatgptThrottle.js +104 -0
  24. package/dist/src/browser/chromeLifecycle.js +267 -43
  25. package/dist/src/browser/config.js +13 -0
  26. package/dist/src/browser/detect.js +1 -1
  27. package/dist/src/browser/index.js +444 -261
  28. package/dist/src/browser/modelDisplay.js +9 -0
  29. package/dist/src/browser/pageActions.js +2 -2
  30. package/dist/src/browser/profileState.js +114 -9
  31. package/dist/src/browser/projectSourcesRunner.js +83 -15
  32. package/dist/src/browser/prompt.js +272 -113
  33. package/dist/src/browser/providers/chatgptDomProvider.js +2 -0
  34. package/dist/src/browser/reattach.js +10 -3
  35. package/dist/src/browser/sessionRunner.js +71 -11
  36. package/dist/src/browser/tabLeaseRegistry.js +414 -38
  37. package/dist/src/browser/utils.js +20 -2
  38. package/dist/src/cli/browserConfig.js +3 -1
  39. package/dist/src/cli/browserDefaults.js +10 -0
  40. package/dist/src/cli/browserTabs.js +12 -29
  41. package/dist/src/cli/detachedSession.js +69 -0
  42. package/dist/src/cli/dryRun.js +58 -48
  43. package/dist/src/cli/harvestIntegrity.js +140 -0
  44. package/dist/src/cli/serveBrowserConfig.js +24 -0
  45. package/dist/src/cli/sessionDisplay.js +9 -2
  46. package/dist/src/cli/sessionRunner.js +91 -7
  47. package/dist/src/config.js +1 -0
  48. package/dist/src/gemini-web/client.js +67 -21
  49. package/dist/src/gemini-web/executor.js +4 -0
  50. package/dist/src/gemini-web/http.js +15 -0
  51. package/dist/src/gemini-web/models.js +15 -11
  52. package/dist/src/mcp/server.js +11 -14
  53. package/dist/src/mcp/tools/chatgptImage.js +4 -4
  54. package/dist/src/mcp/tools/consult.js +84 -22
  55. package/dist/src/mcp/tools/projectSources.js +4 -6
  56. package/dist/src/mcp/tools/sessionResources.js +1 -1
  57. package/dist/src/mcp/tools/sessions.js +2 -2
  58. package/dist/src/mcp/tools/wait.js +182 -0
  59. package/dist/src/mcp/types.js +7 -0
  60. package/dist/src/oracle/errors.js +11 -0
  61. package/dist/src/oracle/providerRoutePlan.js +3 -2
  62. package/dist/src/remote/client.js +70 -6
  63. package/dist/src/remote/health.js +6 -2
  64. package/dist/src/remote/runSlots.js +83 -0
  65. package/dist/src/remote/server.js +345 -71
  66. package/dist/src/sessionManager.js +36 -1
  67. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  68. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  69. package/package.json +25 -23
  70. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  71. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  72. package/dist/bin/oracle.js +0 -683
  73. package/dist/docs-site/.nojekyll +0 -0
  74. package/dist/docs-site/CNAME +0 -1
  75. package/dist/docs-site/RELEASING.html +0 -410
  76. package/dist/docs-site/agents.html +0 -374
  77. package/dist/docs-site/anthropic.html +0 -368
  78. package/dist/docs-site/bridge.html +0 -400
  79. package/dist/docs-site/browser-mode.html +0 -588
  80. package/dist/docs-site/chromium-forks.html +0 -347
  81. package/dist/docs-site/cli-reference.html +0 -346
  82. package/dist/docs-site/configuration.html +0 -436
  83. package/dist/docs-site/favicon.svg +0 -14
  84. package/dist/docs-site/followup.html +0 -368
  85. package/dist/docs-site/gemini.html +0 -379
  86. package/dist/docs-site/grok.html +0 -325
  87. package/dist/docs-site/index.html +0 -360
  88. package/dist/docs-site/install.html +0 -335
  89. package/dist/docs-site/linux.html +0 -321
  90. package/dist/docs-site/llms.txt +0 -43
  91. package/dist/docs-site/manual-tests.html +0 -595
  92. package/dist/docs-site/mcp.html +0 -368
  93. package/dist/docs-site/multimodel.html +0 -364
  94. package/dist/docs-site/mythical-pro-agents.html +0 -360
  95. package/dist/docs-site/notifier.html +0 -338
  96. package/dist/docs-site/openai-endpoints.html +0 -387
  97. package/dist/docs-site/openrouter.html +0 -344
  98. package/dist/docs-site/quickstart.html +0 -369
  99. package/dist/docs-site/refactor/ux.html +0 -532
  100. package/dist/docs-site/sessions.html +0 -388
  101. package/dist/docs-site/social-card.png +0 -0
  102. package/dist/docs-site/social-card.svg +0 -79
  103. package/dist/docs-site/spec.html +0 -363
  104. package/dist/docs-site/testing.html +0 -320
  105. package/dist/docs-site/tui-debug.html +0 -326
  106. package/dist/docs-site/windows-work.html +0 -323
  107. package/dist/docs-site/windows.html +0 -320
  108. package/dist/markdansi/types/index.js +0 -4
  109. package/dist/oracle/bin/oracle-cli.js +0 -472
  110. package/dist/oracle/src/browser/actions/assistantResponse.js +0 -471
  111. package/dist/oracle/src/browser/actions/attachments.js +0 -82
  112. package/dist/oracle/src/browser/actions/modelSelection.js +0 -190
  113. package/dist/oracle/src/browser/actions/navigation.js +0 -75
  114. package/dist/oracle/src/browser/actions/promptComposer.js +0 -167
  115. package/dist/oracle/src/browser/chromeLifecycle.js +0 -104
  116. package/dist/oracle/src/browser/config.js +0 -33
  117. package/dist/oracle/src/browser/constants.js +0 -40
  118. package/dist/oracle/src/browser/cookies.js +0 -210
  119. package/dist/oracle/src/browser/domDebug.js +0 -36
  120. package/dist/oracle/src/browser/index.js +0 -331
  121. package/dist/oracle/src/browser/pageActions.js +0 -5
  122. package/dist/oracle/src/browser/prompt.js +0 -88
  123. package/dist/oracle/src/browser/promptSummary.js +0 -20
  124. package/dist/oracle/src/browser/sessionRunner.js +0 -80
  125. package/dist/oracle/src/browser/types.js +0 -1
  126. package/dist/oracle/src/browser/utils.js +0 -62
  127. package/dist/oracle/src/browserMode.js +0 -1
  128. package/dist/oracle/src/cli/browserConfig.js +0 -44
  129. package/dist/oracle/src/cli/dryRun.js +0 -59
  130. package/dist/oracle/src/cli/engine.js +0 -17
  131. package/dist/oracle/src/cli/errorUtils.js +0 -9
  132. package/dist/oracle/src/cli/help.js +0 -70
  133. package/dist/oracle/src/cli/markdownRenderer.js +0 -15
  134. package/dist/oracle/src/cli/options.js +0 -103
  135. package/dist/oracle/src/cli/promptRequirement.js +0 -14
  136. package/dist/oracle/src/cli/rootAlias.js +0 -30
  137. package/dist/oracle/src/cli/sessionCommand.js +0 -77
  138. package/dist/oracle/src/cli/sessionDisplay.js +0 -270
  139. package/dist/oracle/src/cli/sessionRunner.js +0 -94
  140. package/dist/oracle/src/heartbeat.js +0 -43
  141. package/dist/oracle/src/oracle/client.js +0 -48
  142. package/dist/oracle/src/oracle/config.js +0 -29
  143. package/dist/oracle/src/oracle/errors.js +0 -101
  144. package/dist/oracle/src/oracle/files.js +0 -220
  145. package/dist/oracle/src/oracle/format.js +0 -33
  146. package/dist/oracle/src/oracle/fsAdapter.js +0 -7
  147. package/dist/oracle/src/oracle/oscProgress.js +0 -60
  148. package/dist/oracle/src/oracle/request.js +0 -48
  149. package/dist/oracle/src/oracle/run.js +0 -444
  150. package/dist/oracle/src/oracle/tokenStats.js +0 -39
  151. package/dist/oracle/src/oracle/types.js +0 -1
  152. package/dist/oracle/src/oracle.js +0 -9
  153. package/dist/oracle/src/sessionManager.js +0 -205
  154. package/dist/oracle/src/version.js +0 -39
  155. package/dist/scripts/chrome/browser-tools.js +0 -295
  156. package/dist/src/browser/chromeCookies.js +0 -312
  157. package/dist/src/browser/keytarShim.js +0 -56
  158. package/dist/src/browser/profileSync.js +0 -141
  159. package/dist/src/browser/windowsCookies.js +0 -219
@@ -1,9 +1,16 @@
1
+ import { withoutBrowserCancellation } from "../cancellation.js";
2
+ import { randomUUID } from "node:crypto";
1
3
  import { INPUT_SELECTORS, PROMPT_PRIMARY_SELECTOR, PROMPT_FALLBACK_SELECTOR, SEND_BUTTON_SELECTORS, STOP_BUTTON_SELECTOR, ASSISTANT_ROLE_SELECTOR, } from "../constants.js";
2
4
  import { buildConversationTurnCountExpression, buildConversationTurnListExpression, } from "../conversationTurns.js";
3
5
  import { delay } from "../utils.js";
4
6
  import { logDomFailure } from "../domDebug.js";
7
+ import { assertComposerNavigationSnapshot, assertComposerPlusStayedInPlace, buildAttachmentNamePattern, composerNavigationIdentityFromUrl, } from "./attachments.js";
8
+ import { buildComposerNavigationValidationExpression } from "./attachmentContext.js";
5
9
  import { buildClickDispatcher } from "./domEvents.js";
10
+ import { stageAttachmentPrompt } from "./attachmentPrompt.js";
6
11
  import { BrowserAutomationError } from "../../oracle/errors.js";
12
+ import { buildAttachmentEvidenceExpression } from "./attachmentEvidence.js";
13
+ import { buildAttachmentProgressExpression } from "./attachmentProgress.js";
7
14
  const ENTER_KEY_EVENT = {
8
15
  key: "Enter",
9
16
  code: "Enter",
@@ -11,12 +18,33 @@ const ENTER_KEY_EVENT = {
11
18
  nativeVirtualKeyCode: 13,
12
19
  };
13
20
  const ENTER_KEY_TEXT = "\r";
21
+ const ESCAPE_KEY_EVENT = {
22
+ key: "Escape",
23
+ code: "Escape",
24
+ windowsVirtualKeyCode: 27,
25
+ nativeVirtualKeyCode: 27,
26
+ };
14
27
  export async function submitPrompt(deps, prompt, logger) {
15
28
  const { runtime, input } = deps;
29
+ const hasAttachments = Array.isArray(deps.attachmentNames) && deps.attachmentNames.length > 0;
30
+ if (hasAttachments && !deps.attachmentNavigationUrl) {
31
+ throw new BrowserAutomationError("Oracle cannot safely submit attachments without the pre-upload ChatGPT page identity.", {
32
+ stage: "submit-prompt",
33
+ code: "attachment-navigation-identity-unavailable",
34
+ });
35
+ }
36
+ if (hasAttachments) {
37
+ await assertComposerPlusStayedInPlace(runtime, deps.attachmentNavigationUrl);
38
+ }
16
39
  await waitForDomReady(runtime, logger, deps.inputTimeoutMs ?? undefined);
17
- const encodedPrompt = JSON.stringify(prompt);
18
- const focusResult = await runtime.evaluate({
19
- expression: `(() => {
40
+ let observedLength;
41
+ if (hasAttachments) {
42
+ observedLength = await stageAttachmentPrompt(runtime, prompt, deps.attachmentNavigationUrl);
43
+ }
44
+ else {
45
+ const encodedPrompt = JSON.stringify(prompt);
46
+ const focusResult = await runtime.evaluate({
47
+ expression: `(() => {
20
48
  ${buildClickDispatcher()}
21
49
  const SELECTORS = ${JSON.stringify(INPUT_SELECTORS)};
22
50
  const isVisible = (node) => {
@@ -60,21 +88,21 @@ export async function submitPrompt(deps, prompt, logger) {
60
88
  }
61
89
  return { focused: false };
62
90
  })()`,
63
- returnByValue: true,
64
- awaitPromise: true,
65
- });
66
- if (!focusResult.result?.value?.focused) {
67
- await logDomFailure(runtime, logger, "focus-textarea");
68
- throw new Error("Failed to focus prompt textarea");
69
- }
70
- await input.insertText({ text: prompt });
71
- // Some pages (notably ChatGPT when subscriptions/widgets load) need a brief settle
72
- // before the send button becomes enabled; give it a short breather to avoid races.
73
- await delay(500);
74
- const primarySelectorLiteral = JSON.stringify(PROMPT_PRIMARY_SELECTOR);
75
- const fallbackSelectorLiteral = JSON.stringify(PROMPT_FALLBACK_SELECTOR);
76
- const verification = await runtime.evaluate({
77
- expression: `(() => {
91
+ returnByValue: true,
92
+ awaitPromise: true,
93
+ });
94
+ if (!focusResult.result?.value?.focused) {
95
+ await logDomFailure(runtime, logger, "focus-textarea");
96
+ throw new Error("Failed to focus prompt textarea");
97
+ }
98
+ await input.insertText({ text: prompt });
99
+ // Some pages (notably ChatGPT when subscriptions/widgets load) need a brief settle
100
+ // before the send button becomes enabled; give it a short breather to avoid races.
101
+ await delay(500);
102
+ const primarySelectorLiteral = JSON.stringify(PROMPT_PRIMARY_SELECTOR);
103
+ const fallbackSelectorLiteral = JSON.stringify(PROMPT_FALLBACK_SELECTOR);
104
+ const verification = await runtime.evaluate({
105
+ expression: `(() => {
78
106
  const editor = document.querySelector(${primarySelectorLiteral});
79
107
  const fallback = document.querySelector(${fallbackSelectorLiteral});
80
108
  const inputSelectors = ${JSON.stringify(INPUT_SELECTORS)};
@@ -98,18 +126,18 @@ export async function submitPrompt(deps, prompt, logger) {
98
126
  activeValue: active ? readValue(active) : '',
99
127
  };
100
128
  })()`,
101
- returnByValue: true,
102
- });
103
- const editorTextRaw = verification.result?.value?.editorText ?? "";
104
- const fallbackValueRaw = verification.result?.value?.fallbackValue ?? "";
105
- const activeValueRaw = verification.result?.value?.activeValue ?? "";
106
- const editorTextTrimmed = editorTextRaw?.trim?.() ?? "";
107
- const fallbackValueTrimmed = fallbackValueRaw?.trim?.() ?? "";
108
- const activeValueTrimmed = activeValueRaw?.trim?.() ?? "";
109
- if (!editorTextTrimmed && !fallbackValueTrimmed && !activeValueTrimmed) {
110
- // Learned: occasionally Input.insertText doesn't land in the editor; force textContent/value + input events.
111
- await runtime.evaluate({
112
- expression: `(() => {
129
+ returnByValue: true,
130
+ });
131
+ const editorTextRaw = verification.result?.value?.editorText ?? "";
132
+ const fallbackValueRaw = verification.result?.value?.fallbackValue ?? "";
133
+ const activeValueRaw = verification.result?.value?.activeValue ?? "";
134
+ const editorTextTrimmed = editorTextRaw?.trim?.() ?? "";
135
+ const fallbackValueTrimmed = fallbackValueRaw?.trim?.() ?? "";
136
+ const activeValueTrimmed = activeValueRaw?.trim?.() ?? "";
137
+ if (!editorTextTrimmed && !fallbackValueTrimmed && !activeValueTrimmed) {
138
+ // Learned: occasionally Input.insertText doesn't land in the editor; force textContent/value + input events.
139
+ await runtime.evaluate({
140
+ expression: `(() => {
113
141
  const fallback = document.querySelector(${fallbackSelectorLiteral});
114
142
  if (fallback) {
115
143
  fallback.value = ${encodedPrompt};
@@ -123,11 +151,10 @@ export async function submitPrompt(deps, prompt, logger) {
123
151
  editor.dispatchEvent(new InputEvent('input', { bubbles: true, data: ${encodedPrompt}, inputType: 'insertFromPaste' }));
124
152
  }
125
153
  })()`,
126
- });
127
- }
128
- const promptLength = prompt.length;
129
- const postVerification = await runtime.evaluate({
130
- expression: `(() => {
154
+ });
155
+ }
156
+ const postVerification = await runtime.evaluate({
157
+ expression: `(() => {
131
158
  const editor = document.querySelector(${primarySelectorLiteral});
132
159
  const fallback = document.querySelector(${fallbackSelectorLiteral});
133
160
  const inputSelectors = ${JSON.stringify(INPUT_SELECTORS)};
@@ -151,12 +178,14 @@ export async function submitPrompt(deps, prompt, logger) {
151
178
  activeValue: active ? readValue(active) : '',
152
179
  };
153
180
  })()`,
154
- returnByValue: true,
155
- });
156
- const observedEditor = postVerification.result?.value?.editorText ?? "";
157
- const observedFallback = postVerification.result?.value?.fallbackValue ?? "";
158
- const observedActive = postVerification.result?.value?.activeValue ?? "";
159
- const observedLength = Math.max(observedEditor.length, observedFallback.length, observedActive.length);
181
+ returnByValue: true,
182
+ });
183
+ const observedEditor = postVerification.result?.value?.editorText ?? "";
184
+ const observedFallback = postVerification.result?.value?.fallbackValue ?? "";
185
+ const observedActive = postVerification.result?.value?.activeValue ?? "";
186
+ observedLength = Math.max(observedEditor.length, observedFallback.length, observedActive.length);
187
+ }
188
+ const promptLength = prompt.length;
160
189
  if (promptLength >= 50_000 && observedLength > 0 && observedLength < promptLength - 2_000) {
161
190
  // Learned: very large prompts can truncate silently; fail fast so we can fall back to file uploads.
162
191
  await logDomFailure(runtime, logger, "prompt-too-large");
@@ -167,28 +196,31 @@ export async function submitPrompt(deps, prompt, logger) {
167
196
  observedLength,
168
197
  });
169
198
  }
170
- const clicked = await attemptSendButton(runtime, input, logger, deps?.attachmentNames, deps?.attachmentTimeoutMs);
199
+ const clicked = await attemptSendButton(runtime, input, logger, deps?.attachmentNames, deps?.attachmentTimeoutMs, deps?.page, deps?.attachmentNavigationUrl);
171
200
  if (!clicked) {
172
- await input.dispatchKeyEvent({
173
- type: "keyDown",
174
- ...ENTER_KEY_EVENT,
175
- text: ENTER_KEY_TEXT,
176
- unmodifiedText: ENTER_KEY_TEXT,
177
- });
178
- await input.dispatchKeyEvent({
179
- type: "keyUp",
180
- ...ENTER_KEY_EVENT,
181
- });
201
+ await dispatchEnterKey(input);
182
202
  logger("Submitted prompt via Enter key");
183
203
  }
184
204
  else {
185
- logger("Clicked send button");
205
+ logger("Activated send button");
186
206
  }
187
207
  await deps.onPromptSubmitted?.();
188
208
  const commitTimeoutMs = Math.max(60_000, deps.inputTimeoutMs ?? 0);
189
209
  // Learned: the send button can succeed but the turn doesn't appear immediately; verify commit via turns/stop button.
190
210
  return await verifyPromptCommitted(runtime, prompt, commitTimeoutMs, logger, deps.baselineTurns ?? undefined);
191
211
  }
212
+ async function dispatchEnterKey(Input) {
213
+ await Input.dispatchKeyEvent({
214
+ type: "keyDown",
215
+ ...ENTER_KEY_EVENT,
216
+ text: ENTER_KEY_TEXT,
217
+ unmodifiedText: ENTER_KEY_TEXT,
218
+ });
219
+ await Input.dispatchKeyEvent({
220
+ type: "keyUp",
221
+ ...ENTER_KEY_EVENT,
222
+ });
223
+ }
192
224
  export async function clearPromptComposer(Runtime, logger) {
193
225
  const primarySelectorLiteral = JSON.stringify(PROMPT_PRIMARY_SELECTOR);
194
226
  const fallbackSelectorLiteral = JSON.stringify(PROMPT_FALLBACK_SELECTOR);
@@ -287,11 +319,12 @@ function buildAttachmentReadyExpression(attachmentNames) {
287
319
  const attachmentExpectations = attachmentNames.map((attachment) => {
288
320
  const name = typeof attachment === "string" ? attachment : attachment.name;
289
321
  const normalized = name.toLowerCase().replace(/\s+/g, " ").trim();
322
+ const generatedBundle = typeof attachment === "object" && attachment.generatedBundle === true;
290
323
  return {
291
324
  name: normalized,
292
325
  stem: normalized.replace(/\.[a-z0-9]{1,10}$/i, ""),
293
- extension: normalized.match(/(\.[a-z0-9]{1,10})$/i)?.[1] ?? "",
294
- generatedBundle: typeof attachment === "object" && attachment.generatedBundle === true,
326
+ namePattern: buildAttachmentNamePattern(normalized, generatedBundle)?.source ?? "",
327
+ generatedBundle,
295
328
  };
296
329
  });
297
330
  const namesLiteral = JSON.stringify(attachmentExpectations);
@@ -299,74 +332,13 @@ function buildAttachmentReadyExpression(attachmentNames) {
299
332
  const expected = ${namesLiteral};
300
333
  const sendSelectors = ${JSON.stringify(SEND_BUTTON_SELECTORS)};
301
334
  const normalize = (value) => String(value || '').toLowerCase().replace(/\\s+/g, ' ').trim();
302
- const hasNameBoundary = (text, name) => {
303
- if (!name) return false;
304
- let from = 0;
305
- while (from < text.length) {
306
- const index = text.indexOf(name, from);
307
- if (index === -1) return false;
308
- const previous = text[index - 1] || '';
309
- const next = text[index + name.length] || '';
310
- const previousOk = !previous || !/[a-z0-9._-]/.test(previous);
311
- const nextOk = !next || !/[a-z0-9._-]/.test(next);
312
- if (previousOk && nextOk) return true;
313
- from = index + name.length;
314
- }
315
- return false;
316
- };
317
- const hasStemFileBoundary = (text, stem) => {
318
- if (!stem) return false;
319
- let from = 0;
320
- while (from < text.length) {
321
- const index = text.indexOf(stem, from);
322
- if (index === -1) return false;
323
- const previous = text[index - 1] || '';
324
- const next = text[index + stem.length] || '';
325
- const previousOk = !previous || !/[a-z0-9._-]/.test(previous);
326
- const nextOk = !next || !/[a-z0-9._-]/.test(next);
327
- if (previousOk && nextOk) return true;
328
- from = index + stem.length;
329
- }
330
- return false;
331
- };
332
- const hasBareStemBoundary = (text, stem) => {
333
- if (!stem) return false;
334
- let from = 0;
335
- while (from < text.length) {
336
- const index = text.indexOf(stem, from);
337
- if (index === -1) return false;
338
- const previous = text[index - 1] || '';
339
- const next = text[index + stem.length] || '';
340
- const previousOk = !previous || !/[a-z0-9._-]/.test(previous);
341
- const nextOk = !next || !/[a-z0-9._(-]/.test(next);
342
- if (previousOk && nextOk) return true;
343
- from = index + stem.length;
344
- }
345
- return false;
346
- };
347
- const hasExtensionBoundary = (text, extension) => {
348
- if (!extension) return false;
349
- let from = 0;
350
- while (from < text.length) {
351
- const index = text.indexOf(extension, from);
352
- if (index === -1) return false;
353
- const next = text[index + extension.length] || '';
354
- if (!next || !/[a-z0-9]/.test(next)) return true;
355
- from = index + extension.length;
356
- }
357
- return false;
358
- };
359
335
  const matchesExpected = (value, item) => {
360
336
  const text = normalize(value);
361
337
  if (!text) return false;
362
- if (hasNameBoundary(text, item.name)) return true;
363
- if (item.generatedBundle && hasBareStemBoundary(text, item.stem)) return true;
338
+ const namePattern = item.namePattern ? new RegExp(item.namePattern, 'iu') : null;
364
339
  if (
365
- item.stem &&
366
- item.stem.length >= 4 &&
367
- item.extension &&
368
- text.includes(item.stem + '(') &&
369
- hasExtensionBoundary(text, item.extension)
340
+ namePattern &&
341
+ String(value || '').split('\\n').some((candidate) => namePattern.test(normalize(candidate)))
370
342
  ) {
371
343
  return true;
372
344
  }
@@ -465,7 +437,7 @@ function buildAttachmentReadyExpression(attachmentNames) {
465
437
  };
466
438
  pushAttrs(node);
467
439
  pushText(node);
468
- return pieces.join(' ').toLowerCase();
440
+ return pieces.join('\\n').toLowerCase();
469
441
  };
470
442
  const collectLabelHaystack = (node) => {
471
443
  if (!node) return '';
@@ -478,7 +450,7 @@ function buildAttachmentReadyExpression(attachmentNames) {
478
450
  push(parent);
479
451
  const grandparent = parent?.parentElement;
480
452
  push(grandparent);
481
- return pieces.join(' ').toLowerCase();
453
+ return pieces.join('\\n').toLowerCase();
482
454
  };
483
455
  const attachmentRoots = Array.from(new Set([composer])).filter(Boolean);
484
456
  const collectChipNodes = () => {
@@ -499,32 +471,11 @@ function buildAttachmentReadyExpression(attachmentNames) {
499
471
  };
500
472
  const chipNodes = collectChipNodes();
501
473
  const chipLabels = chipNodes.map((node) => collectLabelHaystack(node));
502
- const chipOwnLabels = chipNodes.map((node) => collectOwnLabelHaystack(node));
503
- const hasEllipsisSuffix = (label) => {
504
- const marker = label.includes('…') ? '…' : label.includes('...') ? '...' : '';
505
- if (!marker) return false;
506
- return normalize(label.split(marker)[1] || '').length > 0;
507
- };
508
- const chipOwnLabelsWithVisibleNames = chipOwnLabels.filter((label) =>
509
- /\\.[a-z][a-z0-9]{0,9}(?:\\b|$)/i.test(label) ||
510
- hasEllipsisSuffix(label),
511
- );
512
- const visibleExtensionLabelsMatchExpected = chipOwnLabelsWithVisibleNames.every((label) =>
513
- expected.some((item) => matchesExpected(label, item)),
514
- );
515
- const visibleStemOnlyMismatch = chipOwnLabels.some((label) =>
516
- expected.some(
517
- (item) =>
518
- !item.generatedBundle &&
519
- item.stem &&
520
- hasStemFileBoundary(label, item.stem) &&
521
- !matchesExpected(label, item),
522
- ),
523
- );
524
-
474
+ const uploadEvidence = ${buildAttachmentEvidenceExpression(attachmentExpectations.map((item) => item.name))};
525
475
  const chipsReady = (() => {
526
476
  const used = new Set();
527
- return expected.every((item) => {
477
+ return expected.every((item, expectedIndex) => {
478
+ if (uploadEvidence[expectedIndex]) return true;
528
479
  const index = chipLabels.findIndex((label, candidateIndex) =>
529
480
  !used.has(candidateIndex) && matchesExpected(label, item),
530
481
  );
@@ -542,42 +493,20 @@ function buildAttachmentReadyExpression(attachmentNames) {
542
493
  ),
543
494
  ),
544
495
  );
545
- // Count-based fallback: if we cannot match names individually (ChatGPT may strip
546
- // the filename out of attribute-readable text into a deeply nested span), but we
547
- // do see at least as many distinct "Remove" affordances as attachments we
548
- // uploaded, trust the upload without double-counting nested chip/remove nodes.
549
- const removeAffordances = [];
550
- const removeSeen = new Set();
551
- for (const root of attachmentRoots) {
552
- for (const node of Array.from(root.querySelectorAll(
553
- '[aria-label*="Remove" i], [aria-label*="remove" i], button[aria-label*="Remove" i], button[aria-label*="remove" i]',
554
- ))) {
555
- if (!(node instanceof HTMLElement)) continue;
556
- if (node.closest('textarea,[contenteditable="true"]')) continue;
557
- const aria = (node.getAttribute?.('aria-label') ?? '').toLowerCase();
558
- const fileSpecific = aria.includes('remove file') || aria.includes('remove attachment');
559
- const attachmentOwner = node.closest(
560
- '[data-testid*="chip"], [data-testid*="attachment"], [data-testid*="upload"], [data-testid*="file"]',
561
- );
562
- if (!fileSpecific && !attachmentOwner) continue;
563
- if (removeSeen.has(node)) continue;
564
- removeSeen.add(node);
565
- removeAffordances.push(node);
566
- }
567
- }
568
- const countReady =
569
- !visibleStemOnlyMismatch &&
570
- visibleExtensionLabelsMatchExpected &&
571
- removeAffordances.length >= expected.length;
572
-
573
- return chipsReady || inputsReady || countReady;
496
+ return (chipsReady || inputsReady) && !${buildAttachmentProgressExpression("composer")};
574
497
  })()`;
575
498
  }
576
499
  export function buildAttachmentReadyExpressionForTest(attachmentNames) {
577
500
  return buildAttachmentReadyExpression(attachmentNames);
578
501
  }
579
- async function attemptSendButton(Runtime, Input, _logger, attachmentNames, attachmentTimeoutMs) {
502
+ async function attemptSendButton(Runtime, Input, logger, attachmentNames, attachmentTimeoutMs, Page, attachmentNavigationUrl) {
580
503
  const needAttachment = Array.isArray(attachmentNames) && attachmentNames.length > 0;
504
+ if (needAttachment && !attachmentNavigationUrl) {
505
+ throw new BrowserAutomationError("Oracle cannot safely submit attachments without the pre-upload ChatGPT page identity.", {
506
+ stage: "submit-prompt",
507
+ code: "attachment-navigation-identity-unavailable",
508
+ });
509
+ }
581
510
  const script = `(() => {
582
511
  ${buildClickDispatcher()}
583
512
  const selectors = ${JSON.stringify(SEND_BUTTON_SELECTORS)};
@@ -606,10 +535,19 @@ async function attemptSendButton(Runtime, Input, _logger, attachmentNames, attac
606
535
  }
607
536
  const button = candidates.find((node) => isVisible(node) && isEnabled(node)) || null;
608
537
  if (!button) return { status: 'missing' };
609
- button.scrollIntoView({ block: 'center', inline: 'center' });
610
538
  const rect = button.getBoundingClientRect();
611
539
  if (rect.width > 0 && rect.height > 0) {
612
- return { status: 'point', x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
540
+ const x = rect.left + rect.width / 2;
541
+ const y = rect.top + rect.height / 2;
542
+ // Scrolling an already-visible composer can trigger ChatGPT's layout snap.
543
+ // If scrolling is necessary, discard these coordinates and measure later.
544
+ if (x < 0 || y < 0 || x >= window.innerWidth || y >= window.innerHeight) {
545
+ button.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' });
546
+ return { status: 'settling' };
547
+ }
548
+ const hit = document.elementFromPoint(x, y);
549
+ if (!hit || !button.contains(hit)) return { status: 'settling' };
550
+ return { status: 'point', x, y };
613
551
  }
614
552
  // Last-resort fallback for unusual DOMs where the button is visible but has no useful rect.
615
553
  dispatchClickSequence(button);
@@ -620,6 +558,10 @@ async function attemptSendButton(Runtime, Input, _logger, attachmentNames, attac
620
558
  // shorter historical deadline.
621
559
  const timeoutMs = sendButtonTimeoutMs(attachmentNames, attachmentTimeoutMs);
622
560
  const deadline = Date.now() + timeoutMs;
561
+ let previousPoint;
562
+ let activated = false;
563
+ let foundButton = false;
564
+ let attachmentMenuChecked = !needAttachment;
623
565
  while (Date.now() < deadline) {
624
566
  if (needAttachment) {
625
567
  const ready = await Runtime.evaluate({
@@ -631,13 +573,45 @@ async function attemptSendButton(Runtime, Input, _logger, attachmentNames, attac
631
573
  continue;
632
574
  }
633
575
  }
576
+ if (!attachmentMenuChecked) {
577
+ await activatePageForTrustedInput(Page, logger);
578
+ activated = true;
579
+ await dismissOpenComposerPlusMenu(Runtime, Input, logger);
580
+ attachmentMenuChecked = true;
581
+ previousPoint = undefined;
582
+ await delay(150);
583
+ continue;
584
+ }
585
+ if (needAttachment) {
586
+ if (await activateExactAttachmentSendButton(Runtime, Input, logger, attachmentNavigationUrl, attachmentNames)) {
587
+ return true;
588
+ }
589
+ previousPoint = undefined;
590
+ await delay(100);
591
+ continue;
592
+ }
593
+ // Activating the target can trigger a final compositor/layout pass. Do it before
594
+ // measuring the button so the trusted click never uses stale coordinates.
595
+ if (!activated) {
596
+ await activatePageForTrustedInput(Page, logger);
597
+ activated = true;
598
+ }
634
599
  const { result } = await Runtime.evaluate({ expression: script, returnByValue: true });
635
600
  const value = result.value;
636
601
  const status = typeof value === "string" ? value : value?.status;
602
+ if (status === "point" || status === "settling")
603
+ foundButton = true;
637
604
  if (status === "point" &&
638
605
  typeof value === "object" &&
639
606
  typeof value.x === "number" &&
640
607
  typeof value.y === "number") {
608
+ if (!previousPoint ||
609
+ Math.abs(previousPoint.x - value.x) > 1 ||
610
+ Math.abs(previousPoint.y - value.y) > 1) {
611
+ previousPoint = { x: value.x, y: value.y };
612
+ await delay(150);
613
+ continue;
614
+ }
641
615
  await clickTrustedPoint(Runtime, Input, value.x, value.y);
642
616
  return true;
643
617
  }
@@ -647,18 +621,223 @@ async function attemptSendButton(Runtime, Input, _logger, attachmentNames, attac
647
621
  if (status === "missing") {
648
622
  break;
649
623
  }
624
+ previousPoint = undefined;
650
625
  await delay(100);
651
626
  }
652
627
  if (Array.isArray(attachmentNames) && attachmentNames.length > 0) {
653
- throw new BrowserAutomationError(`Attachments never reached a clickable send button after ${Math.ceil(timeoutMs / 1000)}s; tune --browser-attachment-timeout.`, {
628
+ throw new BrowserAutomationError(`Attachments never reached the exact ready send button after ${Math.ceil(timeoutMs / 1000)}s; tune --browser-attachment-timeout.`, {
654
629
  stage: "submit-prompt",
655
630
  code: "attachment-send-not-ready",
656
631
  attachmentNames,
657
632
  timeoutMs,
658
633
  });
659
634
  }
635
+ if (foundButton) {
636
+ throw new BrowserAutomationError("Send button did not settle before input timeout.", {
637
+ stage: "submit-prompt",
638
+ code: "send-button-unstable",
639
+ });
640
+ }
660
641
  return false;
661
642
  }
643
+ async function activateExactAttachmentSendButton(Runtime, Input, logger, attachmentNavigationUrl, attachmentNames = []) {
644
+ const probe = await Runtime.evaluate({
645
+ expression: `(() => {
646
+ const button = document.querySelector('button[data-testid="send-button"]');
647
+ if (!(button instanceof HTMLElement)) return { status: 'absent' };
648
+ const rect = button.getBoundingClientRect();
649
+ const style = window.getComputedStyle(button);
650
+ const enabled = !button.hasAttribute('disabled') &&
651
+ button.getAttribute('aria-disabled') !== 'true' &&
652
+ button.getAttribute('data-disabled') !== 'true' &&
653
+ style.pointerEvents !== 'none' &&
654
+ style.display !== 'none' &&
655
+ style.visibility !== 'hidden';
656
+ if (rect.width <= 0 || rect.height <= 0 || !enabled) return { status: 'unavailable' };
657
+ button.focus({ preventScroll: true });
658
+ return { status: document.activeElement === button ? 'focused' : 'unavailable' };
659
+ })()`,
660
+ returnByValue: true,
661
+ });
662
+ const status = probe?.result?.value?.status;
663
+ if (status !== "focused") {
664
+ return false;
665
+ }
666
+ if (!Input || typeof Input.dispatchKeyEvent !== "function") {
667
+ throw new BrowserAutomationError("ChatGPT's attachment send button is ready but Oracle cannot activate it safely.", {
668
+ stage: "submit-prompt",
669
+ code: "attachment-send-keyboard-unavailable",
670
+ });
671
+ }
672
+ const expectedIdentity = composerNavigationIdentityFromUrl(attachmentNavigationUrl ?? "");
673
+ if (!expectedIdentity || !attachmentNavigationUrl) {
674
+ throw new BrowserAutomationError("Attachment page identity is unavailable before dispatch.", {
675
+ stage: "submit-prompt",
676
+ code: "attachment-navigation-identity-unavailable",
677
+ });
678
+ }
679
+ const guardId = randomUUID();
680
+ // Recheck at event delivery too: a SPA can navigate after the CDP probe returns.
681
+ let delivery;
682
+ try {
683
+ const boundary = await Runtime.evaluate({
684
+ expression: `(() => {
685
+ const button = document.querySelector('button[data-testid="send-button"]');
686
+ const check = () => {
687
+ const navigation = ${buildComposerNavigationValidationExpression(attachmentNavigationUrl)};
688
+ const rect = button?.getBoundingClientRect();
689
+ const style = button ? window.getComputedStyle(button) : null;
690
+ return {
691
+ ...navigation,
692
+ focused: button instanceof HTMLElement && document.activeElement === button &&
693
+ document.querySelector('button[data-testid="send-button"]') === button &&
694
+ !button.hasAttribute('disabled') && button.getAttribute('aria-disabled') !== 'true' &&
695
+ button.getAttribute('data-disabled') !== 'true' && rect.width > 0 && rect.height > 0 &&
696
+ style.display !== 'none' && style.visibility !== 'hidden' && style.pointerEvents !== 'none',
697
+ attachmentsReady: ${buildAttachmentReadyExpression(attachmentNames)},
698
+ };
699
+ };
700
+ const safeCheck = () => {
701
+ try { return check(); } catch { return { currentUrl: location.href, contextMatches: false, focused: false, attachmentsReady: false }; }
702
+ };
703
+ const snapshot = safeCheck();
704
+ if (!snapshot.contextMatches || !snapshot.focused || !snapshot.attachmentsReady) return snapshot;
705
+ const existing = window.__oracleAttachmentDispatchGuard;
706
+ if (existing && !existing.finished) return { ...snapshot, focused: false };
707
+ existing?.cleanup?.();
708
+ const guard = { id: ${JSON.stringify(guardId)}, sawKeyDown: false, blocked: null, finished: false };
709
+ const detach = () => {
710
+ window.removeEventListener('keydown', onKeyDown, true);
711
+ window.removeEventListener('keyup', onKeyUp, true);
712
+ window.removeEventListener('click', onClick, true);
713
+ guard.finished = true;
714
+ };
715
+ const cancel = (event, state) => {
716
+ event.preventDefault(); event.stopImmediatePropagation(); guard.blocked = state;
717
+ };
718
+ const onKeyDown = event => {
719
+ if (event.key !== 'Enter' || !event.isTrusted) return;
720
+ guard.sawKeyDown = true;
721
+ const state = safeCheck();
722
+ if (!state.contextMatches || !state.focused || !state.attachmentsReady) cancel(event, state);
723
+ };
724
+ const onKeyUp = event => {
725
+ if (event.key === 'Enter' && event.isTrusted && guard.blocked) { cancel(event, guard.blocked); detach(); }
726
+ };
727
+ const onClick = event => {
728
+ if (!guard.sawKeyDown || !(event.target instanceof Node) ||
729
+ !(button.contains(event.target) || event.target instanceof Element && event.target.closest('button[data-testid="send-button"]'))) return;
730
+ const state = safeCheck();
731
+ if (guard.blocked || !state.contextMatches || !state.focused || !state.attachmentsReady) cancel(event, guard.blocked ?? state);
732
+ detach();
733
+ };
734
+ guard.cleanup = () => { detach(); if (window.__oracleAttachmentDispatchGuard === guard) delete window.__oracleAttachmentDispatchGuard; };
735
+ window.__oracleAttachmentDispatchGuard = guard;
736
+ window.addEventListener('keydown', onKeyDown, true);
737
+ window.addEventListener('keyup', onKeyUp, true);
738
+ window.addEventListener('click', onClick, true);
739
+ return snapshot;
740
+ })()`,
741
+ returnByValue: true,
742
+ });
743
+ const snapshot = boundary?.result?.value;
744
+ assertComposerNavigationSnapshot(attachmentNavigationUrl, snapshot);
745
+ if (snapshot?.focused !== true || snapshot.attachmentsReady !== true)
746
+ return false;
747
+ await Input.dispatchKeyEvent({
748
+ type: "keyDown",
749
+ ...ENTER_KEY_EVENT,
750
+ text: ENTER_KEY_TEXT,
751
+ unmodifiedText: ENTER_KEY_TEXT,
752
+ });
753
+ await Input.dispatchKeyEvent({ type: "keyUp", ...ENTER_KEY_EVENT });
754
+ }
755
+ finally {
756
+ const result = await withoutBrowserCancellation(() => Runtime.evaluate({
757
+ expression: `(() => {
758
+ const guard = window.__oracleAttachmentDispatchGuard;
759
+ if (guard?.id !== ${JSON.stringify(guardId)}) return null;
760
+ const summary = { sawKeyDown: guard.sawKeyDown, blocked: guard.blocked };
761
+ guard.cleanup(); return summary;
762
+ })()`,
763
+ returnByValue: true,
764
+ }).catch(() => undefined));
765
+ delivery = result?.result?.value;
766
+ }
767
+ if (delivery?.blocked) {
768
+ assertComposerNavigationSnapshot(attachmentNavigationUrl, delivery.blocked);
769
+ throw new BrowserAutomationError("Attachment focus or upload readiness changed at dispatch; the send was stopped.", {
770
+ stage: "submit-prompt",
771
+ code: "attachment-send-not-ready",
772
+ });
773
+ }
774
+ if (delivery?.sawKeyDown !== true) {
775
+ throw new BrowserAutomationError("Attachment input delivery could not be verified; do not retry automatically.", {
776
+ stage: "submit-prompt",
777
+ code: "attachment-send-ambiguous",
778
+ });
779
+ }
780
+ logger?.("Activated exact attachment send button via keyboard");
781
+ return true;
782
+ }
783
+ async function dismissOpenComposerPlusMenu(Runtime, Input, logger) {
784
+ const probe = await Runtime.evaluate({
785
+ expression: `(() => {
786
+ const selectors = ['#composer-plus-btn', 'button[data-testid="composer-plus-btn"]'];
787
+ const button = selectors
788
+ .map(selector => document.querySelector(selector))
789
+ .find(node => node instanceof HTMLElement && node.getBoundingClientRect().width > 0 && node.getBoundingClientRect().height > 0);
790
+ if (!(button instanceof HTMLElement)) return { status: 'absent' };
791
+ if (button.getAttribute('aria-expanded') !== 'true') return { status: 'closed' };
792
+ button.focus({ preventScroll: true });
793
+ return { status: 'open', focused: document.activeElement === button };
794
+ })()`,
795
+ returnByValue: true,
796
+ });
797
+ const status = probe?.result?.value?.status;
798
+ if (status !== "open") {
799
+ return false;
800
+ }
801
+ if (!Input || typeof Input.dispatchKeyEvent !== "function") {
802
+ throw new BrowserAutomationError("ChatGPT's attachment menu is still open and Oracle cannot dismiss it safely before sending.", {
803
+ stage: "submit-prompt",
804
+ code: "attachment-menu-dismiss-unavailable",
805
+ });
806
+ }
807
+ await Input.dispatchKeyEvent({ type: "keyDown", ...ESCAPE_KEY_EVENT });
808
+ await Input.dispatchKeyEvent({ type: "keyUp", ...ESCAPE_KEY_EVENT });
809
+ const deadline = Date.now() + 2_000;
810
+ while (Date.now() < deadline) {
811
+ const state = await Runtime.evaluate({
812
+ expression: `(() => {
813
+ const selectors = ['#composer-plus-btn', 'button[data-testid="composer-plus-btn"]'];
814
+ return !selectors.some(selector =>
815
+ document.querySelector(selector)?.getAttribute?.('aria-expanded') === 'true'
816
+ );
817
+ })()`,
818
+ returnByValue: true,
819
+ });
820
+ if (state?.result?.value === true) {
821
+ logger?.("Closed attachment menu before send");
822
+ return true;
823
+ }
824
+ await delay(50);
825
+ }
826
+ throw new BrowserAutomationError("ChatGPT's attachment menu did not close before send; prompt was left staged.", {
827
+ stage: "submit-prompt",
828
+ code: "attachment-menu-stuck-open",
829
+ });
830
+ }
831
+ async function activatePageForTrustedInput(Page, logger) {
832
+ if (!Page || typeof Page.bringToFront !== "function") {
833
+ return;
834
+ }
835
+ await Page.bringToFront().catch((error) => {
836
+ const message = error instanceof Error ? error.message : String(error);
837
+ logger?.(`[browser] Could not bring ChatGPT target to front before trusted click: ${message}`);
838
+ });
839
+ await delay(150);
840
+ }
662
841
  async function clickTrustedPoint(Runtime, Input, x, y) {
663
842
  if (Input && typeof Input.dispatchMouseEvent === "function") {
664
843
  await Input.dispatchMouseEvent({ type: "mouseMoved", x, y });
@@ -814,14 +993,8 @@ async function verifyPromptCommitted(Runtime, prompt, timeoutMs, logger, baselin
814
993
  logger(`Prompt commit check failed; latest state: ${probe ? JSON.stringify(probe) : "unavailable"}`);
815
994
  await logDomFailure(Runtime, logger, "prompt-commit");
816
995
  }
817
- if (prompt.trim().length >= 50_000) {
818
- throw new BrowserAutomationError("Prompt did not appear in conversation before timeout (likely too large).", {
819
- stage: "submit-prompt",
820
- code: "prompt-too-large",
821
- promptLength: prompt.trim().length,
822
- timeoutMs,
823
- });
824
- }
996
+ // Input was already dispatched. Even a large staged prompt may commit late;
997
+ // classifying this as truncation would let the runner submit an upload fallback.
825
998
  throw new BrowserAutomationError("Prompt did not appear in conversation before timeout (send may have failed)", {
826
999
  stage: "submit-prompt",
827
1000
  code: "prompt-commit-timeout",
@@ -849,7 +1022,9 @@ function summarizeCommitProbe(probe) {
849
1022
  }
850
1023
  // biome-ignore lint/style/useNamingConvention: test-only export used in vitest suite
851
1024
  export const __test__ = {
1025
+ activateExactAttachmentSendButton,
852
1026
  attemptSendButton,
1027
+ dismissOpenComposerPlusMenu,
853
1028
  sendButtonTimeoutMs,
854
1029
  verifyPromptCommitted,
855
1030
  };