@steipete/oracle 0.8.4 → 0.8.6

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 (44) hide show
  1. package/README.md +30 -1
  2. package/dist/bin/oracle-cli.js +291 -16
  3. package/dist/scripts/debug/extract-chatgpt-response.js +53 -0
  4. package/dist/src/bridge/connection.js +103 -0
  5. package/dist/src/bridge/userConfigFile.js +28 -0
  6. package/dist/src/browser/actions/assistantResponse.js +85 -42
  7. package/dist/src/browser/actions/promptComposer.js +141 -32
  8. package/dist/src/browser/chromeLifecycle.js +78 -9
  9. package/dist/src/browser/config.js +14 -0
  10. package/dist/src/browser/detect.js +164 -0
  11. package/dist/src/browser/index.js +394 -24
  12. package/dist/src/browser/profileState.js +93 -0
  13. package/dist/src/cli/bridge/claudeConfig.js +54 -0
  14. package/dist/src/cli/bridge/client.js +73 -0
  15. package/dist/src/cli/bridge/codexConfig.js +43 -0
  16. package/dist/src/cli/bridge/doctor.js +107 -0
  17. package/dist/src/cli/bridge/host.js +259 -0
  18. package/dist/src/cli/browserConfig.js +21 -0
  19. package/dist/src/cli/browserDefaults.js +21 -0
  20. package/dist/src/cli/engine.js +17 -1
  21. package/dist/src/cli/options.js +14 -0
  22. package/dist/src/cli/runOptions.js +4 -0
  23. package/dist/src/cli/sessionRunner.js +149 -0
  24. package/dist/src/cli/tui/index.js +1 -0
  25. package/dist/src/mcp/tools/consult.js +81 -15
  26. package/dist/src/mcp/tools/sessions.js +15 -6
  27. package/dist/src/mcp/types.js +4 -0
  28. package/dist/src/mcp/utils.js +12 -2
  29. package/dist/src/oracle/background.js +1 -2
  30. package/dist/src/oracle/client.js +5 -2
  31. package/dist/src/oracle/files.js +2 -2
  32. package/dist/src/oracle/modelResolver.js +33 -1
  33. package/dist/src/oracle/run.js +1 -0
  34. package/dist/src/remote/client.js +6 -5
  35. package/dist/src/remote/health.js +113 -0
  36. package/dist/src/remote/remoteServiceConfig.js +31 -0
  37. package/dist/src/remote/server.js +28 -1
  38. package/dist/src/sessionManager.js +72 -7
  39. package/dist/src/sessionStore.js +2 -2
  40. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  41. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  42. package/package.json +21 -21
  43. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  44. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
