@steipete/oracle 0.15.2 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/README.md +1 -1
  2. package/dist/bin/oracle-cli.js +1 -1
  3. package/dist/bin/oracle-mcp.js +0 -0
  4. package/dist/src/browser/actions/assistantResponse.js +218 -126
  5. package/dist/src/browser/actions/modelSelection.js +133 -22
  6. package/dist/src/browser/actions/navigation.js +235 -14
  7. package/dist/src/browser/actions/thinkingStatus.js +228 -0
  8. package/dist/src/browser/actions/thinkingTime.js +68 -11
  9. package/dist/src/browser/cdpLiveness.js +78 -0
  10. package/dist/src/browser/chromeLifecycle.js +112 -30
  11. package/dist/src/browser/config.js +14 -1
  12. package/dist/src/browser/constants.js +5 -1
  13. package/dist/src/browser/controlPlan.js +2 -2
  14. package/dist/src/browser/conversationUrl.js +16 -0
  15. package/dist/src/browser/conversationUrlMonitor.js +2 -4
  16. package/dist/src/browser/cookies.js +6 -4
  17. package/dist/src/browser/index.js +213 -57
  18. package/dist/src/browser/liveTabs.js +115 -33
  19. package/dist/src/browser/pageActions.js +1 -1
  20. package/dist/src/browser/projectSourcesRunner.js +4 -4
  21. package/dist/src/browser/reattach.js +2 -2
  22. package/dist/src/browser/reattachHelpers.js +3 -5
  23. package/dist/src/browser/reattachability.js +2 -1
  24. package/dist/src/browser/recoverConversation.js +90 -29
  25. package/dist/src/browser/tabLeaseRegistry.js +9 -3
  26. package/dist/src/cli/browserConfig.js +5 -1
  27. package/dist/src/cli/browserDefaults.js +4 -1
  28. package/dist/src/cli/browserTabs.js +54 -33
  29. package/dist/src/cli/options.js +24 -0
  30. package/dist/src/cli/runOptions.js +6 -1
  31. package/dist/src/cli/sessionDisplay.js +38 -14
  32. package/dist/src/cli/sessionRunner.js +49 -2
  33. package/dist/src/oracle/config.js +25 -0
  34. package/dist/src/oracle/geminiModels.js +2 -0
  35. package/dist/src/remote/server.js +8 -0
  36. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  37. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  38. package/package.json +13 -14
  39. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  40. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  41. package/dist/bin/oracle.js +0 -569
  42. package/dist/docs-site/.nojekyll +0 -0
  43. package/dist/docs-site/CNAME +0 -1
  44. package/dist/docs-site/RELEASING.html +0 -410
  45. package/dist/docs-site/agents.html +0 -374
  46. package/dist/docs-site/anthropic.html +0 -368
  47. package/dist/docs-site/bridge.html +0 -416
  48. package/dist/docs-site/browser-mode.html +0 -594
  49. package/dist/docs-site/chromium-forks.html +0 -347
  50. package/dist/docs-site/cli-reference.html +0 -346
  51. package/dist/docs-site/configuration.html +0 -462
  52. package/dist/docs-site/favicon.svg +0 -14
  53. package/dist/docs-site/followup.html +0 -375
  54. package/dist/docs-site/gemini.html +0 -383
  55. package/dist/docs-site/grok.html +0 -325
  56. package/dist/docs-site/index.html +0 -360
  57. package/dist/docs-site/install.html +0 -335
  58. package/dist/docs-site/linux.html +0 -321
  59. package/dist/docs-site/llms.txt +0 -43
  60. package/dist/docs-site/manual-tests.html +0 -596
  61. package/dist/docs-site/mcp.html +0 -391
  62. package/dist/docs-site/multimodel.html +0 -364
  63. package/dist/docs-site/mythical-pro-agents.html +0 -360
  64. package/dist/docs-site/notifier.html +0 -338
  65. package/dist/docs-site/openai-endpoints.html +0 -399
  66. package/dist/docs-site/openrouter.html +0 -344
  67. package/dist/docs-site/quickstart.html +0 -369
  68. package/dist/docs-site/refactor/ux.html +0 -532
  69. package/dist/docs-site/sessions.html +0 -388
  70. package/dist/docs-site/social-card.png +0 -0
  71. package/dist/docs-site/social-card.svg +0 -79
  72. package/dist/docs-site/spec.html +0 -363
  73. package/dist/docs-site/testing.html +0 -320
  74. package/dist/docs-site/tui-debug.html +0 -326
  75. package/dist/docs-site/windows-work.html +0 -323
  76. package/dist/docs-site/windows.html +0 -320
  77. package/dist/src/browser/chromeCookies.js +0 -312
  78. package/dist/src/browser/keytarShim.js +0 -56
  79. package/dist/src/browser/windowsCookies.js +0 -219
