Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ oracle --engine browser \
| `--browser-manual-login` | Skip cookie copy; reuse a persistent automation profile and wait for manual ChatGPT login. |
| `--browser-thinking-time <light\|standard\|extended\|heavy>` | Set ChatGPT thinking-time intensity (browser; Thinking/Pro models only). |
| `--browser-port <port>` | Pin the Chrome DevTools port (WSL/Windows firewall helper). |
| `--browser-inline-cookies[(-file)] <payload \| path>` | Supply cookies without Chrome/Keychain (browser). |
| `--browser-inline-cookies[(-file)] <payload \| path>` | Supply cookies without Chrome/Keychain (browser). |
| `--browser-timeout`, `--browser-input-timeout` | Control overall/browser input timeouts (supports h/m/s/ms). |
| `--browser-recheck-delay`, `--browser-recheck-timeout` | Delayed recheck for long Pro runs: wait then retry capture after timeout (supports h/m/s/ms). |
| `--browser-reuse-wait` | Wait for a shared Chrome profile before launching (parallel browser runs). |
Expand Down
43 changes: 43 additions & 0 deletions bin/oracle-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ import {
import { isErrorLogged } from "../src/cli/errorUtils.js";
import { handleSessionAlias, handleStatusFlag } from "../src/cli/rootAlias.js";
import { resolveOutputPath } from "../src/cli/writeOutputPath.js";
import { showBrowserTabsStatus } from "../src/cli/browserTabs.js";
import { getCliVersion } from "../src/version.js";
import { runDryRunSummary, runBrowserPreview } from "../src/cli/dryRun.js";
import { launchTui } from "../src/cli/tui/index.js";
Expand Down Expand Up @@ -138,6 +139,7 @@ interface CliOptions extends OptionValues {
browserHeadless?: boolean;
browserHideWindow?: boolean;
browserKeepBrowser?: boolean;
browserTab?: string;
browserModelStrategy?: "select" | "current" | "ignore";
browserManualLogin?: boolean;
browserManualLoginProfileDir?: string;
Expand Down Expand Up @@ -612,6 +614,10 @@ program
"Connect to remote Chrome DevTools Protocol (e.g., 192.168.1.10:9222 or [2001:db8::1]:9222 for IPv6).",
),
)
.option(
"--browser-tab <ref>",
"Reuse an existing ChatGPT tab by ref (current, target id, full URL, or title substring) instead of opening a new tab.",
)
.addOption(
new Option(
"--remote-host <host:port>",
Expand Down Expand Up @@ -835,6 +841,24 @@ program
.option("--render-markdown", "Alias for --render.", false)
.option("--model <name>", "Filter sessions/output for a specific model.", "")
.option("--path", "Print the stored session paths instead of attaching.", false)
.option(
"--harvest",
"Re-read the bound browser tab and print/save the latest assistant output.",
false,
)
.option(
"--live",
"Tail the live browser tab for this session until it completes, stalls, or detaches.",
false,
)
.option(
"--write-output <path>",
"Write harvested browser output to this file (requires --harvest or --live).",
)
.option(
"--browser-tab <ref>",
"Override the browser tab ref used for harvesting/live tail (current, target id, URL, or title substring).",
)
.addOption(new Option("--clean", "Deprecated alias for --clear.").default(false).hideHelp())
.action(async (sessionId, _options: StatusOptions, cmd: Command) => {
await handleSessionCommand(sessionId, cmd);
Expand All @@ -853,9 +877,25 @@ program
.option("--render-markdown", "Alias for --render.", false)
.option("--model <name>", "Filter sessions/output for a specific model.", "")
.option("--hide-prompt", "Hide stored prompt when displaying a session.", false)
.option(
"--browser-tabs",
"List live ChatGPT browser tabs and known Oracle session linkage.",
false,
)
.addOption(new Option("--clean", "Deprecated alias for --clear.").default(false).hideHelp())
.action(async (sessionId: string | undefined, _options: StatusOptions, command: Command) => {
const statusOptions = command.opts<StatusOptions>();
if (statusOptions.browserTabs) {
if (sessionId) {
console.error(
"Cannot combine a session ID with --browser-tabs. Remove the ID to inspect live browser tabs.",
);
process.exitCode = 1;
return;
}
await showBrowserTabsStatus();
return;
}
const clearRequested = Boolean(statusOptions.clear || statusOptions.clean);
if (clearRequested) {
if (sessionId) {
Expand Down Expand Up @@ -1334,6 +1374,9 @@ async function runRootCommand(options: CliOptions): Promise<void> {
if (remoteHost && options.remoteChrome) {
throw new Error("--remote-host cannot be combined with --remote-chrome.");
}
if (options.browserTab && engine !== "browser") {
throw new Error("--browser-tab requires --engine browser.");
}

if (optionUsesDefault("azureEndpoint")) {
if (process.env.AZURE_OPENAI_ENDPOINT) {
Expand Down
23 changes: 19 additions & 4 deletions src/browser/actions/modelSelection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,10 +108,13 @@ function buildModelSelectionExpression(
? '5-1'
: normalizedTarget.includes('5 0')
? '5-0'
: null;
: null;
const wantsPro = normalizedTarget.includes(' pro') || normalizedTarget.endsWith(' pro') || normalizedTokens.includes('pro');
const wantsInstant = normalizedTarget.includes('instant');
const wantsThinking = normalizedTarget.includes('thinking');
const hasProComposerPill = () => Boolean(
document.querySelector('button.__composer-pill, button[aria-label="Pro, click to remove"]')
);

const button = document.querySelector(BUTTON_SELECTOR);
if (!button) {
Expand Down Expand Up @@ -140,11 +143,20 @@ function buildModelSelectionExpression(

const getButtonLabel = () => (button.textContent ?? '').trim();
if (MODEL_STRATEGY === 'current') {
return { status: 'already-selected', label: getButtonLabel() };
const currentLabel = getButtonLabel();
return {
status: 'already-selected',
label: wantsPro && normalizeText(currentLabel) === 'chatgpt' && hasProComposerPill()
? currentLabel + ' + Pro'
: currentLabel,
};
}
const buttonMatchesTarget = () => {
const normalizedLabel = normalizeText(getButtonLabel());
if (!normalizedLabel) return false;
if (wantsPro && normalizedLabel === 'chatgpt' && hasProComposerPill()) {
return true;
}
if (desiredVersion) {
if (desiredVersion === '5-4' && !normalizedLabel.includes('5 4')) return false;
if (desiredVersion === '5-2' && !normalizedLabel.includes('5 2')) return false;
Expand Down Expand Up @@ -572,6 +584,9 @@ function buildModelMatchersLiteral(targetModel: string): {
};
}

export function buildModelSelectionExpressionForTest(targetModel: string): string {
return buildModelSelectionExpression(targetModel, "select");
export function buildModelSelectionExpressionForTest(
targetModel: string,
strategy: BrowserModelStrategy = "select",
): string {
return buildModelSelectionExpression(targetModel, strategy);
}
2 changes: 2 additions & 0 deletions src/browser/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export const DEFAULT_BROWSER_CONFIG: ResolvedBrowserConfig = {
chromeProfile: null,
chromePath: null,
chromeCookiePath: null,
browserTabRef: null,
url: CHATGPT_URL,
chatgptUrl: CHATGPT_URL,
timeoutMs: 1_200_000,
Expand Down Expand Up @@ -110,6 +111,7 @@ export function resolveBrowserConfig(
chromeProfile: config?.chromeProfile ?? DEFAULT_BROWSER_CONFIG.chromeProfile,
chromePath: config?.chromePath ?? DEFAULT_BROWSER_CONFIG.chromePath,
chromeCookiePath: config?.chromeCookiePath ?? DEFAULT_BROWSER_CONFIG.chromeCookiePath,
browserTabRef: config?.browserTabRef ?? DEFAULT_BROWSER_CONFIG.browserTabRef,
debug: config?.debug ?? DEFAULT_BROWSER_CONFIG.debug,
allowCookieErrors:
config?.allowCookieErrors ?? envAllowCookieErrors ?? DEFAULT_BROWSER_CONFIG.allowCookieErrors,
Expand Down
2 changes: 2 additions & 0 deletions src/browser/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@ export const COOKIE_URLS = [
export const INPUT_SELECTORS = [
'textarea[data-id="prompt-textarea"]',
'textarea[placeholder*="Send a message"]',
'textarea[aria-label="Chat with ChatGPT"]',
'textarea[aria-label="Message ChatGPT"]',
"textarea:not([disabled])",
'textarea[name="prompt-textarea"]',
"#prompt-textarea",
".ProseMirror",
'[contenteditable="true"][role="textbox"]',
'[contenteditable="true"][data-virtualkeyboard="true"]',
];

Expand Down
80 changes: 63 additions & 17 deletions src/browser/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import {
} from "./profileState.js";
import { runProviderSubmissionFlow } from "./providerDomFlow.js";
import { chatgptDomProvider } from "./providers/index.js";
import { connectToExistingChatGptTab } from "./liveTabs.js";

export type { BrowserAutomationConfig, BrowserRunOptions, BrowserRunResult } from "./types.js";
export { CHATGPT_URL, DEFAULT_MODEL_STRATEGY, DEFAULT_MODEL_TARGET } from "./constants.js";
Expand Down Expand Up @@ -210,6 +211,7 @@ export async function runBrowserMode(options: BrowserRunOptions): Promise<Browse

let client: ChromeClient | null = null;
let isolatedTargetId: string | null = null;
let ownsTarget = true;
const startedAt = Date.now();
let answerText = "";
let answerMarkdown = "";
Expand All @@ -223,14 +225,31 @@ export async function runBrowserMode(options: BrowserRunOptions): Promise<Browse

try {
try {
const strictTabIsolation = Boolean(manualLogin && reusedChrome);
const connection = await connectWithNewTab(chrome.port, logger, undefined, chromeHost, {
fallbackToDefault: !strictTabIsolation,
retries: strictTabIsolation ? 3 : 0,
retryDelayMs: 500,
});
client = connection.client;
isolatedTargetId = connection.targetId ?? null;
if (config.browserTabRef) {
const attached = await connectToExistingChatGptTab({
host: chromeHost,
port: chrome.port,
ref: config.browserTabRef,
});
client = attached.client;
isolatedTargetId = attached.targetId ?? null;
lastTargetId = attached.targetId ?? undefined;
lastUrl = attached.tab.url || lastUrl;
ownsTarget = false;
logger(
`Attached to existing ChatGPT tab ${attached.targetId}${attached.tab.url ? ` (${attached.tab.url})` : ""}`,
);
} else {
const strictTabIsolation = Boolean(manualLogin && reusedChrome);
const connection = await connectWithNewTab(chrome.port, logger, undefined, chromeHost, {
fallbackToDefault: !strictTabIsolation,
retries: strictTabIsolation ? 3 : 0,
retryDelayMs: 500,
});
client = connection.client;
isolatedTargetId = connection.targetId ?? null;
ownsTarget = true;
}
} catch (error) {
const hint = describeDevtoolsFirewallHint(chromeHost, chrome.port);
if (hint) {
Expand Down Expand Up @@ -1008,7 +1027,7 @@ export async function runBrowserMode(options: BrowserRunOptions): Promise<Browse
// Close the isolated tab once the response has been fully captured to prevent
// tab accumulation across repeated runs. Keep the tab open on incomplete runs
// so reattach can recover the response.
if (runStatus === "complete" && isolatedTargetId && chrome?.port) {
if (runStatus === "complete" && isolatedTargetId && chrome?.port && ownsTarget) {
await closeTab(chrome.port, isolatedTargetId, logger, chromeHost).catch(() => undefined);
}
removeDialogHandler?.();
Expand Down Expand Up @@ -1275,6 +1294,8 @@ async function runRemoteBrowserMode(
let client: ChromeClient | null = null;
let remoteTargetId: string | null = null;
let lastUrl: string | undefined;
let attachedExistingTab = false;
let ownsTarget = true;
const runtimeHintCb = options.runtimeHintCb;
const emitRuntimeHint = async () => {
if (!runtimeHintCb) return;
Expand All @@ -1300,9 +1321,26 @@ async function runRemoteBrowserMode(
let removeDialogHandler: (() => void) | null = null;

try {
const connection = await connectToRemoteChrome(host, port, logger, config.url);
client = connection.client;
remoteTargetId = connection.targetId ?? null;
if (config.browserTabRef) {
const attached = await connectToExistingChatGptTab({
host,
port,
ref: config.browserTabRef,
});
client = attached.client;
remoteTargetId = attached.targetId ?? null;
lastUrl = attached.tab.url || lastUrl;
attachedExistingTab = true;
ownsTarget = false;
logger(
`Attached to existing remote ChatGPT tab ${attached.targetId}${attached.tab.url ? ` (${attached.tab.url})` : ""}`,
);
} else {
const connection = await connectToRemoteChrome(host, port, logger, config.url);
client = connection.client;
remoteTargetId = connection.targetId ?? null;
ownsTarget = true;
}
await emitRuntimeHint();
const markConnectionLost = () => {
connectionClosedUnexpectedly = true;
Expand All @@ -1320,10 +1358,16 @@ async function runRemoteBrowserMode(
// Skip cookie sync for remote Chrome - it already has cookies
logger("Skipping cookie sync for remote Chrome (using existing session)");

await navigateToChatGPT(Page, Runtime, config.url, logger);
await ensureNotBlocked(Runtime, config.headless, logger);
await ensureLoggedIn(Runtime, logger, { remoteSession: true });
await ensurePromptReady(Runtime, config.inputTimeoutMs, logger);
if (!attachedExistingTab) {
await navigateToChatGPT(Page, Runtime, config.url, logger);
await ensureNotBlocked(Runtime, config.headless, logger);
await ensureLoggedIn(Runtime, logger, { remoteSession: true });
await ensurePromptReady(Runtime, config.inputTimeoutMs, logger);
} else {
await ensureNotBlocked(Runtime, config.headless, logger);
await ensureLoggedIn(Runtime, logger, { remoteSession: true });
await ensurePromptReady(Runtime, config.inputTimeoutMs, logger);
}
logger(
`Prompt textarea ready (initial focus, ${promptText.length.toLocaleString()} chars queued)`,
);
Expand Down Expand Up @@ -1756,7 +1800,9 @@ async function runRemoteBrowserMode(
// ignore
}
removeDialogHandler?.();
await closeRemoteChromeTarget(host, port, remoteTargetId ?? undefined, logger);
if (ownsTarget) {
await closeRemoteChromeTarget(host, port, remoteTargetId ?? undefined, logger);
}
// Don't kill remote Chrome - it's not ours to manage
const totalSeconds = (Date.now() - startedAt) / 1000;
logger(`Remote session complete • ${totalSeconds.toFixed(1)}s total`);
Expand Down
Loading