@@ -0,0 +1,103 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ export function normalizeHostPort(hostname, port) {
4
+ const trimmed = hostname.trim();
5
+ const unwrapped = trimmed.startsWith('[') && trimmed.endsWith(']') ? trimmed.slice(1, -1) : trimmed;
6
+ if (unwrapped.includes(':')) {
7
+ return `[${unwrapped}]:${port}`;
8
+ }
9
+ return `${unwrapped}:${port}`;
10
+ }
11
+ export function parseHostPort(raw) {
12
+ const target = raw.trim();
13
+ if (!target) {
14
+ throw new Error('Expected host:port but received an empty value.');
15
+ }
16
+ const ipv6Match = target.match(/^\[(.+)]:(\d+)$/);
17
+ let hostname;
18
+ let portSegment;
19
+ if (ipv6Match) {
20
+ hostname = ipv6Match[1]?.trim();
21
+ portSegment = ipv6Match[2]?.trim();
22
+ }
23
+ else {
24
+ const lastColon = target.lastIndexOf(':');
25
+ if (lastColon === -1) {
26
+ throw new Error(`Invalid host:port format: ${target}. Expected host:port (IPv6 must use [host]:port notation).`);
27
+ }
28
+ hostname = target.slice(0, lastColon).trim();
29
+ portSegment = target.slice(lastColon + 1).trim();
30
+ if (hostname.includes(':')) {
31
+ throw new Error(`Invalid host:port format: ${target}. Wrap IPv6 addresses in brackets, e.g. "[2001:db8::1]:9473".`);
32
+ }
33
+ }
34
+ if (!hostname) {
35
+ throw new Error(`Invalid host:port format: ${target}. Host portion is missing.`);
36
+ }
37
+ const port = Number.parseInt(portSegment ?? '', 10);
38
+ if (!Number.isFinite(port) || port <= 0 || port > 65_535) {
39
+ throw new Error(`Invalid port: "${portSegment ?? ''}". Expected a number between 1 and 65535.`);
40
+ }
41
+ return { hostname, port };
42
+ }
43
+ export function parseBridgeConnectionString(input) {
44
+ const raw = input.trim();
45
+ if (!raw) {
46
+ throw new Error('Missing connection string.');
47
+ }
48
+ let url;
49
+ try {
50
+ url = raw.includes('://') ? new URL(raw) : new URL(`oracle+tcp://${raw}`);
51
+ }
52
+ catch (error) {
53
+ throw new Error(`Invalid connection string: ${error instanceof Error ? error.message : String(error)}`);
54
+ }
55
+ const hostname = url.hostname?.trim();
56
+ const port = Number.parseInt(url.port ?? '', 10);
57
+ if (!hostname || !Number.isFinite(port) || port <= 0 || port > 65_535) {
58
+ throw new Error(`Invalid connection string host: ${raw}. Expected host:port.`);
59
+ }
60
+ const token = url.searchParams.get('token')?.trim() ?? '';
61
+ if (!token) {
62
+ throw new Error('Connection string is missing token. Expected "?token=...".');
63
+ }
64
+ const remoteHost = normalizeHostPort(hostname, port);
65
+ return { remoteHost, remoteToken: token };
66
+ }
67
+ export function formatBridgeConnectionString(connection, options = {}) {
68
+ const { hostname, port } = parseHostPort(connection.remoteHost);
69
+ const base = `oracle+tcp://${normalizeHostPort(hostname, port)}`;
70
+ if (!options.includeToken) {
71
+ return base;
72
+ }
73
+ const params = new URLSearchParams({ token: connection.remoteToken });
74
+ return `${base}?${params.toString()}`;
75
+ }
76
+ export function looksLikePath(value) {
77
+ return value.includes('/') || value.includes('\\') || value.endsWith('.json');
78
+ }
79
+ export async function readBridgeConnectionArtifact(filePath) {
80
+ const resolved = path.resolve(process.cwd(), filePath);
81
+ const raw = await fs.readFile(resolved, 'utf8');
82
+ let parsed;
83
+ try {
84
+ parsed = JSON.parse(raw);
85
+ }
86
+ catch (error) {
87
+ throw new Error(`Failed to parse connection artifact JSON at ${resolved}: ${error instanceof Error ? error.message : String(error)}`);
88
+ }
89
+ if (!parsed || typeof parsed !== 'object') {
90
+ throw new Error(`Invalid connection artifact at ${resolved}: expected an object.`);
91
+ }
92
+ const remoteHost = parsed.remoteHost;
93
+ const remoteToken = parsed.remoteToken;
94
+ if (typeof remoteHost !== 'string' || remoteHost.trim().length === 0) {
95
+ throw new Error(`Invalid connection artifact at ${resolved}: remoteHost is missing.`);
96
+ }
97
+ if (typeof remoteToken !== 'string' || remoteToken.trim().length === 0) {
98
+ throw new Error(`Invalid connection artifact at ${resolved}: remoteToken is missing.`);
99
+ }
100
+ // Validate host formatting early so downstream checks don't crash.
101
+ parseHostPort(remoteHost);
102
+ return parsed;
103
+ }
@@ -0,0 +1,28 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import JSON5 from 'json5';
4
+ export async function readUserConfigFile(configPath) {
5
+ try {
6
+ const raw = await fs.readFile(configPath, 'utf8');
7
+ const parsed = JSON5.parse(raw);
8
+ return { config: parsed ?? {}, loaded: true };
9
+ }
10
+ catch (error) {
11
+ const code = error.code;
12
+ if (code === 'ENOENT') {
13
+ return { config: {}, loaded: false };
14
+ }
15
+ throw new Error(`Failed to read ${configPath}: ${error instanceof Error ? error.message : String(error)}`);
16
+ }
17
+ }
18
+ export async function writeUserConfigFile(configPath, config) {
19
+ const dir = path.dirname(configPath);
20
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 });
21
+ const contents = `${JSON.stringify(config, null, 2)}\n`;
22
+ const tempPath = `${configPath}.tmp-${process.pid}-${Date.now()}`;
23
+ await fs.writeFile(tempPath, contents, { encoding: 'utf8', mode: 0o600 });
24
+ await fs.rename(tempPath, configPath);
25
+ if (process.platform !== 'win32') {
26
+ await fs.chmod(configPath, 0o600).catch(() => undefined);
27
+ }
28
+ }
@@ -27,23 +27,26 @@ export async function waitForAssistantResponse(Runtime, timeoutMs, logger, minTu
27
27
  const raceReadyEvaluation = evaluationPromise.then((value) => ({ kind: 'evaluation', value }), (error) => {
28
28
  throw { source: 'evaluation', error };
29
29
  });
30
- const pollerPromise = pollAssistantCompletion(Runtime, timeoutMs, minTurnIndex).then((value) => {
31
- if (!value) {
32
- throw { source: 'poll', error: new Error(ASSISTANT_POLL_TIMEOUT_ERROR) };
33
- }
34
- return { kind: 'poll', value };
35
- }, (error) => {
30
+ // Use AbortController to stop the poller when the evaluation wins the race,
31
+ // preventing abandoned polling loops from consuming resources.
32
+ const pollerAbort = new AbortController();
33
+ const pollerPromise = pollAssistantCompletion(Runtime, timeoutMs, minTurnIndex, pollerAbort.signal).then((value) => ({ kind: 'poll', value }), (error) => {
36
34
  throw { source: 'poll', error };
37
35
  });
38
36
  let evaluation = null;
39
37
  try {
40
38
  const winner = await Promise.race([raceReadyEvaluation, pollerPromise]);
41
39
  if (winner.kind === 'poll') {
40
+ if (!winner.value) {
41
+ throw { source: 'poll', error: new Error(ASSISTANT_POLL_TIMEOUT_ERROR) };
42
+ }
42
43
  logger('Captured assistant response via snapshot watchdog');
43
44
  evaluationPromise.catch(() => undefined);
44
45
  await terminateRuntimeExecution(Runtime);
45
46
  return winner.value;
46
47
  }
48
+ // Evaluation won - abort the poller to prevent it from running until timeout
49
+ pollerAbort.abort();
47
50
  evaluation = winner.value;
48
51
  }
49
52
  catch (wrappedError) {
@@ -86,7 +89,7 @@ export async function waitForAssistantResponse(Runtime, timeoutMs, logger, minTu
86
89
  pollerPromise.catch(() => null),
87
90
  delay(remainingMs).then(() => null),
88
91
  ]);
89
- if (polled && polled.kind === 'poll') {
92
+ if (polled && polled.kind === 'poll' && polled.value) {
90
93
  return polled.value;
91
94
  }
92
95
  }
@@ -263,12 +266,16 @@ async function terminateRuntimeExecution(Runtime) {
263
266
  // ignore termination failures
264
267
  }
265
268
  }
266
- async function pollAssistantCompletion(Runtime, timeoutMs, minTurnIndex) {
269
+ async function pollAssistantCompletion(Runtime, timeoutMs, minTurnIndex, abortSignal) {
267
270
  const watchdogDeadline = Date.now() + timeoutMs;
268
271
  let previousLength = 0;
269
272
  let stableCycles = 0;
270
273
  let lastChangeAt = Date.now();
271
274
  while (Date.now() < watchdogDeadline) {
275
+ // Check abort signal to stop polling when another path won the race
276
+ if (abortSignal?.aborted) {
277
+ return null;
278
+ }
272
279
  const snapshot = await readAssistantSnapshot(Runtime, minTurnIndex);
273
280
  const normalized = normalizeAssistantSnapshot(snapshot);
274
281
  if (normalized) {
@@ -286,11 +293,15 @@ async function pollAssistantCompletion(Runtime, timeoutMs, minTurnIndex) {
286
293
  isCompletionVisible(Runtime),
287
294
  ]);
288
295
  const shortAnswer = currentLength > 0 && currentLength < 16;
296
+ const mediumAnswer = currentLength >= 16 && currentLength < 40;
297
+ const longAnswer = currentLength >= 40 && currentLength < 500;
289
298
  // Learned: short answers need a longer stability window or they truncate.
290
- const completionStableTarget = shortAnswer ? 12 : currentLength < 40 ? 8 : 4;
291
- const requiredStableCycles = shortAnswer ? 12 : 6;
299
+ // Learned: long streaming responses (esp. thinking models) can pause mid-stream;
300
+ // use progressively longer windows to avoid truncation (#71).
301
+ const completionStableTarget = shortAnswer ? 12 : mediumAnswer ? 8 : longAnswer ? 6 : 8;
302
+ const requiredStableCycles = shortAnswer ? 12 : mediumAnswer ? 8 : longAnswer ? 8 : 10;
292
303
  const stableMs = Date.now() - lastChangeAt;
293
- const minStableMs = shortAnswer ? 8000 : 1200;
304
+ const minStableMs = shortAnswer ? 8000 : mediumAnswer ? 1200 : longAnswer ? 2000 : 3000;
294
305
  // Require stop button to disappear before treating completion as final.
295
306
  if (!stopVisible) {
296
307
  const stableEnough = stableCycles >= requiredStableCycles && stableMs >= minStableMs;
@@ -479,33 +490,63 @@ function buildResponseObserverExpression(timeoutMs, minTurnIndex) {
479
490
  new Promise((resolve, reject) => {
480
491
  const deadline = Date.now() + ${timeoutMs};
481
492
  let stopInterval = null;
482
- const observer = new MutationObserver(() => {
483
- const extractedRaw = extractFromTurns();
484
- const extractedCandidate =
485
- extractedRaw && !isAnswerNowPlaceholder(extractedRaw) ? extractedRaw : null;
486
- let extracted = acceptSnapshot(extractedCandidate);
487
- if (!extracted) {
488
- const fallbackRaw = extractFromMarkdownFallback();
489
- const fallbackCandidate =
490
- fallbackRaw && !isAnswerNowPlaceholder(fallbackRaw) ? fallbackRaw : null;
491
- extracted = acceptSnapshot(fallbackCandidate);
493
+ let timeoutId = null;
494
+ let cleanedUp = false;
495
+ let observer = null;
496
+
497
+ // Centralized cleanup to prevent resource leaks
498
+ const cleanup = () => {
499
+ if (cleanedUp) return;
500
+ cleanedUp = true;
501
+ if (stopInterval) {
502
+ clearInterval(stopInterval);
503
+ stopInterval = null;
504
+ }
505
+ if (timeoutId) {
506
+ clearTimeout(timeoutId);
507
+ timeoutId = null;
508
+ }
509
+ if (observer) {
510
+ try {
511
+ observer.disconnect();
512
+ } catch {
513
+ // ignore disconnect errors
514
+ }
515
+ observer = null;
492
516
  }
493
- if (extracted) {
494
- observer.disconnect();
495
- if (stopInterval) {
496
- clearInterval(stopInterval);
517
+ };
518
+
519
+ const observerCallback = () => {
520
+ if (cleanedUp) return;
521
+ try {
522
+ const extractedRaw = extractFromTurns();
523
+ const extractedCandidate =
524
+ extractedRaw && !isAnswerNowPlaceholder(extractedRaw) ? extractedRaw : null;
525
+ let extracted = acceptSnapshot(extractedCandidate);
526
+ if (!extracted) {
527
+ const fallbackRaw = extractFromMarkdownFallback();
528
+ const fallbackCandidate =
529
+ fallbackRaw && !isAnswerNowPlaceholder(fallbackRaw) ? fallbackRaw : null;
530
+ extracted = acceptSnapshot(fallbackCandidate);
497
531
  }
498
- resolve(extracted);
499
- } else if (Date.now() > deadline) {
500
- observer.disconnect();
501
- if (stopInterval) {
502
- clearInterval(stopInterval);
532
+ if (extracted) {
533
+ cleanup();
534
+ resolve(extracted);
535
+ } else if (Date.now() > deadline) {
536
+ cleanup();
537
+ reject(new Error('Response timeout'));
503
538
  }
504
- reject(new Error('Response timeout'));
539
+ } catch (error) {
540
+ cleanup();
541
+ reject(error);
505
542
  }
506
- });
543
+ };
544
+
545
+ observer = new MutationObserver(observerCallback);
507
546
  observer.observe(document.body, { childList: true, subtree: true, characterData: true });
547
+
508
548
  stopInterval = setInterval(() => {
549
+ if (cleanedUp) return;
509
550
  const stop = document.querySelector(STOP_SELECTOR);
510
551
  if (!stop) {
511
552
  return;
@@ -517,11 +558,9 @@ function buildResponseObserverExpression(timeoutMs, minTurnIndex) {
517
558
  }
518
559
  dispatchClickSequence(stop);
519
560
  }, 500);
520
- setTimeout(() => {
521
- if (stopInterval) {
522
- clearInterval(stopInterval);
523
- }
524
- observer.disconnect();
561
+
562
+ timeoutId = setTimeout(() => {
563
+ cleanup();
525
564
  reject(new Error('Response timeout'));
526
565
  }, ${timeoutMs});
527
566
  });
@@ -546,15 +585,19 @@ function buildResponseObserverExpression(timeoutMs, minTurnIndex) {
546
585
 
547
586
  const waitForSettle = async (snapshot) => {
548
587
  // Learned: short answers can be 1-2 tokens; enforce longer settle windows to avoid truncation.
588
+ // Learned: long streaming responses (esp. thinking models) can pause mid-stream;
589
+ // use progressively longer windows to avoid truncation (#71).
549
590
  const initialLength = snapshot?.text?.length ?? 0;
550
591
  const shortAnswer = initialLength > 0 && initialLength < 16;
551
- const settleWindowMs = shortAnswer ? 12_000 : 5_000;
592
+ const mediumAnswer = initialLength >= 16 && initialLength < 40;
593
+ const longAnswer = initialLength >= 40 && initialLength < 500;
594
+ const settleWindowMs = shortAnswer ? 12_000 : mediumAnswer ? 5_000 : longAnswer ? 8_000 : 10_000;
552
595
  const settleIntervalMs = 400;
553
596
  const deadline = Date.now() + settleWindowMs;
554
597
  let latest = snapshot;
555
598
  let lastLength = snapshot?.text?.length ?? 0;
556
599
  let stableCycles = 0;
557
- const stableTarget = shortAnswer ? 6 : 3;
600
+ const stableTarget = shortAnswer ? 6 : mediumAnswer ? 3 : longAnswer ? 5 : 6;
558
601
  while (Date.now() < deadline) {
559
602
  await new Promise((resolve) => setTimeout(resolve, settleIntervalMs));
560
603
  const refreshedRaw = extractFromTurns();
@@ -678,7 +721,7 @@ function buildAssistantExtractor(functionName) {
678
721
  function buildMarkdownFallbackExtractor(minTurnLiteral) {
679
722
  const turnIndexValue = minTurnLiteral ? `(${minTurnLiteral} >= 0 ? ${minTurnLiteral} : null)` : 'null';
680
723
  return `(() => {
681
- const MIN_TURN_INDEX = ${turnIndexValue};
724
+ const __minTurn = ${turnIndexValue};
682
725
  const roots = [
683
726
  document.querySelector('section[data-testid="screen-threadFlyOut"]'),
684
727
  document.querySelector('[data-testid="chat-thread"]'),
@@ -720,10 +763,10 @@ function buildMarkdownFallbackExtractor(minTurnLiteral) {
720
763
  return idx >= 0 ? idx : null;
721
764
  };
722
765
  const isAfterMinTurn = (node) => {
723
- if (MIN_TURN_INDEX === null) return true;
766
+ if (__minTurn === null) return true;
724
767
  if (!hasTurns) return true;
725
768
  const idx = resolveTurnIndex(node);
726
- return idx !== null && idx >= MIN_TURN_INDEX;
769
+ return idx !== null && idx >= __minTurn;
727
770
  };
728
771
  const normalize = (value) => String(value || '').toLowerCase().replace(/\\s+/g, ' ').trim();
729
772
  const collectUserText = (scope) => {
@@ -18,6 +18,13 @@ export async function submitPrompt(deps, prompt, logger) {
18
18
  expression: `(() => {
19
19
  ${buildClickDispatcher()}
20
20
  const SELECTORS = ${JSON.stringify(INPUT_SELECTORS)};
21
+ const isVisible = (node) => {
22
+ if (!node || typeof node.getBoundingClientRect !== 'function') {
23
+ return false;
24
+ }
25
+ const rect = node.getBoundingClientRect();
26
+ return rect.width > 0 && rect.height > 0;
27
+ };
21
28
  const focusNode = (node) => {
22
29
  if (!node) {
23
30
  return false;
@@ -39,13 +46,17 @@ export async function submitPrompt(deps, prompt, logger) {
39
46
  return true;
40
47
  };
41
48
 
49
+ const candidates = [];
42
50
  for (const selector of SELECTORS) {
43
51
  const node = document.querySelector(selector);
44
- if (!node) continue;
45
- if (focusNode(node)) {
46
- return { focused: true };
52
+ if (node) {
53
+ candidates.push(node);
47
54
  }
48
55
  }
56
+ const preferred = candidates.find((node) => isVisible(node)) || candidates[0];
57
+ if (preferred && focusNode(preferred)) {
58
+ return { focused: true };
59
+ }
49
60
  return { focused: false };
50
61
  })()`,
51
62
  returnByValue: true,
@@ -65,18 +76,36 @@ export async function submitPrompt(deps, prompt, logger) {
65
76
  expression: `(() => {
66
77
  const editor = document.querySelector(${primarySelectorLiteral});
67
78
  const fallback = document.querySelector(${fallbackSelectorLiteral});
79
+ const inputSelectors = ${JSON.stringify(INPUT_SELECTORS)};
80
+ const readValue = (node) => {
81
+ if (!node) return '';
82
+ if (node instanceof HTMLTextAreaElement) return node.value ?? '';
83
+ return node.innerText ?? '';
84
+ };
85
+ const isVisible = (node) => {
86
+ if (!node || typeof node.getBoundingClientRect !== 'function') return false;
87
+ const rect = node.getBoundingClientRect();
88
+ return rect.width > 0 && rect.height > 0;
89
+ };
90
+ const candidates = inputSelectors
91
+ .map((selector) => document.querySelector(selector))
92
+ .filter((node) => Boolean(node));
93
+ const active = candidates.find((node) => isVisible(node)) || candidates[0] || null;
68
94
  return {
69
95
  editorText: editor?.innerText ?? '',
70
96
  fallbackValue: fallback?.value ?? '',
97
+ activeValue: active ? readValue(active) : '',
71
98
  };
72
99
  })()`,
73
100
  returnByValue: true,
74
101
  });
75
102
  const editorTextRaw = verification.result?.value?.editorText ?? '';
76
103
  const fallbackValueRaw = verification.result?.value?.fallbackValue ?? '';
104
+ const activeValueRaw = verification.result?.value?.activeValue ?? '';
77
105
  const editorTextTrimmed = editorTextRaw?.trim?.() ?? '';
78
106
  const fallbackValueTrimmed = fallbackValueRaw?.trim?.() ?? '';
79
- if (!editorTextTrimmed && !fallbackValueTrimmed) {
107
+ const activeValueTrimmed = activeValueRaw?.trim?.() ?? '';
108
+ if (!editorTextTrimmed && !fallbackValueTrimmed && !activeValueTrimmed) {
80
109
  // Learned: occasionally Input.insertText doesn't land in the editor; force textContent/value + input events.
81
110
  await runtime.evaluate({
82
111
  expression: `(() => {
@@ -100,16 +129,33 @@ export async function submitPrompt(deps, prompt, logger) {
100
129
  expression: `(() => {
101
130
  const editor = document.querySelector(${primarySelectorLiteral});
102
131
  const fallback = document.querySelector(${fallbackSelectorLiteral});
132
+ const inputSelectors = ${JSON.stringify(INPUT_SELECTORS)};
133
+ const readValue = (node) => {
134
+ if (!node) return '';
135
+ if (node instanceof HTMLTextAreaElement) return node.value ?? '';
136
+ return node.innerText ?? '';
137
+ };
138
+ const isVisible = (node) => {
139
+ if (!node || typeof node.getBoundingClientRect !== 'function') return false;
140
+ const rect = node.getBoundingClientRect();
141
+ return rect.width > 0 && rect.height > 0;
142
+ };
143
+ const candidates = inputSelectors
144
+ .map((selector) => document.querySelector(selector))
145
+ .filter((node) => Boolean(node));
146
+ const active = candidates.find((node) => isVisible(node)) || candidates[0] || null;
103
147
  return {
104
148
  editorText: editor?.innerText ?? '',
105
149
  fallbackValue: fallback?.value ?? '',
150
+ activeValue: active ? readValue(active) : '',
106
151
  };
107
152
  })()`,
108
153
  returnByValue: true,
109
154
  });
110
155
  const observedEditor = postVerification.result?.value?.editorText ?? '';
111
156
  const observedFallback = postVerification.result?.value?.fallbackValue ?? '';
112
- const observedLength = Math.max(observedEditor.length, observedFallback.length);
157
+ const observedActive = postVerification.result?.value?.activeValue ?? '';
158
+ const observedLength = Math.max(observedEditor.length, observedFallback.length, observedActive.length);
113
159
  if (promptLength >= 50_000 && observedLength > 0 && observedLength < promptLength - 2_000) {
114
160
  // Learned: very large prompts can truncate silently; fail fast so we can fall back to file uploads.
115
161
  await logDomFailure(runtime, logger, 'prompt-too-large');
@@ -144,10 +190,12 @@ export async function submitPrompt(deps, prompt, logger) {
144
190
  export async function clearPromptComposer(Runtime, logger) {
145
191
  const primarySelectorLiteral = JSON.stringify(PROMPT_PRIMARY_SELECTOR);
146
192
  const fallbackSelectorLiteral = JSON.stringify(PROMPT_FALLBACK_SELECTOR);
193
+ const inputSelectorsLiteral = JSON.stringify(INPUT_SELECTORS);
147
194
  const result = await Runtime.evaluate({
148
195
  expression: `(() => {
149
196
  const fallback = document.querySelector(${fallbackSelectorLiteral});
150
197
  const editor = document.querySelector(${primarySelectorLiteral});
198
+ const inputSelectors = ${inputSelectorsLiteral};
151
199
  let cleared = false;
152
200
  if (fallback) {
153
201
  fallback.value = '';
@@ -160,6 +208,24 @@ export async function clearPromptComposer(Runtime, logger) {
160
208
  editor.dispatchEvent(new InputEvent('input', { bubbles: true, data: '', inputType: 'deleteByCut' }));
161
209
  cleared = true;
162
210
  }
211
+ const nodes = inputSelectors
212
+ .map((selector) => document.querySelector(selector))
213
+ .filter((node) => Boolean(node));
214
+ for (const node of nodes) {
215
+ if (!node) continue;
216
+ if (node instanceof HTMLTextAreaElement) {
217
+ node.value = '';
218
+ node.dispatchEvent(new InputEvent('input', { bubbles: true, data: '', inputType: 'deleteByCut' }));
219
+ node.dispatchEvent(new Event('change', { bubbles: true }));
220
+ cleared = true;
221
+ continue;
222
+ }
223
+ if (node.isContentEditable || node.getAttribute('contenteditable') === 'true') {
224
+ node.textContent = '';
225
+ node.dispatchEvent(new InputEvent('input', { bubbles: true, data: '', inputType: 'deleteByCut' }));
226
+ cleared = true;
227
+ }
228
+ }
163
229
  return { cleared };
164
230
  })()`,
165
231
  returnByValue: true,
@@ -281,15 +347,34 @@ async function verifyPromptCommitted(Runtime, prompt, timeoutMs, logger, baselin
281
347
  const encodedPrompt = JSON.stringify(prompt.trim());
282
348
  const primarySelectorLiteral = JSON.stringify(PROMPT_PRIMARY_SELECTOR);
283
349
  const fallbackSelectorLiteral = JSON.stringify(PROMPT_FALLBACK_SELECTOR);
350
+ const inputSelectorsLiteral = JSON.stringify(INPUT_SELECTORS);
284
351
  const stopSelectorLiteral = JSON.stringify(STOP_BUTTON_SELECTOR);
285
352
  const assistantSelectorLiteral = JSON.stringify(ASSISTANT_ROLE_SELECTOR);
286
- const baselineLiteral = typeof baselineTurns === 'number' && Number.isFinite(baselineTurns) && baselineTurns >= 0
353
+ const turnSelectorLiteral = JSON.stringify(CONVERSATION_TURN_SELECTOR);
354
+ let baseline = typeof baselineTurns === 'number' && Number.isFinite(baselineTurns) && baselineTurns >= 0
287
355
  ? Math.floor(baselineTurns)
288
- : -1;
356
+ : null;
357
+ if (baseline === null) {
358
+ try {
359
+ const { result } = await Runtime.evaluate({
360
+ expression: `document.querySelectorAll(${turnSelectorLiteral}).length`,
361
+ returnByValue: true,
362
+ });
363
+ const raw = typeof result?.value === 'number' ? result.value : Number(result?.value);
364
+ if (Number.isFinite(raw)) {
365
+ baseline = Math.max(0, Math.floor(raw));
366
+ }
367
+ }
368
+ catch {
369
+ // ignore; baseline stays unknown
370
+ }
371
+ }
372
+ const baselineLiteral = baseline ?? -1;
289
373
  // Learned: ChatGPT can echo/format text; normalize markdown and use prefix matches to detect the sent prompt.
290
374
  const script = `(() => {
291
- const editor = document.querySelector(${primarySelectorLiteral});
292
- const fallback = document.querySelector(${fallbackSelectorLiteral});
375
+ const editor = document.querySelector(${primarySelectorLiteral});
376
+ const fallback = document.querySelector(${fallbackSelectorLiteral});
377
+ const inputSelectors = ${inputSelectorsLiteral};
293
378
  const normalize = (value) => {
294
379
  let text = value?.toLowerCase?.() ?? '';
295
380
  // Strip markdown *markers* but keep content (ChatGPT renders fence markers differently).
@@ -303,35 +388,53 @@ async function verifyPromptCommitted(Runtime, prompt, timeoutMs, logger, baselin
303
388
  const CONVERSATION_SELECTOR = ${JSON.stringify(CONVERSATION_TURN_SELECTOR)};
304
389
  const articles = Array.from(document.querySelectorAll(CONVERSATION_SELECTOR));
305
390
  const normalizedTurns = articles.map((node) => normalize(node?.innerText));
391
+ const readValue = (node) => {
392
+ if (!node) return '';
393
+ if (node instanceof HTMLTextAreaElement) return node.value ?? '';
394
+ return node.innerText ?? '';
395
+ };
396
+ const isVisible = (node) => {
397
+ if (!node || typeof node.getBoundingClientRect !== 'function') return false;
398
+ const rect = node.getBoundingClientRect();
399
+ return rect.width > 0 && rect.height > 0;
400
+ };
401
+ const inputs = inputSelectors
402
+ .map((selector) => document.querySelector(selector))
403
+ .filter((node) => Boolean(node));
404
+ const visibleInputs = inputs.filter((node) => isVisible(node));
405
+ const activeInputs = visibleInputs.length > 0 ? visibleInputs : inputs;
306
406
  const userMatched =
307
407
  normalizedPrompt.length > 0 && normalizedTurns.some((text) => text.includes(normalizedPrompt));
308
408
  const prefixMatched =
309
409
  normalizedPromptPrefix.length > 30 &&
310
410
  normalizedTurns.some((text) => text.includes(normalizedPromptPrefix));
311
- const lastTurn = normalizedTurns[normalizedTurns.length - 1] ?? '';
312
- const lastMatched =
313
- normalizedPrompt.length > 0 &&
314
- (lastTurn.includes(normalizedPrompt) ||
315
- (normalizedPromptPrefix.length > 30 && lastTurn.includes(normalizedPromptPrefix)));
316
- const baseline = ${baselineLiteral};
317
- const hasNewTurn = baseline < 0 ? true : normalizedTurns.length > baseline;
318
- const stopVisible = Boolean(document.querySelector(${stopSelectorLiteral}));
319
- const assistantVisible = Boolean(
320
- document.querySelector(${assistantSelectorLiteral}) ||
321
- document.querySelector('[data-testid*="assistant"]'),
322
- );
323
- // Learned: composer clearing + stop button or assistant presence is a reliable fallback signal.
411
+ const lastTurn = normalizedTurns[normalizedTurns.length - 1] ?? '';
412
+ const lastMatched =
413
+ normalizedPrompt.length > 0 &&
414
+ (lastTurn.includes(normalizedPrompt) ||
415
+ (normalizedPromptPrefix.length > 30 && lastTurn.includes(normalizedPromptPrefix)));
416
+ const baseline = ${baselineLiteral};
417
+ const hasNewTurn = baseline < 0 ? false : normalizedTurns.length > baseline;
418
+ const stopVisible = Boolean(document.querySelector(${stopSelectorLiteral}));
419
+ const assistantVisible = Boolean(
420
+ document.querySelector(${assistantSelectorLiteral}) ||
421
+ document.querySelector('[data-testid*="assistant"]'),
422
+ );
423
+ // Learned: composer clearing + stop button or assistant presence is a reliable fallback signal.
324
424
  const editorValue = editor?.innerText ?? '';
325
425
  const fallbackValue = fallback?.value ?? '';
326
- const composerCleared = !(String(editorValue).trim() || String(fallbackValue).trim());
426
+ const activeEmpty =
427
+ activeInputs.length === 0 ? null : activeInputs.every((node) => !String(readValue(node)).trim());
428
+ const composerCleared = activeEmpty ?? !(String(editorValue).trim() || String(fallbackValue).trim());
327
429
  const href = typeof location === 'object' && location.href ? location.href : '';
328
430
  const inConversation = /\\/c\\//.test(href);
329
- return {
330
- userMatched,
331
- prefixMatched,
332
- lastMatched,
333
- hasNewTurn,
334
- stopVisible,
431
+ return {
432
+ baseline,
433
+ userMatched,
434
+ prefixMatched,
435
+ lastMatched,
436
+ hasNewTurn,
437
+ stopVisible,
335
438
  assistantVisible,
336
439
  composerCleared,
337
440
  inConversation,
@@ -346,12 +449,14 @@ async function verifyPromptCommitted(Runtime, prompt, timeoutMs, logger, baselin
346
449
  const { result } = await Runtime.evaluate({ expression: script, returnByValue: true });
347
450
  const info = result.value;
348
451
  const turnsCount = result.value?.turnsCount;
349
- if (info?.hasNewTurn && (info?.lastMatched || info?.userMatched || info?.prefixMatched)) {
452
+ const matchesPrompt = Boolean(info?.lastMatched || info?.userMatched || info?.prefixMatched);
453
+ const baselineUnknown = typeof info?.baseline === 'number' ? info.baseline < 0 : baselineLiteral < 0;
454
+ if (matchesPrompt && (baselineUnknown || info?.hasNewTurn)) {
350
455
  return typeof turnsCount === 'number' && Number.isFinite(turnsCount) ? turnsCount : null;
351
456
  }
352
457
  const fallbackCommit = info?.composerCleared &&
353
- ((info?.stopVisible ?? false) ||
354
- (info?.hasNewTurn && (info?.assistantVisible || info?.inConversation)));
458
+ Boolean(info?.hasNewTurn) &&
459
+ ((info?.stopVisible ?? false) || info?.assistantVisible || info?.inConversation);
355
460
  if (fallbackCommit) {
356
461
  return typeof turnsCount === 'number' && Number.isFinite(turnsCount) ? turnsCount : null;
357
462
  }
@@ -374,3 +479,7 @@ async function verifyPromptCommitted(Runtime, prompt, timeoutMs, logger, baselin
374
479
  }
375
480
  throw new Error('Prompt did not appear in conversation before timeout (send may have failed)');
376
481
  }
482
+ // biome-ignore lint/style/useNamingConvention: test-only export used in vitest suite
483
+ export const __test__ = {
484
+ verifyPromptCommitted,
485
+ };