@@ -2,18 +2,15 @@ import { rm } from "node:fs/promises";
2
2
  import { readFileSync } from "node:fs";
3
3
  import os from "node:os";
4
4
  import net from "node:net";
5
- import { execFile } from "node:child_process";
6
- import { promisify } from "node:util";
7
5
  import CDP from "chrome-remote-interface";
8
6
  import { launch, Launcher } from "chrome-launcher";
9
7
  import { cleanupStaleProfileState } from "./profileState.js";
10
8
  import { delay } from "./utils.js";
11
- const execFileAsync = promisify(execFile);
12
9
  export async function launchChrome(config, userDataDir, logger) {
13
10
  const connectHost = resolveRemoteDebugHost();
14
11
  const debugBindAddress = connectHost && connectHost !== "127.0.0.1" ? "0.0.0.0" : connectHost;
15
12
  const debugPort = config.debugPort ?? parseDebugPortEnv();
16
- const chromeFlags = buildChromeFlags(config.headless ?? false, debugBindAddress);
13
+ const chromeFlags = buildChromeFlags(config.headless ?? false, debugBindAddress, config.hideWindow ?? false);
17
14
  const usePatchedLauncher = Boolean(connectHost && connectHost !== "127.0.0.1");
18
15
  // copy-profile reuses a copied signed-in profile whose cookies are
19
16
  // Keychain-encrypted, so it must launch with the real Keychain (not mocked):
@@ -46,6 +43,24 @@ export async function launchChrome(config, userDataDir, logger) {
46
43
  logger(`Launched Chrome${pidLabel} on port ${launcher.port}${hostLabel}`);
47
44
  return Object.assign(launcher, { host: connectHost ?? "127.0.0.1" });
48
45
  }
46
+ export async function positionChromeWindowOffscreen(client, logger) {
47
+ if (process.platform !== "darwin") {
48
+ logger("Window hiding is only supported on macOS");
49
+ return;
50
+ }
51
+ try {
52
+ const { windowId } = await client.Browser.getWindowForTarget();
53
+ await client.Browser.setWindowBounds({
54
+ windowId,
55
+ bounds: { left: -32_000, top: -32_000, windowState: "normal" },
56
+ });
57
+ logger("Chrome window positioned off-screen");
58
+ }
59
+ catch (error) {
60
+ const message = error instanceof Error ? error.message : String(error);
61
+ logger(`Failed to position Chrome window off-screen: ${message}`);
62
+ }
63
+ }
49
64
  export function registerTerminationHooks(chrome, userDataDir, keepBrowser, logger, opts) {
50
65
  const signals = ["SIGINT", "SIGTERM", "SIGQUIT"];
51
66
  let handling;
@@ -110,29 +125,6 @@ export function registerTerminationHooks(chrome, userDataDir, keepBrowser, logge
110
125
  }
111
126
  };
112
127
  }
