@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,174 @@
1
+ import { withoutBrowserCancellation } from "../cancellation.js";
1
2
  import path from "node:path";
3
+ import { randomUUID } from "node:crypto";
2
4
  import { INPUT_SELECTORS, SEND_BUTTON_SELECTORS, UPLOAD_STATUS_SELECTORS } from "../constants.js";
3
5
  import { buildConversationTurnListExpression } from "../conversationTurns.js";
4
6
  import { delay } from "../utils.js";
5
7
  import { logDomFailure } from "../domDebug.js";
8
+ import { BrowserAutomationError } from "../../oracle/errors.js";
6
9
  import { transferAttachmentViaDataTransfer } from "./attachmentDataTransfer.js";
10
+ import { buildAttachmentProgressExpression } from "./attachmentProgress.js";
11
+ import { assertComposerPlusStayedInPlace, assertComposerNavigationSnapshot, buildComposerNavigationValidationExpression, buildComposerNavigationProbeExpression, withGuardedFileInput, dispatchGuardedFileInputEvents, clearGuardedFileInput, } from "./attachmentContext.js";
12
+ export { captureComposerNavigationUrl, assertComposerPlusStayedInPlace, buildComposerNavigationProbeExpression, assertComposerNavigationSnapshot, composerNavigationIdentityFromUrl, } from "./attachmentContext.js";
13
+ import { beginAttachmentEvidence, confirmAttachmentEvidence, buildAttachmentEvidenceExpression, } from "./attachmentEvidence.js";
14
+ export function buildAttachmentNamePattern(expectedName, allowStemOnly = false) {
15
+ const baseName = expectedName.split("/").pop()?.split("\\").pop() ?? expectedName;
16
+ const normalizedExpected = baseName.toLowerCase().replace(/\s+/g, " ").trim();
17
+ if (!normalizedExpected)
18
+ return null;
19
+ const escape = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
20
+ // Unicode letters, numbers, and combining marks continue a filename, not its label.
21
+ const filenameChars = String.raw `\p{L}\p{N}\p{M}._-`;
22
+ const boundary = `(?=$|[^${filenameChars}])`;
23
+ const extensionIndex = normalizedExpected.lastIndexOf(".");
24
+ let name = `${escape(normalizedExpected)}${boundary}`;
25
+ if (extensionIndex > 0 && extensionIndex < normalizedExpected.length - 1) {
26
+ const stem = normalizedExpected.slice(0, extensionIndex);
27
+ const extension = escape(normalizedExpected.slice(extensionIndex));
28
+ name = `${escape(stem)}(?:\\s*\\([0-9]+(?:-[0-9]+)*\\))?${extension}${boundary}`;
29
+ if (allowStemOnly && stem.length >= 6) {
30
+ // A visible extension or collision suffix must not fall through to a bare stem.
31
+ name = `(?:${name}|${escape(stem)}(?!\\s*[.(])${boundary})`;
32
+ }
33
+ }
34
+ return new RegExp(`(?:^|[^${filenameChars}])${name}`, "iu");
35
+ }
36
+ export async function activateComposerPlus(runtime, input, navigationUrl) {
37
+ const guardId = randomUUID();
38
+ const probe = await Promise.resolve(runtime.evaluate({
39
+ expression: `(() => {
40
+ const navigation = ${navigationUrl ? buildComposerNavigationValidationExpression(navigationUrl) : buildComposerNavigationProbeExpression()};
41
+ const startUrl = navigation.currentUrl;
42
+ if (navigation.contextMatches === false) return { status: 'context-changed', startUrl, navigation };
43
+ if (navigation.workSelected) {
44
+ return { status: 'work-selected', startUrl };
45
+ }
46
+ if (navigation.modeUnverified) return { status: 'mode-unverified', startUrl };
47
+ const selectors = ['#composer-plus-btn', 'button[data-testid="composer-plus-btn"]'];
48
+ for (const selector of selectors) {
49
+ const node = document.querySelector(selector);
50
+ if (!(node instanceof HTMLElement)) continue;
51
+ const rect = node.getBoundingClientRect();
52
+ if (rect.width <= 0 || rect.height <= 0) continue;
53
+ const nodes = window.__oracleAttachmentPlusNodes ??= Object.create(null);
54
+ nodes[${JSON.stringify(guardId)}] = node;
55
+ node.focus({ preventScroll: true });
56
+ return { status: 'focused', startUrl, focused: document.activeElement === node };
57
+ }
58
+ return { status: 'missing', startUrl };
59
+ })()`,
60
+ returnByValue: true,
61
+ })).then((result) => result?.result?.value);
62
+ const startUrl = navigationUrl ?? (typeof probe?.startUrl === "string" ? probe.startUrl : "");
63
+ if (probe?.status === "context-changed") {
64
+ assertComposerNavigationSnapshot(startUrl, probe.navigation);
65
+ }
66
+ if (probe?.status === "mode-unverified") {
67
+ throw new BrowserAutomationError("ChatGPT mode could not be verified; attachment preparation was stopped.", {
68
+ stage: "upload-attachment",
69
+ code: "attachment-control-mode-unverified",
70
+ startUrl,
71
+ });
72
+ }
73
+ if (probe?.status === "work-selected") {
74
+ throw new BrowserAutomationError("Oracle refused to open the attachment menu because ChatGPT is in Work mode.", {
75
+ stage: "upload-attachment",
76
+ code: "attachment-control-work-mode",
77
+ startUrl,
78
+ });
79
+ }
80
+ if (probe?.status !== "focused") {
81
+ return { method: "unavailable", startUrl };
82
+ }
83
+ const trusted = Boolean(input && typeof input.dispatchKeyEvent === "function");
84
+ let delivery;
85
+ try {
86
+ const prepared = await runtime.evaluate({
87
+ expression: `(() => {
88
+ const button = window.__oracleAttachmentPlusNodes?.[${JSON.stringify(guardId)}];
89
+ const check = () => {
90
+ const navigation = ${buildComposerNavigationValidationExpression(startUrl)};
91
+ const rect = button?.getBoundingClientRect();
92
+ return { ...navigation, focused: button instanceof HTMLElement && button.isConnected &&
93
+ document.activeElement === button && document.querySelector('#composer-plus-btn, button[data-testid="composer-plus-btn"]') === button &&
94
+ !button.hasAttribute('disabled') && button.getAttribute('aria-disabled') !== 'true' &&
95
+ rect.width > 0 && rect.height > 0 };
96
+ };
97
+ const safeCheck = () => { try { return check(); } catch { return { currentUrl: location.href, contextMatches: false, focused: false }; } };
98
+ const guard = { sawKeyDown: false, clicked: false, blocked: null };
99
+ const cancel = (event, state) => { event.preventDefault(); event.stopImmediatePropagation(); guard.blocked = state; };
100
+ const onKey = event => {
101
+ if (event.key !== 'Enter' || !event.isTrusted) return;
102
+ if (event.type === 'keyup') { if (guard.blocked) cancel(event, guard.blocked); return; }
103
+ guard.sawKeyDown = true;
104
+ const state = safeCheck();
105
+ if (guard.blocked || !state.contextMatches || !state.focused) cancel(event, guard.blocked ?? state);
106
+ };
107
+ const onClick = event => {
108
+ if (!guard.sawKeyDown && ${trusted}) return;
109
+ const state = safeCheck();
110
+ if (guard.blocked || !state.contextMatches || !state.focused || !(event.target instanceof Node) || !button.contains(event.target)) cancel(event, guard.blocked ?? state);
111
+ else guard.clicked = true;
112
+ };
113
+ guard.cleanup = () => {
114
+ window.removeEventListener('keydown', onKey, true);
115
+ window.removeEventListener('keyup', onKey, true);
116
+ window.removeEventListener('click', onClick, true);
117
+ };
118
+ const guards = window.__oracleAttachmentPlusGuards ??= Object.create(null);
119
+ guards[${JSON.stringify(guardId)}] = guard;
120
+ const state = safeCheck();
121
+ if (!state.contextMatches || !state.focused) { guard.blocked = state; return state; }
122
+ window.addEventListener('keydown', onKey, true);
123
+ window.addEventListener('keyup', onKey, true);
124
+ window.addEventListener('click', onClick, true);
125
+ if (!${trusted}) button.click();
126
+ return state;
127
+ })()`,
128
+ returnByValue: true,
129
+ });
130
+ const state = prepared.result?.value;
131
+ assertComposerNavigationSnapshot(startUrl, state);
132
+ if (!state?.focused)
133
+ throw new BrowserAutomationError("Attachment menu focus changed before activation; upload was stopped.", { stage: "upload-attachment", code: "attachment-plus-not-ready" });
134
+ if (trusted && input) {
135
+ const enter = {
136
+ key: "Enter",
137
+ code: "Enter",
138
+ windowsVirtualKeyCode: 13,
139
+ nativeVirtualKeyCode: 13,
140
+ };
141
+ // A transport failure can follow a successful dispatch; never activate twice.
142
+ await input.dispatchKeyEvent({ type: "keyDown", ...enter, text: "\r", unmodifiedText: "\r" });
143
+ await input.dispatchKeyEvent({ type: "keyUp", ...enter });
144
+ }
145
+ }
146
+ finally {
147
+ const observed = await withoutBrowserCancellation(() => runtime
148
+ .evaluate({
149
+ expression: `(() => {
150
+ const guards = window.__oracleAttachmentPlusGuards;
151
+ const guard = guards?.[${JSON.stringify(guardId)}];
152
+ const nodes = window.__oracleAttachmentPlusNodes;
153
+ if (nodes) delete nodes[${JSON.stringify(guardId)}];
154
+ if (!guard) return null;
155
+ const summary = { sawKeyDown: guard.sawKeyDown, clicked: guard.clicked, blocked: guard.blocked };
156
+ guard.cleanup(); delete guards[${JSON.stringify(guardId)}]; return summary;
157
+ })()`,
158
+ returnByValue: true,
159
+ })
160
+ .catch(() => undefined));
161
+ delivery = observed?.result?.value;
162
+ }
163
+ if (delivery?.blocked) {
164
+ assertComposerNavigationSnapshot(startUrl, delivery.blocked);
165
+ throw new BrowserAutomationError("Attachment menu focus changed at activation; upload was stopped.", { stage: "upload-attachment", code: "attachment-plus-not-ready" });
166
+ }
167
+ if (!delivery || (trusted ? !delivery.sawKeyDown : !delivery.clicked)) {
168
+ throw new BrowserAutomationError("Attachment menu activation could not be verified; do not retry automatically.", { stage: "upload-attachment", code: "attachment-plus-ambiguous" });
169
+ }
170
+ return { method: trusted ? "trusted-keyboard" : "synthetic", startUrl };
171
+ }
7
172
  export async function uploadAttachmentFile(deps, attachment, logger, options) {
8
173
  const { runtime, dom, input } = deps;
9
174
  if (!dom) {
@@ -13,17 +178,19 @@ export async function uploadAttachmentFile(deps, attachment, logger, options) {
13
178
  ? Math.max(0, Math.floor(options.expectedCount))
14
179
  : 0;
15
180
  const readAttachmentSignals = async (name) => {
181
+ const namePattern = buildAttachmentNamePattern(name, true);
16
182
  const check = await runtime.evaluate({
17
183
  expression: `(() => {
18
184
  const expected = ${JSON.stringify(name)};
19
185
  const normalizedExpected = String(expected || '').toLowerCase().replace(/\\s+/g, ' ').trim();
20
186
  const expectedNoExt = normalizedExpected.replace(/\\.[a-z0-9]{1,10}$/i, '');
187
+ const namePatternSource = ${JSON.stringify(namePattern?.source ?? "")};
188
+ const namePattern = namePatternSource ? new RegExp(namePatternSource, 'iu') : null;
21
189
  const normalize = (value) => String(value || '').toLowerCase().replace(/\\s+/g, ' ').trim();
22
190
  const matchesExpected = (value) => {
23
191
  const text = normalize(value);
24
192
  if (!text) return false;
25
- if (text.includes(normalizedExpected)) return true;
26
- if (expectedNoExt.length >= 6 && text.includes(expectedNoExt)) return true;
193
+ if (namePattern?.test(text)) return true;
27
194
  if (text.includes('…') || text.includes('...')) {
28
195
  const marker = text.includes('…') ? '…' : '...';
29
196
  const [prefixRaw, suffixRaw] = text.split(marker);
@@ -237,22 +404,23 @@ export async function uploadAttachmentFile(deps, attachment, logger, options) {
237
404
  '[aria-label*="attachment"]',
238
405
  '[title*="attachment"]',
239
406
  ].join(',');
240
- const fileCountScope = scope ?? root ?? document.body;
407
+ const fileCountScope =
408
+ scope && scope !== document.body
409
+ ? scope
410
+ : root && root !== document.body
411
+ ? root
412
+ : null;
241
413
  const localFileNodes = fileCountScope
242
414
  ? Array.from(fileCountScope.querySelectorAll(fileCountSelectors))
243
415
  : [];
244
- const globalFileNodes = Array.from(document.querySelectorAll(fileCountSelectors));
245
- let fileCount = collectFileCount(localFileNodes);
246
- if (!fileCount && globalFileNodes.length > 0) {
247
- fileCount = collectFileCount(globalFileNodes);
248
- }
249
- const hasAttachmentSignal = localCandidates.length > 0 || inputCount > 0 || fileCount > 0 || uploading;
416
+ const fileCount = collectFileCount(localFileNodes);
417
+ const hasAttachmentSignal = localCandidates.length > 0 || inputCount > 0 || uploading;
250
418
  if (!uiMatch && rootTextRaw && hasAttachmentSignal && matchesExpected(rootTextRaw)) {
251
419
  uiMatch = true;
252
420
  }
253
421
 
254
422
  return {
255
- ui: uiMatch,
423
+ ui: uiMatch || (${buildAttachmentEvidenceExpression([name])})[0],
256
424
  input: inputMatch,
257
425
  inputCount,
258
426
  chipCount: localCandidates.length,
@@ -274,87 +442,24 @@ export async function uploadAttachmentFile(deps, attachment, logger, options) {
274
442
  fileCount: typeof value?.fileCount === "number" ? value?.fileCount : 0,
275
443
  };
276
444
  };
277
- // New ChatGPT UI hides the real file input behind a composer "+" menu; click it pre-emptively.
278
- // Learned: synthetic `.click()` is sometimes ignored (isTrusted checks). Prefer a CDP mouse click when possible.
279
- const clickPlusTrusted = async () => {
280
- if (!input || typeof input.dispatchMouseEvent !== "function")
281
- return false;
282
- const locate = await runtime
283
- .evaluate({
284
- expression: `(() => {
285
- const selectors = [
286
- '#composer-plus-btn',
287
- 'button[data-testid="composer-plus-btn"]',
288
- '[data-testid*="plus"]',
289
- 'button[aria-label*="add"]',
290
- 'button[aria-label*="attachment"]',
291
- 'button[aria-label*="file"]',
292
- ];
293
- for (const selector of selectors) {
294
- const el = document.querySelector(selector);
295
- if (!(el instanceof HTMLElement)) continue;
296
- const rect = el.getBoundingClientRect();
297
- if (rect.width <= 0 || rect.height <= 0) continue;
298
- el.scrollIntoView({ block: 'center', inline: 'center' });
299
- const nextRect = el.getBoundingClientRect();
300
- return { ok: true, x: nextRect.left + nextRect.width / 2, y: nextRect.top + nextRect.height / 2 };
301
- }
302
- return { ok: false };
303
- })()`,
304
- returnByValue: true,
305
- })
306
- .then((res) => res?.result?.value)
307
- .catch(() => undefined);
308
- if (!locate?.ok || typeof locate.x !== "number" || typeof locate.y !== "number")
309
- return false;
310
- const x = locate.x;
311
- const y = locate.y;
312
- await input.dispatchMouseEvent({ type: "mouseMoved", x, y });
313
- await input.dispatchMouseEvent({ type: "mousePressed", x, y, button: "left", clickCount: 1 });
314
- await input.dispatchMouseEvent({ type: "mouseReleased", x, y, button: "left", clickCount: 1 });
315
- return true;
316
- };
317
- const clickedTrusted = await clickPlusTrusted().catch(() => false);
318
- if (!clickedTrusted) {
319
- await Promise.resolve(runtime.evaluate({
320
- expression: `(() => {
321
- const selectors = [
322
- '#composer-plus-btn',
323
- 'button[data-testid="composer-plus-btn"]',
324
- '[data-testid*="plus"]',
325
- 'button[aria-label*="add"]',
326
- 'button[aria-label*="attachment"]',
327
- 'button[aria-label*="file"]',
328
- ];
329
- for (const selector of selectors) {
330
- const el = document.querySelector(selector);
331
- if (el instanceof HTMLElement) {
332
- el.click();
333
- return true;
334
- }
335
- }
336
- return false;
337
- })()`,
338
- returnByValue: true,
339
- })).catch(() => undefined);
340
- }
445
+ // Work suggestions sit close to the composer. Activate only the exact plus control and
446
+ // verify that the page did not enter Work or another conversation before touching files.
447
+ const plusActivation = await activateComposerPlus(runtime, input, options?.navigationUrl);
448
+ const navigationUrl = options?.navigationUrl ?? plusActivation.startUrl;
449
+ const assertNavigation = () => assertComposerPlusStayedInPlace(runtime, navigationUrl);
341
450
  await delay(350);
451
+ await assertNavigation();
342
452
  const normalizeForMatch = (value) => String(value || "")
343
453
  .toLowerCase()
344
454
  .replace(/\s+/g, " ")
345
455
  .trim();
346
456
  const expectedName = path.basename(attachment.path);
347
- const expectedNameLower = normalizeForMatch(expectedName);
348
- const expectedNameNoExt = expectedNameLower.replace(/\.[a-z0-9]{1,10}$/i, "");
457
+ const expectedNamePattern = buildAttachmentNamePattern(expectedName, true);
349
458
  const matchesExpectedName = (value) => {
350
459
  const normalized = normalizeForMatch(value);
351
460
  if (!normalized)
352
461
  return false;
353
- if (normalized.includes(expectedNameLower))
354
- return true;
355
- if (expectedNameNoExt.length >= 6 && normalized.includes(expectedNameNoExt))
356
- return true;
357
- return false;
462
+ return expectedNamePattern?.test(normalized) ?? false;
358
463
  };
359
464
  const isImageAttachment = /\.(png|jpe?g|gif|webp|bmp|svg|heic|heif)$/i.test(expectedName);
360
465
  const attachmentUiTimeoutMs = 25_000;
@@ -368,20 +473,10 @@ export async function uploadAttachmentFile(deps, attachment, logger, options) {
368
473
  const isExpectedSatisfied = (signals) => {
369
474
  if (expectedCount <= 0)
370
475
  return false;
371
- const fileCount = typeof signals.fileCount === "number" ? signals.fileCount : 0;
372
476
  const chipCount = typeof signals.chipCount === "number" ? signals.chipCount : 0;
373
- if (fileCount >= expectedCount)
374
- return true;
375
477
  return Boolean(signals.ui && chipCount >= expectedCount);
376
478
  };
377
- const initialInputSatisfied = expectedCount > 0 ? initialSignals.inputCount >= expectedCount : Boolean(initialSignals.input);
378
- if (expectedCount > 0 &&
379
- (initialSignals.fileCount >= expectedCount || initialSignals.inputCount >= expectedCount)) {
380
- const satisfiedCount = Math.max(initialSignals.fileCount, initialSignals.inputCount);
381
- logger(`Attachment already present: composer shows ${satisfiedCount} file${satisfiedCount === 1 ? "" : "s"}`);
382
- return true;
383
- }
384
- if (initialInputSatisfied || initialSignals.input) {
479
+ if (initialSignals.input) {
385
480
  logger(`Attachment already queued in file input: ${path.basename(attachment.path)}`);
386
481
  return true;
387
482
  }
@@ -557,15 +652,16 @@ export async function uploadAttachmentFile(deps, attachment, logger, options) {
557
652
  '[aria-label*="attachment"]',
558
653
  '[title*="attachment"]',
559
654
  ].join(',');
560
- const fileCountScope = scope ?? root ?? document.body;
655
+ const fileCountScope =
656
+ scope && scope !== document.body
657
+ ? scope
658
+ : root && root !== document.body
659
+ ? root
660
+ : null;
561
661
  const localFileNodes = fileCountScope
562
662
  ? Array.from(fileCountScope.querySelectorAll(fileCountSelectors))
563
663
  : [];
564
- const globalFileNodes = Array.from(document.querySelectorAll(fileCountSelectors));
565
- let baselineFileCount = collectFileCount(localFileNodes);
566
- if (!baselineFileCount && globalFileNodes.length > 0) {
567
- baselineFileCount = collectFileCount(globalFileNodes);
568
- }
664
+ const baselineFileCount = collectFileCount(localFileNodes);
569
665
 
570
666
  // Mark candidates with stable indices so we can select them via DOM.querySelector.
571
667
  // Learned: ChatGPT sometimes renders a zero-sized file input that does *not* trigger uploads;
@@ -618,7 +714,6 @@ export async function uploadAttachmentFile(deps, attachment, logger, options) {
618
714
  ? candidateValue.baselineChips
619
715
  : [];
620
716
  const baselineUploading = Boolean(candidateValue?.baselineUploading);
621
- const baselineFileCount = typeof candidateValue?.baselineFileCount === "number" ? candidateValue.baselineFileCount : 0;
622
717
  const baselineInputCount = typeof candidateValue?.baselineInputCount === "number" ? candidateValue.baselineInputCount : 0;
623
718
  const serializeChips = (chips) => chips
624
719
  .map((chip) => [chip.text, chip.aria, chip.title, chip.testid]
@@ -644,7 +739,6 @@ export async function uploadAttachmentFile(deps, attachment, logger, options) {
644
739
  };
645
740
  const hasInputDelta = (signals) => (typeof signals.inputCount === "number" ? signals.inputCount : 0) > baselineInputCount;
646
741
  const hasUploadDelta = (signals) => Boolean(signals.uploading && !baselineUploading);
647
- const hasFileCountDelta = (signals) => (typeof signals.fileCount === "number" ? signals.fileCount : 0) > baselineFileCount;
648
742
  const waitForAttachmentUiSignal = async (timeoutMs) => {
649
743
  const deadline = Date.now() + timeoutMs;
650
744
  let sawInputSignal = false;
@@ -654,7 +748,6 @@ export async function uploadAttachmentFile(deps, attachment, logger, options) {
654
748
  const chipDelta = hasChipDelta(signals);
655
749
  const inputDelta = hasInputDelta(signals) || signals.input;
656
750
  const uploadDelta = hasUploadDelta(signals);
657
- const fileCountDelta = hasFileCountDelta(signals);
658
751
  const expectedSatisfied = isExpectedSatisfied(signals);
659
752
  if (inputDelta) {
660
753
  sawInputSignal = true;
@@ -664,10 +757,9 @@ export async function uploadAttachmentFile(deps, attachment, logger, options) {
664
757
  chipDelta,
665
758
  inputDelta: sawInputSignal,
666
759
  uploadDelta,
667
- fileCountDelta,
668
760
  expectedSatisfied,
669
761
  };
670
- if (signals.ui || chipDelta || uploadDelta || fileCountDelta || expectedSatisfied) {
762
+ if (signals.ui || chipDelta || uploadDelta || expectedSatisfied) {
671
763
  return latest;
672
764
  }
673
765
  await delay(250);
@@ -817,9 +909,26 @@ export async function uploadAttachmentFile(deps, attachment, logger, options) {
817
909
  };
818
910
  })()`;
819
911
  let confirmedAttachment = false;
912
+ let uploadEvidenceConfirmed = false;
913
+ let exactInputNameConfirmed = false;
820
914
  let lastInputNames = [];
821
915
  let lastInputValue = "";
822
916
  let finalSnapshot = null;
917
+ const readComposerSnapshot = async (idx) => {
918
+ const snapshot = await runtime
919
+ .evaluate({ expression: composerSnapshotFor(idx), returnByValue: true })
920
+ .then((res) => res?.result?.value)
921
+ .catch(() => undefined);
922
+ if (!snapshot)
923
+ return undefined;
924
+ return {
925
+ chipCount: Number(snapshot.chipCount ?? 0),
926
+ chips: Array.isArray(snapshot.chips) ? snapshot.chips : [],
927
+ inputNames: Array.isArray(snapshot.inputNames) ? snapshot.inputNames : [],
928
+ composerText: typeof snapshot.composerText === "string" ? snapshot.composerText : "",
929
+ uploading: Boolean(snapshot.uploading),
930
+ };
931
+ };
823
932
  const resolveInputNameCandidates = () => {
824
933
  const snapshot = finalSnapshot;
825
934
  const snapshotNames = snapshot?.inputNames;
@@ -835,8 +944,7 @@ export async function uploadAttachmentFile(deps, attachment, logger, options) {
835
944
  if (queuedSignals.ui ||
836
945
  isExpectedSatisfied(queuedSignals) ||
837
946
  hasChipDelta(queuedSignals) ||
838
- hasUploadDelta(queuedSignals) ||
839
- hasFileCountDelta(queuedSignals)) {
947
+ hasUploadDelta(queuedSignals)) {
840
948
  confirmedAttachment = true;
841
949
  break;
842
950
  }
@@ -856,18 +964,9 @@ export async function uploadAttachmentFile(deps, attachment, logger, options) {
856
964
  const signalResult = await waitForAttachmentUiSignal(waitMs);
857
965
  const postInputSnapshot = await readInputSnapshot(idx);
858
966
  const postInputSignals = inputSignalsFor(baselineInputSnapshot, postInputSnapshot);
859
- const snapshot = await runtime
860
- .evaluate({ expression: composerSnapshotFor(idx), returnByValue: true })
861
- .then((res) => res?.result?.value)
862
- .catch(() => undefined);
967
+ const snapshot = await readComposerSnapshot(idx);
863
968
  if (snapshot) {
864
- finalSnapshot = {
865
- chipCount: Number(snapshot.chipCount ?? 0),
866
- chips: Array.isArray(snapshot.chips) ? snapshot.chips : [],
867
- inputNames: Array.isArray(snapshot.inputNames) ? snapshot.inputNames : [],
868
- composerText: typeof snapshot.composerText === "string" ? snapshot.composerText : "",
869
- uploading: Boolean(snapshot.uploading),
870
- };
969
+ finalSnapshot = snapshot;
871
970
  }
872
971
  lastInputNames = postInputSnapshot.names;
873
972
  lastInputValue = postInputSnapshot.value;
@@ -879,25 +978,23 @@ export async function uploadAttachmentFile(deps, attachment, logger, options) {
879
978
  const inputNameCandidates = resolveInputNameCandidates();
880
979
  const inputHasFile = inputNameCandidates.some((name) => matchesExpectedName(name)) ||
881
980
  (lastInputValue && matchesExpectedName(lastInputValue));
981
+ if (inputHasFile) {
982
+ exactInputNameConfirmed = true;
983
+ }
882
984
  const inputEvidence = immediateInputMatch ||
883
985
  postInputSignals.touched ||
884
986
  Boolean(signalResult?.signals?.input) ||
885
987
  Boolean(signalResult?.inputDelta) ||
886
988
  inputHasFile;
887
989
  const uiDirect = Boolean(signalResult?.signals?.ui) || expectedSatisfied;
888
- const uiDelta = Boolean(signalResult?.chipDelta) ||
889
- Boolean(signalResult?.uploadDelta) ||
890
- Boolean(signalResult?.fileCountDelta);
990
+ const uiDelta = Boolean(signalResult?.chipDelta) || Boolean(signalResult?.uploadDelta);
891
991
  if (uiDirect || (uiDelta && inputEvidence)) {
892
992
  return { status: "ui" };
893
993
  }
894
994
  const postSignals = await readAttachmentSignals(expectedName);
895
995
  if (postSignals.ui ||
896
996
  isExpectedSatisfied(postSignals) ||
897
- ((hasChipDelta(postSignals) ||
898
- hasUploadDelta(postSignals) ||
899
- hasFileCountDelta(postSignals)) &&
900
- inputEvidence)) {
997
+ ((hasChipDelta(postSignals) || hasUploadDelta(postSignals)) && inputEvidence)) {
901
998
  return { status: "ui" };
902
999
  }
903
1000
  const inputSignal = immediateInputMatch ||
@@ -915,16 +1012,24 @@ export async function uploadAttachmentFile(deps, attachment, logger, options) {
915
1012
  const runInputAttempt = async (mode) => {
916
1013
  let immediateInputSnapshot = await readInputSnapshot(idx);
917
1014
  let hasExpectedFile = snapshotMatchesExpected(immediateInputSnapshot);
1015
+ let attemptIssued = false;
1016
+ let evidenceId;
918
1017
  if (!hasExpectedFile) {
1018
+ await assertNavigation();
1019
+ evidenceId = await beginAttachmentEvidence(runtime, expectedName);
919
1020
  if (mode === "set") {
920
- await dom.setFileInputFiles({ nodeId: resultNode.nodeId, files: [attachment.path] });
1021
+ await withGuardedFileInput(runtime, `input[type="file"][data-oracle-upload-idx="${idx}"]`, navigationUrl, () => dom.setFileInputFiles({ nodeId: resultNode.nodeId, files: [attachment.path] }));
1022
+ attemptIssued = true;
921
1023
  }
922
1024
  else {
923
1025
  const selector = `input[type="file"][data-oracle-upload-idx="${idx}"]`;
924
1026
  try {
925
- await transferAttachmentViaDataTransfer(runtime, attachment, selector);
1027
+ await transferAttachmentViaDataTransfer(runtime, attachment, selector, navigationUrl);
1028
+ attemptIssued = true;
926
1029
  }
927
1030
  catch (error) {
1031
+ if (error instanceof BrowserAutomationError)
1032
+ throw error;
928
1033
  logger(`Attachment data transfer failed: ${error?.message ?? String(error)}`);
929
1034
  }
930
1035
  }
@@ -932,6 +1037,9 @@ export async function uploadAttachmentFile(deps, attachment, logger, options) {
932
1037
  hasExpectedFile = snapshotMatchesExpected(immediateInputSnapshot);
933
1038
  }
934
1039
  const immediateSignals = inputSignalsFor(baselineInputSnapshot, immediateInputSnapshot);
1040
+ if (hasExpectedFile || immediateSignals.nameMatch) {
1041
+ exactInputNameConfirmed = true;
1042
+ }
935
1043
  lastInputNames = immediateInputSnapshot.names;
936
1044
  lastInputValue = immediateInputSnapshot.value;
937
1045
  const immediateInputMatch = immediateSignals.touched || hasExpectedFile;
@@ -939,26 +1047,16 @@ export async function uploadAttachmentFile(deps, attachment, logger, options) {
939
1047
  inputConfirmed = true;
940
1048
  }
941
1049
  const signalState = await gatherSignals();
942
- const evaluation = await evaluateSignals(signalState.signalResult, signalState.postInputSignals, immediateInputMatch);
1050
+ if (attemptIssued && evidenceId) {
1051
+ uploadEvidenceConfirmed = await confirmAttachmentEvidence(runtime, evidenceId);
1052
+ }
1053
+ const evaluation = uploadEvidenceConfirmed
1054
+ ? { status: "ui" }
1055
+ : await evaluateSignals(signalState.signalResult, signalState.postInputSignals, immediateInputMatch);
943
1056
  return { evaluation, signalState, immediateInputMatch };
944
1057
  };
945
1058
  const dispatchInputEvents = async () => {
946
- await runtime
947
- .evaluate({
948
- expression: `(() => {
949
- const input = document.querySelector('input[type="file"][data-oracle-upload-idx="${idx}"]');
950
- if (!(input instanceof HTMLInputElement)) return false;
951
- try {
952
- input.dispatchEvent(new Event('input', { bubbles: true }));
953
- input.dispatchEvent(new Event('change', { bubbles: true }));
954
- return true;
955
- } catch {
956
- return false;
957
- }
958
- })()`,
959
- returnByValue: true,
960
- })
961
- .catch(() => undefined);
1059
+ await dispatchGuardedFileInputEvents(runtime, `input[type="file"][data-oracle-upload-idx="${idx}"]`, navigationUrl);
962
1060
  };
963
1061
  let result = await runInputAttempt("set");
964
1062
  if (result.evaluation.status === "ui") {
@@ -980,9 +1078,8 @@ export async function uploadAttachmentFile(deps, attachment, logger, options) {
980
1078
  break;
981
1079
  }
982
1080
  logger("Attachment input set; retrying with data transfer to trigger ChatGPT upload.");
983
- await dom
984
- .setFileInputFiles({ nodeId: resultNode.nodeId, files: [] })
985
- .catch(() => undefined);
1081
+ await assertNavigation();
1082
+ await clearGuardedFileInput(runtime, `input[type="file"][data-oracle-upload-idx="${idx}"]`, navigationUrl);
986
1083
  await delay(150);
987
1084
  result = await runInputAttempt("transfer");
988
1085
  if (result.evaluation.status === "ui") {
@@ -999,8 +1096,7 @@ export async function uploadAttachmentFile(deps, attachment, logger, options) {
999
1096
  if (lateSignals.ui ||
1000
1097
  isExpectedSatisfied(lateSignals) ||
1001
1098
  hasChipDelta(lateSignals) ||
1002
- hasUploadDelta(lateSignals) ||
1003
- hasFileCountDelta(lateSignals)) {
1099
+ hasUploadDelta(lateSignals)) {
1004
1100
  confirmedAttachment = true;
1005
1101
  break;
1006
1102
  }
@@ -1021,28 +1117,40 @@ export async function uploadAttachmentFile(deps, attachment, logger, options) {
1021
1117
  break;
1022
1118
  }
1023
1119
  if (orderIndex < candidateOrder.length - 1) {
1024
- await dom
1025
- .setFileInputFiles({ nodeId: resultNode.nodeId, files: [] })
1026
- .catch(() => undefined);
1120
+ await assertNavigation();
1121
+ await clearGuardedFileInput(runtime, `input[type="file"][data-oracle-upload-idx="${idx}"]`, navigationUrl);
1027
1122
  await delay(150);
1028
1123
  }
1029
1124
  }
1030
1125
  }
1126
+ if (uploadEvidenceConfirmed) {
1127
+ logger("Attachment queued (per-file composer evidence confirmed)");
1128
+ return true;
1129
+ }
1031
1130
  if (confirmedAttachment) {
1032
1131
  const inputNameCandidates = resolveInputNameCandidates();
1033
- const inputHasFile = inputNameCandidates.some((name) => matchesExpectedName(name)) ||
1132
+ const inputHasFile = exactInputNameConfirmed ||
1133
+ inputNameCandidates.some((name) => matchesExpectedName(name)) ||
1034
1134
  (lastInputValue && matchesExpectedName(lastInputValue));
1035
- await waitForAttachmentVisible(runtime, expectedName, attachmentUiTimeoutMs, logger);
1135
+ // Image previews may expose a new composer-local chip without rendering the filename.
1136
+ // Exact current-input evidence plus the UI delta above is already two-source confirmation;
1137
+ // requiring the literal filename again would reject a successfully queued image.
1138
+ if (!inputHasFile) {
1139
+ await waitForAttachmentVisible(runtime, expectedName, attachmentUiTimeoutMs, logger);
1140
+ }
1036
1141
  logger(inputHasFile
1037
1142
  ? "Attachment queued (UI anchored, file input confirmed)"
1038
1143
  : "Attachment queued (UI anchored)");
1039
1144
  return true;
1040
1145
  }
1041
1146
  const inputNameCandidates = resolveInputNameCandidates();
1042
- const inputHasFile = inputNameCandidates.some((name) => matchesExpectedName(name)) ||
1147
+ const inputHasFile = exactInputNameConfirmed ||
1148
+ inputNameCandidates.some((name) => matchesExpectedName(name)) ||
1043
1149
  (lastInputValue && matchesExpectedName(lastInputValue));
1044
1150
  if (await waitForAttachmentAnchored(runtime, expectedName, attachmentUiTimeoutMs)) {
1045
- await waitForAttachmentVisible(runtime, expectedName, attachmentUiTimeoutMs, logger);
1151
+ if (!inputHasFile) {
1152
+ await waitForAttachmentVisible(runtime, expectedName, attachmentUiTimeoutMs, logger);
1153
+ }
1046
1154
  logger(inputHasFile
1047
1155
  ? "Attachment queued (UI anchored, file input confirmed)"
1048
1156
  : "Attachment queued (UI anchored)");
@@ -1058,6 +1166,7 @@ export async function uploadAttachmentFile(deps, attachment, logger, options) {
1058
1166
  export async function clearComposerAttachments(Runtime, timeoutMs, logger) {
1059
1167
  const deadline = Date.now() + Math.max(0, timeoutMs);
1060
1168
  const expression = `(() => {
1169
+ ${buildAttachmentEvidenceExpression([], "clear")};
1061
1170
  const promptSelectors = ${JSON.stringify(INPUT_SELECTORS)};
1062
1171
  const sendSelectors = ${JSON.stringify(SEND_BUTTON_SELECTORS)};
1063
1172
  const findPromptNode = () => {
@@ -1253,19 +1362,7 @@ export async function waitForAttachmentCompletion(Runtime, timeoutMs, expectedNa
1253
1362
  button.getAttribute('data-disabled') === 'true' ||
1254
1363
  window.getComputedStyle(button).pointerEvents === 'none'
1255
1364
  : null;
1256
- const uploadingSelectors = ${JSON.stringify(UPLOAD_STATUS_SELECTORS)};
1257
- const uploading = uploadingSelectors.some((selector) => {
1258
- return Array.from(document.querySelectorAll(selector)).some((node) => {
1259
- const ariaBusy = node.getAttribute?.('aria-busy');
1260
- const dataState = node.getAttribute?.('data-state');
1261
- if (ariaBusy === 'true' || dataState === 'loading' || dataState === 'uploading' || dataState === 'pending') {
1262
- return true;
1263
- }
1264
- // Avoid false positives from user prompts ("upload:") or generic UI copy; only treat explicit progress strings as uploading.
1265
- const text = node.textContent?.toLowerCase?.() ?? '';
1266
- return /\buploading\b/.test(text) || /\bprocessing\b/.test(text);
1267
- });
1268
- });
1365
+ const uploading = ${buildAttachmentProgressExpression("composerRoot")};
1269
1366
  const attachmentChipSelectors = [
1270
1367
  '[data-testid*="chip"]',
1271
1368
  '[data-testid*="attachment"]',
@@ -1375,14 +1472,16 @@ export async function waitForAttachmentCompletion(Runtime, timeoutMs, expectedNa
1375
1472
  }
1376
1473
  return count;
1377
1474
  };
1378
- const localFileCountNodes = composerScope
1379
- ? Array.from(composerScope.querySelectorAll(fileCountSelectors))
1475
+ const fileCountScope =
1476
+ composerScope && composerScope !== document && composerScope !== document.body
1477
+ ? composerScope
1478
+ : null;
1479
+ const localFileCountNodes = fileCountScope
1480
+ ? Array.from(fileCountScope.querySelectorAll(fileCountSelectors))
1380
1481
  : [];
1381
- let fileCount = collectFileCount(localFileCountNodes);
1382
- if (!fileCount) {
1383
- fileCount = collectFileCount(Array.from(document.querySelectorAll(fileCountSelectors)));
1384
- }
1385
- const filesAttached = attachedNames.length > 0 || fileCount > 0;
1482
+ const fileCount = collectFileCount(localFileCountNodes);
1483
+ const confirmed = ${buildAttachmentEvidenceExpression(expectedNormalized)};
1484
+ const filesAttached = attachedNames.length > 0;
1386
1485
  return {
1387
1486
  state: button ? (disabled ? 'disabled' : 'ready') : 'missing',
1388
1487
  uploading,
@@ -1390,6 +1489,7 @@ export async function waitForAttachmentCompletion(Runtime, timeoutMs, expectedNa
1390
1489
  attachedNames,
1391
1490
  inputNames,
1392
1491
  fileCount,
1492
+ confirmedNames: ${JSON.stringify(expectedNormalized)}.filter((_, index) => confirmed[index]),
1393
1493
  };
1394
1494
  })()`;
1395
1495
  while (Date.now() < deadline) {
@@ -1420,6 +1520,14 @@ export async function waitForAttachmentCompletion(Runtime, timeoutMs, expectedNa
1420
1520
  })}`);
1421
1521
  }
1422
1522
  }
1523
+ if (value.uploading) {
1524
+ attachmentMatchSince = null;
1525
+ inputMatchSince = null;
1526
+ inputOnlyReadySince = null;
1527
+ inputOnlySignature = "";
1528
+ await delay(250);
1529
+ continue;
1530
+ }
1423
1531
  const attachedNames = (value.attachedNames ?? [])
1424
1532
  .map((name) => name.toLowerCase().replace(/\s+/g, " ").trim())
1425
1533
  .filter(Boolean);
@@ -1427,15 +1535,15 @@ export async function waitForAttachmentCompletion(Runtime, timeoutMs, expectedNa
1427
1535
  .map((name) => name.toLowerCase().replace(/\s+/g, " ").trim())
1428
1536
  .filter(Boolean);
1429
1537
  const fileCount = typeof value.fileCount === "number" ? value.fileCount : 0;
1430
- const fileCountSatisfied = expectedNormalized.length > 0 && fileCount >= expectedNormalized.length;
1431
1538
  const matchesExpected = (expected) => {
1539
+ if (value.confirmedNames?.includes(expected))
1540
+ return true;
1432
1541
  const baseName = expected.split("/").pop()?.split("\\").pop() ?? expected;
1433
1542
  const normalizedExpected = baseName.toLowerCase().replace(/\s+/g, " ").trim();
1434
1543
  const expectedNoExt = normalizedExpected.replace(/\.[a-z0-9]{1,10}$/i, "");
1544
+ const namePattern = buildAttachmentNamePattern(normalizedExpected, true);
1435
1545
  return attachedNames.some((raw) => {
1436
- if (raw.includes(normalizedExpected))
1437
- return true;
1438
- if (expectedNoExt.length >= 6 && raw.includes(expectedNoExt))
1546
+ if (namePattern?.test(raw))
1439
1547
  return true;
1440
1548
  if (raw.includes("…") || raw.includes("...")) {
1441
1549
  const marker = raw.includes("…") ? "…" : "...";
@@ -1451,8 +1559,8 @@ export async function waitForAttachmentCompletion(Runtime, timeoutMs, expectedNa
1451
1559
  });
1452
1560
  };
1453
1561
  const missing = expectedNormalized.filter((expected) => !matchesExpected(expected));
1454
- if (missing.length === 0 || fileCountSatisfied) {
1455
- const stableThresholdMs = value.uploading ? 3000 : 1500;
1562
+ if (missing.length === 0) {
1563
+ const stableThresholdMs = 1500;
1456
1564
  if (attachmentMatchSince === null) {
1457
1565
  attachmentMatchSince = Date.now();
1458
1566
  }
@@ -1462,11 +1570,11 @@ export async function waitForAttachmentCompletion(Runtime, timeoutMs, expectedNa
1462
1570
  }
1463
1571
  // Don't treat disabled button as complete - wait for it to become 'ready'.
1464
1572
  // The spinner detection is unreliable, so a disabled button likely means upload is in progress.
1465
- if (value.state === "missing" && (value.filesAttached || fileCountSatisfied)) {
1573
+ if (value.state === "missing" && value.filesAttached) {
1466
1574
  return;
1467
1575
  }
1468
1576
  // If files are attached but button isn't ready yet, give it more time but don't fail immediately.
1469
- if (value.filesAttached || fileCountSatisfied) {
1577
+ if (value.filesAttached) {
1470
1578
  await delay(500);
1471
1579
  continue;
1472
1580
  }
@@ -1496,18 +1604,11 @@ export async function waitForAttachmentCompletion(Runtime, timeoutMs, expectedNa
1496
1604
  }
1497
1605
  // Fallback: if the file input has the expected names, allow progress once that condition is stable.
1498
1606
  // Some ChatGPT surfaces only render the filename after sending the message.
1499
- const inputMissing = expectedNormalized.filter((expected) => {
1500
- const baseName = expected.split("/").pop()?.split("\\").pop() ?? expected;
1501
- const normalizedExpected = baseName.toLowerCase().replace(/\s+/g, " ").trim();
1502
- const expectedNoExt = normalizedExpected.replace(/\.[a-z0-9]{1,10}$/i, "");
1503
- return !inputNames.some((raw) => raw.includes(normalizedExpected) ||
1504
- (expectedNoExt.length >= 6 && raw.includes(expectedNoExt)));
1505
- });
1506
1607
  // Don't include 'disabled' - a disabled button likely means upload is still in progress.
1507
1608
  const inputStateOk = value.state === "ready" || value.state === "missing";
1508
- const inputSeenNow = inputMissing.length === 0 || fileCountSatisfied;
1509
- const inputEvidenceOk = Boolean(value.filesAttached) || fileCountSatisfied;
1510
- const stableThresholdMs = value.uploading ? 3000 : 1500;
1609
+ const inputSeenNow = inputOnlyNamesSatisfied;
1610
+ const inputEvidenceOk = inputSeenNow;
1611
+ const stableThresholdMs = 1500;
1511
1612
  if (inputSeenNow && inputStateOk && inputEvidenceOk) {
1512
1613
  if (inputMatchSince === null) {
1513
1614
  inputMatchSince = Date.now();
@@ -1572,14 +1673,7 @@ export async function waitForUserTurnAttachments(Runtime, expectedNames, timeout
1572
1673
  const fileCountSatisfied = fileCount >= expectedNormalized.length && expectedNormalized.length > 0;
1573
1674
  const attachmentUiSatisfied = attachmentUiCount >= expectedNormalized.length && expectedNormalized.length > 0;
1574
1675
  const missing = expectedNormalized.filter((expected) => {
1575
- const baseName = expected.split("/").pop()?.split("\\").pop() ?? expected;
1576
- const normalizedExpected = baseName.toLowerCase().replace(/\s+/g, " ").trim();
1577
- const expectedNoExt = normalizedExpected.replace(/\.[a-z0-9]{1,10}$/i, "");
1578
- if (haystack.includes(normalizedExpected))
1579
- return false;
1580
- if (expectedNoExt.length >= 6 && haystack.includes(expectedNoExt))
1581
- return false;
1582
- return true;
1676
+ return !buildAttachmentNamePattern(expected, true)?.test(haystack);
1583
1677
  });
1584
1678
  if (promptMatches && (missing.length === 0 || fileCountSatisfied || attachmentUiSatisfied)) {
1585
1679
  return true;
@@ -1705,19 +1799,15 @@ export function buildUserTurnAttachmentExpressionForTest(options) {
1705
1799
  : null,
1706
1800
  });
1707
1801
  }
1708
- export async function waitForAttachmentVisible(Runtime, expectedName, timeoutMs, logger) {
1802
+ export async function waitForAttachmentVisible(Runtime, expectedName, timeoutMs, logger, evidenceId) {
1709
1803
  // Attachments can take a few seconds to render in the composer (headless/remote Chrome is slower),
1710
1804
  // so respect the caller-provided timeout instead of capping at 2s.
1711
1805
  const deadline = Date.now() + timeoutMs;
1712
1806
  const expression = `(() => {
1713
- const expected = ${JSON.stringify(expectedName)};
1714
- const normalized = expected.toLowerCase();
1715
- const normalizedNoExt = normalized.replace(/\\.[a-z0-9]{1,10}$/i, '');
1807
+ if ((${buildAttachmentEvidenceExpression([expectedName])})[0]) return { found: true, source: 'upload-evidence' };
1808
+ const namePattern = new RegExp(${JSON.stringify(buildAttachmentNamePattern(expectedName, true)?.source ?? "(?!)")}, 'iu');
1716
1809
  const matchesExpectedFileName = (value) => {
1717
- const text = String(value || '').toLowerCase();
1718
- if (!text) return false;
1719
- if (text.includes(normalized)) return true;
1720
- return normalizedNoExt.length >= 6 && text.includes(normalizedNoExt);
1810
+ return namePattern.test(String(value || '').toLowerCase().replace(/\\s+/g, ' ').trim());
1721
1811
  };
1722
1812
  const matchNode = (node) => {
1723
1813
  if (!node) return false;
@@ -1728,7 +1818,7 @@ export async function waitForAttachmentVisible(Runtime, expectedName, timeoutMs,
1728
1818
  const testId = node.getAttribute?.('data-testid')?.toLowerCase?.() ?? '';
1729
1819
  const alt = node.getAttribute?.('alt')?.toLowerCase?.() ?? '';
1730
1820
  const candidates = [text, aria, title, testId, alt].filter(Boolean);
1731
- return candidates.some((value) => value.includes(normalized) || (normalizedNoExt.length >= 6 && value.includes(normalizedNoExt)));
1821
+ return candidates.some(matchesExpectedFileName);
1732
1822
  };
1733
1823
 
1734
1824
  const inputs = Array.from(document.querySelectorAll('input[type="file"]'));
@@ -1791,93 +1881,25 @@ export async function waitForAttachmentVisible(Runtime, expectedName, timeoutMs,
1791
1881
  };
1792
1882
  const composerRoot = locateComposerRoot() ?? document.body;
1793
1883
 
1794
- const attachmentMatch = ['[data-testid*="attachment"]','[data-testid*="chip"]','[data-testid*="upload"]','[data-testid*="file"]'].some((selector) =>
1884
+ const attachmentMatch = ['[data-testid*="attachment"]','[data-testid*="chip"]','[data-testid*="upload"]','[data-testid*="file"]','[aria-label*="Remove" i]'].some((selector) =>
1795
1885
  Array.from(composerRoot.querySelectorAll(selector)).some(matchNode),
1796
1886
  );
1797
1887
  if (attachmentMatch) {
1798
1888
  return { found: true, source: 'attachments' };
1799
1889
  }
1800
1890
 
1801
- const removeButtons = Array.from(
1802
- (composerRoot ?? document).querySelectorAll('[aria-label*="Remove"],[aria-label*="remove"]'),
1803
- );
1804
- const visibleRemove = removeButtons.some((btn) => {
1805
- if (!(btn instanceof HTMLElement)) return false;
1806
- const rect = btn.getBoundingClientRect();
1807
- if (rect.width <= 0 || rect.height <= 0) return false;
1808
- const style = window.getComputedStyle(btn);
1809
- return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0';
1810
- });
1811
- if (visibleRemove) {
1812
- return { found: true, source: 'remove-button' };
1813
- }
1814
-
1815
1891
  const cardTexts = Array.from(composerRoot.querySelectorAll('[aria-label*="Remove"]')).map((btn) =>
1816
1892
  btn?.parentElement?.parentElement?.innerText?.toLowerCase?.() ?? '',
1817
1893
  );
1818
- if (cardTexts.some((text) => text.includes(normalized) || (normalizedNoExt.length >= 6 && text.includes(normalizedNoExt)))) {
1894
+ if (cardTexts.some(matchesExpectedFileName)) {
1819
1895
  return { found: true, source: 'attachment-cards' };
1820
1896
  }
1821
1897
 
1822
- const countRegex = /(?:^|\\b)(\\d+)\\s+(?:files?|attachments?)\\b/;
1823
- const fileCountNodes = Array.from(composerRoot.querySelectorAll('button,span,div,[aria-label],[title]'));
1824
- let fileCount = 0;
1825
- for (const node of fileCountNodes) {
1826
- if (!(node instanceof HTMLElement)) continue;
1827
- if (node.matches('textarea,input,[contenteditable="true"]')) continue;
1828
- const dataTestId = node.getAttribute?.('data-testid') ?? '';
1829
- const aria = node.getAttribute?.('aria-label') ?? '';
1830
- const title = node.getAttribute?.('title') ?? '';
1831
- const tooltip =
1832
- node.getAttribute?.('data-tooltip') ?? node.getAttribute?.('data-tooltip-content') ?? '';
1833
- const text = node.textContent ?? '';
1834
- const parent = node.parentElement;
1835
- const parentText = parent?.textContent ?? '';
1836
- const parentAria = parent?.getAttribute?.('aria-label') ?? '';
1837
- const parentTitle = parent?.getAttribute?.('title') ?? '';
1838
- const parentTooltip =
1839
- parent?.getAttribute?.('data-tooltip') ?? parent?.getAttribute?.('data-tooltip-content') ?? '';
1840
- const parentTestId = parent?.getAttribute?.('data-testid') ?? '';
1841
- const candidates = [
1842
- text,
1843
- aria,
1844
- title,
1845
- tooltip,
1846
- dataTestId,
1847
- parentText,
1848
- parentAria,
1849
- parentTitle,
1850
- parentTooltip,
1851
- parentTestId,
1852
- ];
1853
- let hasFileHint = false;
1854
- for (const raw of candidates) {
1855
- if (!raw) continue;
1856
- const lowered = String(raw).toLowerCase();
1857
- if (lowered.includes('file') || lowered.includes('attachment')) {
1858
- hasFileHint = true;
1859
- break;
1860
- }
1861
- }
1862
- if (!hasFileHint) continue;
1863
- for (const raw of candidates) {
1864
- if (!raw) continue;
1865
- const match = String(raw).toLowerCase().match(countRegex);
1866
- if (match) {
1867
- const count = Number(match[1]);
1868
- if (Number.isFinite(count)) {
1869
- fileCount = Math.max(fileCount, count);
1870
- }
1871
- }
1872
- }
1873
- }
1874
- if (fileCount > 0) {
1875
- return { found: true, source: 'file-count' };
1876
- }
1877
-
1878
1898
  return { found: false };
1879
1899
  })()`;
1880
1900
  while (Date.now() < deadline) {
1901
+ if (evidenceId)
1902
+ await confirmAttachmentEvidence(Runtime, evidenceId);
1881
1903
  const { result } = await Runtime.evaluate({ expression, returnByValue: true });
1882
1904
  const value = result?.value;
1883
1905
  if (value?.found) {
@@ -1894,11 +1916,11 @@ async function waitForAttachmentAnchored(Runtime, expectedName, timeoutMs) {
1894
1916
  const expression = `(() => {
1895
1917
  const normalized = ${JSON.stringify(expectedName.toLowerCase())};
1896
1918
  const normalizedNoExt = normalized.replace(/\\.[a-z0-9]{1,10}$/i, '');
1919
+ const namePattern = new RegExp(${JSON.stringify(buildAttachmentNamePattern(expectedName, true)?.source ?? "(?!)")}, 'iu');
1897
1920
  const matchesExpected = (value) => {
1898
- const text = (value ?? '').toLowerCase();
1921
+ const text = String(value ?? '').toLowerCase().replace(/\\s+/g, ' ').trim();
1899
1922
  if (!text) return false;
1900
- if (text.includes(normalized)) return true;
1901
- if (normalizedNoExt.length >= 6 && text.includes(normalizedNoExt)) return true;
1923
+ if (namePattern.test(text)) return true;
1902
1924
  if (text.includes('…') || text.includes('...')) {
1903
1925
  const marker = text.includes('…') ? '…' : '...';
1904
1926
  const [prefixRaw, suffixRaw] = text.split(marker);
@@ -1948,77 +1970,6 @@ async function waitForAttachmentAnchored(Runtime, expectedName, timeoutMs) {
1948
1970
  if (cards.some(matchesExpected)) {
1949
1971
  return { found: true, text: cards.find(matchesExpected) };
1950
1972
  }
1951
- const countRegex = /(?:^|\\b)(\\d+)\\s+(?:files?|attachments?)\\b/;
1952
- const fileCountNodes = (() => {
1953
- const nodes = [];
1954
- const seen = new Set();
1955
- const add = (node) => {
1956
- if (!node || seen.has(node)) return;
1957
- seen.add(node);
1958
- nodes.push(node);
1959
- };
1960
- const root =
1961
- document.querySelector('[data-testid*="composer"]') || document.querySelector('form') || document.body;
1962
- const localNodes = root ? Array.from(root.querySelectorAll('button,span,div,[aria-label],[title]')) : [];
1963
- for (const node of localNodes) add(node);
1964
- for (const node of Array.from(document.querySelectorAll('button,span,div,[aria-label],[title]'))) {
1965
- add(node);
1966
- }
1967
- return nodes;
1968
- })();
1969
- let fileCount = 0;
1970
- for (const node of fileCountNodes) {
1971
- if (!(node instanceof HTMLElement)) continue;
1972
- if (node.matches('textarea,input,[contenteditable="true"]')) continue;
1973
- const dataTestId = node.getAttribute?.('data-testid') ?? '';
1974
- const aria = node.getAttribute?.('aria-label') ?? '';
1975
- const title = node.getAttribute?.('title') ?? '';
1976
- const tooltip =
1977
- node.getAttribute?.('data-tooltip') ?? node.getAttribute?.('data-tooltip-content') ?? '';
1978
- const text = node.textContent ?? '';
1979
- const parent = node.parentElement;
1980
- const parentText = parent?.textContent ?? '';
1981
- const parentAria = parent?.getAttribute?.('aria-label') ?? '';
1982
- const parentTitle = parent?.getAttribute?.('title') ?? '';
1983
- const parentTooltip =
1984
- parent?.getAttribute?.('data-tooltip') ?? parent?.getAttribute?.('data-tooltip-content') ?? '';
1985
- const parentTestId = parent?.getAttribute?.('data-testid') ?? '';
1986
- const candidates = [
1987
- text,
1988
- aria,
1989
- title,
1990
- tooltip,
1991
- dataTestId,
1992
- parentText,
1993
- parentAria,
1994
- parentTitle,
1995
- parentTooltip,
1996
- parentTestId,
1997
- ];
1998
- let hasFileHint = false;
1999
- for (const raw of candidates) {
2000
- if (!raw) continue;
2001
- const lowered = String(raw).toLowerCase();
2002
- if (lowered.includes('file') || lowered.includes('attachment')) {
2003
- hasFileHint = true;
2004
- break;
2005
- }
2006
- }
2007
- if (!hasFileHint) continue;
2008
- for (const raw of candidates) {
2009
- if (!raw) continue;
2010
- const match = String(raw).toLowerCase().match(countRegex);
2011
- if (match) {
2012
- const count = Number(match[1]);
2013
- if (Number.isFinite(count)) {
2014
- fileCount = Math.max(fileCount, count);
2015
- }
2016
- }
2017
- }
2018
- }
2019
- if (fileCount > 0) {
2020
- return { found: true, text: 'file-count' };
2021
- }
2022
1973
  return { found: false };
2023
1974
  })()`;
2024
1975
  while (Date.now() < deadline) {