113
- export async function hideChromeWindow(chrome, logger) {
114
- if (process.platform !== "darwin") {
115
- logger("Window hiding is only supported on macOS");
116
- return;
117
- }
118
- if (!chrome.pid) {
119
- logger("Unable to hide window: missing Chrome PID");
120
- return;
121
- }
122
- const script = `tell application "System Events"
123
- try
124
- set visible of (first process whose unix id is ${chrome.pid}) to false
125
- end try
126
- end tell`;
127
- try {
128
- await execFileAsync("osascript", ["-e", script]);
129
- logger("Chrome window hidden (Cmd-H)");
130
- }
131
- catch (error) {
132
- const message = error instanceof Error ? error.message : String(error);
133
- logger(`Failed to hide Chrome window: ${message}`);
134
- }
135
- }
136
128
  export async function connectToChrome(port, logger, host) {
137
129
  const client = await CDP({ port, host });
138
130
  logger("Connected to Chrome DevTools protocol");
@@ -414,13 +406,79 @@ export async function closeTab(port, targetId, logger, host) {
414
406
  const effectiveHost = host ?? "127.0.0.1";
415
407
  try {
416
408
  await CDP.Close({ host: effectiveHost, port, id: targetId });
417
- logger(`Closed isolated browser tab (target=${targetId})`);
409
+ for (let attempt = 0; attempt < 40; attempt += 1) {
410
+ await delay(25);
411
+ let targets;
412
+ try {
413
+ targets = (await CDP.List({ host: effectiveHost, port }));
414
+ }
415
+ catch {
416
+ continue;
417
+ }
418
+ if (!targets.some((target) => (target.targetId ?? target.id) === targetId)) {
419
+ logger(`Closed isolated browser tab (target=${targetId})`);
420
+ return true;
421
+ }
422
+ }
423
+ logger(`Browser tab close was not confirmed (target=${targetId})`);
424
+ return false;
418
425
  }
419
426
  catch (error) {
427
+ try {
428
+ const targets = (await CDP.List({ host: effectiveHost, port }));
429
+ if (!targets.some((target) => (target.targetId ?? target.id) === targetId)) {
430
+ logger(`Closed isolated browser tab (target=${targetId})`);
431
+ return true;
432
+ }
433
+ }
434
+ catch {
435
+ // Preserve the original close error below.
436
+ }
420
437
  const message = error instanceof Error ? error.message : String(error);
421
438
  logger(`Failed to close browser tab ${targetId}: ${message}`);
439
+ return false;
422
440
  }
423
441
  }
442
+ export async function createChromePageTarget(port, logger, host) {
443
+ const effectiveHost = host ?? "127.0.0.1";
444
+ try {
445
+ const created = (await CDP.New({
446
+ host: effectiveHost,
447
+ port,
448
+ url: "about:blank",
449
+ }));
450
+ const createdTargetId = created.targetId ?? created.id;
451
+ if (!createdTargetId) {
452
+ logger("Failed to create a replacement Chrome tab.");
453
+ return undefined;
454
+ }
455
+ logger(`Opened replacement Chrome tab (target=${createdTargetId})`);
456
+ return createdTargetId;
457
+ }
458
+ catch (error) {
459
+ const message = error instanceof Error ? error.message : String(error);
460
+ logger(`Failed to create a replacement Chrome tab: ${message}`);
461
+ return undefined;
462
+ }
463
+ }
464
+ export async function ensureChromePageTargetAfterClose(port, closingTargetId, logger, host) {
465
+ const effectiveHost = host ?? "127.0.0.1";
466
+ try {
467
+ const targets = (await CDP.List({ host: effectiveHost, port }));
468
+ const existingPageTargetId = targets
469
+ .filter((target) => target.type === "page")
470
+ .map((target) => target.targetId ?? target.id)
471
+ .find((targetId) => Boolean(targetId) && targetId !== closingTargetId);
472
+ if (existingPageTargetId) {
473
+ return existingPageTargetId;
474
+ }
475
+ }
476
+ catch (error) {
477
+ const message = error instanceof Error ? error.message : String(error);
478
+ logger(`Failed to inspect Chrome tabs before closing ${closingTargetId}: ${message}`);
479
+ }
480
+ return await createChromePageTarget(port, logger, host);
481
+ }
424
482
  export async function closeBlankChromeTabs(port, logger, host, options) {
425
483
  const effectiveHost = host ?? "127.0.0.1";
426
484
  const excluded = new Set([...(options?.excludeTargetIds ?? [])].filter((targetId) => typeof targetId === "string" && targetId.length > 0));
@@ -433,10 +491,20 @@ export async function closeBlankChromeTabs(port, logger, host, options) {
433
491
  logger(`Failed to inspect blank Chrome tabs: ${message}`);
434
492
  return;
435
493
  }
494
+ const preservedBlankTargetId = options?.preserveOneBlank
495
+ ? targets
496
+ .filter(isBlankPageTarget)
497
+ .map((target) => target.targetId ?? target.id)
498
+ .filter((targetId) => Boolean(targetId))
499
+ .sort()[0]
500
+ : undefined;
436
501
  let closed = 0;
437
502
  for (const target of targets) {
438
503
  const targetId = target.targetId ?? target.id;
439
- if (!targetId || excluded.has(targetId) || !isBlankPageTarget(target)) {
504
+ if (!targetId ||
505
+ targetId === preservedBlankTargetId ||
506
+ excluded.has(targetId) ||
507
+ !isBlankPageTarget(target)) {
440
508
  continue;
441
509
  }
442
510
  try {
@@ -459,7 +527,7 @@ function isBlankPageTarget(target) {
459
527
  const url = (target.url ?? "").trim().toLowerCase();
460
528
  return url === "about:blank" || url === "chrome://newtab/" || url === "chrome://new-tab-page/";
461
529
  }
462
- function buildChromeFlags(headless, debugBindAddress) {
530
+ function buildChromeFlags(headless, debugBindAddress, hideWindow = false) {
463
531
  const flags = [
464
532
  "--disable-background-networking",
465
533
  "--disable-background-timer-throttling",
@@ -489,8 +557,22 @@ function buildChromeFlags(headless, debugBindAddress) {
489
557
  if (headless) {
490
558
  flags.push("--headless=new");
491
559
  }
560
+ else if (hideWindow && process.platform === "darwin") {
561
+ // Cmd-H stops macOS Chrome from compositing the page, which can swallow
562
+ // trusted CDP clicks and retain the prompt as a draft. Keeping the window
563
+ // off-screen avoids desktop disruption while preserving normal rendering.
564
+ flags.push("--window-position=-32000,-32000");
565
+ }
566
+ // Opt-in only: container/CI Chromium often cannot use the sandbox. Callers must
567
+ // set ORACLE_CHROME_NO_SANDBOX=1 explicitly (never default this on).
568
+ if (process.env.ORACLE_CHROME_NO_SANDBOX === "1") {
569
+ flags.push("--no-sandbox", "--disable-dev-shm-usage");
570
+ }
492
571
  return flags;
493
572
  }
573
+ export function buildChromeFlagsForTest(headless, debugBindAddress, hideWindow = false) {
574
+ return buildChromeFlags(headless, debugBindAddress, hideWindow);
575
+ }
494
576
  function resolveChromeLaunchOptions(chromeFlags, usingCopiedProfile) {
495
577
  if (!usingCopiedProfile) {
496
578
  return { chromeFlags, ignoreDefaultFlags: false };
@@ -61,6 +61,7 @@ export function resolveBrowserConfig(config) {
61
61
  const debugPortEnv = parseDebugPort(process.env.ORACLE_BROWSER_PORT ?? process.env.ORACLE_BROWSER_DEBUG_PORT);
62
62
  const envAllowCookieErrors = (process.env.ORACLE_BROWSER_ALLOW_COOKIE_ERRORS ?? "").trim().toLowerCase() === "true" ||
63
63
  (process.env.ORACLE_BROWSER_ALLOW_COOKIE_ERRORS ?? "").trim() === "1";
64
+ const envMaxConcurrentTabs = parseMaxConcurrentTabs(process.env.ORACLE_BROWSER_MAX_CONCURRENT_TABS);
64
65
  const rawUrl = config?.chatgptUrl ?? config?.url ?? DEFAULT_BROWSER_CONFIG.url;
65
66
  const normalizedUrl = normalizeChatgptUrl(rawUrl ?? DEFAULT_BROWSER_CONFIG.url, DEFAULT_BROWSER_CONFIG.url);
66
67
  const desiredModel = config?.desiredModel ?? DEFAULT_BROWSER_CONFIG.desiredModel ?? DEFAULT_MODEL_TARGET;
@@ -87,7 +88,7 @@ export function resolveBrowserConfig(config) {
87
88
  assistantRecheckTimeoutMs: config?.assistantRecheckTimeoutMs ?? DEFAULT_BROWSER_CONFIG.assistantRecheckTimeoutMs,
88
89
  reuseChromeWaitMs: config?.reuseChromeWaitMs ?? DEFAULT_BROWSER_CONFIG.reuseChromeWaitMs,
89
90
  profileLockTimeoutMs: config?.profileLockTimeoutMs ?? DEFAULT_BROWSER_CONFIG.profileLockTimeoutMs,
90
- maxConcurrentTabs: normalizeMaxConcurrentTabs(config?.maxConcurrentTabs ?? DEFAULT_BROWSER_CONFIG.maxConcurrentTabs),
91
+ maxConcurrentTabs: normalizeMaxConcurrentTabs(config?.maxConcurrentTabs ?? envMaxConcurrentTabs ?? DEFAULT_BROWSER_CONFIG.maxConcurrentTabs),
91
92
  autoReattachDelayMs: config?.autoReattachDelayMs ?? DEFAULT_BROWSER_CONFIG.autoReattachDelayMs,
92
93
  autoReattachIntervalMs: config?.autoReattachIntervalMs ?? DEFAULT_BROWSER_CONFIG.autoReattachIntervalMs,
93
94
  autoReattachTimeoutMs: config?.autoReattachTimeoutMs ?? DEFAULT_BROWSER_CONFIG.autoReattachTimeoutMs,
@@ -134,6 +135,18 @@ function parseDebugPort(raw) {
134
135
  }
135
136
  return value;
136
137
  }
138
+ function parseMaxConcurrentTabs(raw) {
139
+ if (!raw)
140
+ return null;
141
+ const trimmed = raw.trim();
142
+ if (!/^\d+$/.test(trimmed))
143
+ return null;
144
+ const value = Number(trimmed);
145
+ if (!Number.isInteger(value) || value <= 0) {
146
+ return null;
147
+ }
148
+ return value;
149
+ }
137
150
  function resolveManualLoginProfileDir(...candidates) {
138
151
  for (const candidate of candidates) {
139
152
  const profileDir = candidate?.trim();
@@ -64,10 +64,14 @@ export const UPLOAD_STATUS_SELECTORS = [
64
64
  '[aria-live="assertive"]',
65
65
  ];
66
66
  export const STOP_BUTTON_SELECTOR = '[data-testid="stop-button"]';
67
+ // The aria-label fallback exists for data-testid drift, but a document-wide match makes ANY
68
+ // visible "stop" control (read-aloud, voice/dictation) read as "still generating", which blocks
69
+ // completion until the response timeout. Scope it to the composer form and exclude the known
70
+ // non-generation stop controls that legitimately live there.
67
71
  export const STOP_BUTTON_SELECTORS = [
68
72
  STOP_BUTTON_SELECTOR,
69
73
  '[data-testid="composer-stop-button"]',
70
- 'button[aria-label*="stop" i]',
74
+ 'form button[aria-label*="stop" i]:not([aria-label*="dictat" i]):not([aria-label*="voice" i]):not([aria-label*="read" i])',
71
75
  ];
72
76
  export const SEND_BUTTON_SELECTORS = [
73
77
  'button[data-testid="send-button"]',
@@ -45,13 +45,13 @@ export function describeBrowserControlPlan(config = {}) {
45
45
  };
46
46
  }
47
47
  if (config.hideWindow) {
48
- guidance.push("Chrome may briefly focus while launching before Oracle hides it.");
48
+ guidance.push("On macOS, Oracle launches Chrome off-screen while keeping the page rendered.");
49
49
  guidance.push("For the calmest shared-desktop flow, prefer --browser-attach-running or --remote-chrome.");
50
50
  return {
51
51
  mode: "hidden-window",
52
52
  launchesChrome: true,
53
53
  mayFocusWindow: true,
54
- summary: "launch Chrome and hide the window after startup",
54
+ summary: "launch Chrome in hidden-window mode",
55
55
  guidance,
56
56
  };
57
57
  }
@@ -0,0 +1,16 @@
1
+ const CONVERSATION_ID_PATH = /\/c\/([a-zA-Z0-9-]+)(?=[/?#]|$)/;
2
+ /**
3
+ * Extract a durable ChatGPT conversation id from a URL.
4
+ *
5
+ * ChatGPT can briefly expose client-created routes such as `/c/WEB:<request-id>`
6
+ * before replacing them with the persisted conversation URL. Those transient
7
+ * routes must not be used to scope assistant-response capture or reattachment.
8
+ */
9
+ export function extractStableConversationIdFromUrl(url) {
10
+ if (!url)
11
+ return undefined;
12
+ return url.match(CONVERSATION_ID_PATH)?.[1];
13
+ }
14
+ export function isStableConversationUrl(url) {
15
+ return extractStableConversationIdFromUrl(url) !== undefined;
16
+ }
@@ -1,4 +1,5 @@
1
1
  import { delay } from "./utils.js";
2
+ import { isStableConversationUrl } from "./conversationUrl.js";
2
3
  export function createConversationUrlMonitor(options) {
3
4
  const pollIntervalMs = options.pollIntervalMs ?? 250;
4
5
  const wait = options.wait ?? delay;
@@ -14,7 +15,7 @@ export function createConversationUrlMonitor(options) {
14
15
  if (stopped) {
15
16
  return false;
16
17
  }
17
- if (url && isConversationUrl(url)) {
18
+ if (url && isStableConversationUrl(url)) {
18
19
  options.logger(`[browser] conversation url (${label}) = ${url}`);
19
20
  const persist = options.persistUrl(url);
20
21
  activePersists.add(persist);
@@ -59,6 +60,3 @@ export function createConversationUrlMonitor(options) {
59
60
  },
60
61
  };
61
62
  }
62
- function isConversationUrl(url) {
63
- return /\/c\/[a-z0-9-]+/i.test(url);
64
- }
@@ -244,12 +244,14 @@ function normalizeExpiration(expires) {
244
244
  if (value <= 0) {
245
245
  return undefined;
246
246
  }
247
- if (value > 1_000_000_000_000) {
248
- // Learned: Chrome may store WebKit microseconds since 1601; convert to Unix seconds.
247
+ // Units by magnitude (do not treat Unix seconds ~1.7e9 as milliseconds):
248
+ // - >= 1e15: Chrome/WebKit FILETIME microseconds since 1601
249
+ // - >= 1e12: Unix milliseconds
250
+ // - else: Unix seconds (sweet-cookie / Chromium Cookie.expires)
251
+ if (value >= 1_000_000_000_000_000) {
249
252
  return Math.round(value / 1_000_000 - 11644473600);
250
253
  }
251
- if (value > 1_000_000_000) {
252
- // Likely milliseconds; normalize to seconds for CDP.
254
+ if (value >= 1_000_000_000_000) {
253
255
  return Math.round(value / 1000);
254
256
  }
255
257
  return Math.round(value);