@steipete/oracle 0.5.6 → 0.6.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 (34) hide show
  1. package/README.md +3 -3
  2. package/dist/bin/oracle-cli.js +8 -2
  3. package/dist/src/browser/actions/assistantResponse.js +65 -6
  4. package/dist/src/browser/actions/modelSelection.js +22 -0
  5. package/dist/src/browser/actions/promptComposer.js +67 -3
  6. package/dist/src/browser/actions/thinkingTime.js +190 -0
  7. package/dist/src/browser/config.js +1 -0
  8. package/dist/src/browser/constants.js +1 -1
  9. package/dist/src/browser/index.js +106 -74
  10. package/dist/src/browser/pageActions.js +1 -1
  11. package/dist/src/browser/profileState.js +171 -0
  12. package/dist/src/browser/prompt.js +63 -19
  13. package/dist/src/browser/sessionRunner.js +10 -6
  14. package/dist/src/cli/browserConfig.js +6 -2
  15. package/dist/src/cli/sessionDisplay.js +8 -1
  16. package/dist/src/cli/sessionRunner.js +2 -8
  17. package/dist/src/cli/tui/index.js +1 -0
  18. package/dist/src/config.js +2 -3
  19. package/dist/src/oracle/config.js +38 -3
  20. package/dist/src/oracle/errors.js +3 -3
  21. package/dist/src/oracle/gemini.js +4 -1
  22. package/dist/src/oracle/run.js +4 -7
  23. package/dist/src/oracleHome.js +13 -0
  24. package/dist/src/remote/server.js +17 -11
  25. package/dist/src/sessionManager.js +10 -8
  26. package/dist/src/sessionStore.js +2 -2
  27. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  28. package/dist/vendor/oracle-notifier/build-notifier.sh +0 -0
  29. package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  30. package/dist/vendor/oracle-notifier/oracle-notifier/build-notifier.sh +0 -0
  31. package/package.json +22 -38
  32. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  33. package/vendor/oracle-notifier/build-notifier.sh +0 -0
  34. package/vendor/oracle-notifier/README.md +0 -24
@@ -1,16 +1,18 @@
1
- import { mkdtemp, rm, mkdir, readFile } from 'node:fs/promises';
1
+ import { mkdtemp, rm, mkdir } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import os from 'node:os';
4
4
  import net from 'node:net';
5
5
  import { resolveBrowserConfig } from './config.js';
6
6
  import { launchChrome, registerTerminationHooks, hideChromeWindow, connectToChrome, connectToRemoteChrome, closeRemoteChromeTarget, } from './chromeLifecycle.js';
7
7
  import { syncCookies } from './cookies.js';
8
- import { navigateToChatGPT, ensureNotBlocked, ensureLoggedIn, ensurePromptReady, ensureModelSelection, submitPrompt, waitForAssistantResponse, captureAssistantMarkdown, uploadAttachmentFile, waitForAttachmentCompletion, readAssistantSnapshot, } from './pageActions.js';
8
+ import { navigateToChatGPT, ensureNotBlocked, ensureLoggedIn, ensurePromptReady, ensureModelSelection, submitPrompt, clearPromptComposer, waitForAssistantResponse, captureAssistantMarkdown, uploadAttachmentFile, waitForAttachmentCompletion, readAssistantSnapshot, } from './pageActions.js';
9
9
  import { uploadAttachmentViaDataTransfer } from './actions/remoteFileTransfer.js';
10
+ import { ensureExtendedThinking } from './actions/thinkingTime.js';
10
11
  import { estimateTokenCount, withRetries, delay } from './utils.js';
11
12
  import { formatElapsed } from '../oracle/format.js';
12
13
  import { CHATGPT_URL } from './constants.js';
13
14
  import { BrowserAutomationError } from '../oracle/errors.js';
15
+ import { cleanupStaleProfileState, readChromePid, readDevToolsPort, verifyDevToolsReachable, writeChromePid, writeDevToolsActivePort, } from './profileState.js';
14
16
  export { CHATGPT_URL, DEFAULT_MODEL_TARGET } from './constants.js';
15
17
  export { parseDuration, delay, normalizeChatgptUrl } from './utils.js';
16
18
  export async function runBrowserMode(options) {
@@ -19,6 +21,7 @@ export async function runBrowserMode(options) {
19
21
  throw new Error('Prompt text is required when using browser mode.');
20
22
  }
21
23
  const attachments = options.attachments ?? [];
24
+ const fallbackSubmission = options.fallbackSubmission;
22
25
  let config = resolveBrowserConfig(options.config);
23
26
  const logger = options.log ?? ((_message) => { });
24
27
  if (logger.verbose === undefined) {
@@ -96,6 +99,13 @@ export async function runBrowserMode(options) {
96
99
  remoteChrome: config.remoteChrome,
97
100
  }, userDataDir, logger));
98
101
  const chromeHost = chrome.host ?? '127.0.0.1';
102
+ // Persist profile state so future manual-login runs can reuse this Chrome.
103
+ if (manualLogin && chrome.port) {
104
+ await writeDevToolsActivePort(userDataDir, chrome.port);
105
+ if (!reusedChrome && chrome.pid) {
106
+ await writeChromePid(userDataDir, chrome.pid);
107
+ }
108
+ }
99
109
  let removeTerminationHooks = null;
100
110
  try {
101
111
  removeTerminationHooks = registerTerminationHooks(chrome, userDataDir, effectiveKeepBrowser, logger, {
@@ -254,20 +264,49 @@ export async function runBrowserMode(options) {
254
264
  await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger));
255
265
  logger(`Prompt textarea ready (after model switch, ${promptText.length.toLocaleString()} chars queued)`);
256
266
  }
257
- const attachmentNames = attachments.map((a) => path.basename(a.path));
258
- if (attachments.length > 0) {
259
- if (!DOM) {
260
- throw new Error('Chrome DOM domain unavailable while uploading attachments.');
267
+ if (config.extendedThinking) {
268
+ await raceWithDisconnect(withRetries(() => ensureExtendedThinking(Runtime, logger), {
269
+ retries: 2,
270
+ delayMs: 300,
271
+ onRetry: (attempt, error) => {
272
+ if (options.verbose) {
273
+ logger(`[retry] Extended thinking attempt ${attempt + 1}: ${error instanceof Error ? error.message : error}`);
274
+ }
275
+ },
276
+ }));
277
+ }
278
+ const submitOnce = async (prompt, submissionAttachments) => {
279
+ const attachmentNames = submissionAttachments.map((a) => path.basename(a.path));
280
+ if (submissionAttachments.length > 0) {
281
+ if (!DOM) {
282
+ throw new Error('Chrome DOM domain unavailable while uploading attachments.');
283
+ }
284
+ for (const attachment of submissionAttachments) {
285
+ logger(`Uploading attachment: ${attachment.displayPath}`);
286
+ await uploadAttachmentFile({ runtime: Runtime, dom: DOM }, attachment, logger);
287
+ }
288
+ const waitBudget = Math.max(config.inputTimeoutMs ?? 30_000, 30_000);
289
+ await waitForAttachmentCompletion(Runtime, waitBudget, attachmentNames, logger);
290
+ logger('All attachments uploaded');
261
291
  }
262
- for (const attachment of attachments) {
263
- logger(`Uploading attachment: ${attachment.displayPath}`);
264
- await uploadAttachmentFile({ runtime: Runtime, dom: DOM }, attachment, logger);
292
+ await submitPrompt({ runtime: Runtime, input: Input, attachmentNames }, prompt, logger);
293
+ };
294
+ try {
295
+ await raceWithDisconnect(submitOnce(promptText, attachments));
296
+ }
297
+ catch (error) {
298
+ const isPromptTooLarge = error instanceof BrowserAutomationError &&
299
+ error.details?.code === 'prompt-too-large';
300
+ if (fallbackSubmission && isPromptTooLarge) {
301
+ logger('[browser] Inline prompt too large; retrying with file uploads.');
302
+ await raceWithDisconnect(clearPromptComposer(Runtime, logger));
303
+ await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger));
304
+ await raceWithDisconnect(submitOnce(fallbackSubmission.prompt, fallbackSubmission.attachments));
305
+ }
306
+ else {
307
+ throw error;
265
308
  }
266
- const waitBudget = Math.max(config.inputTimeoutMs ?? 30_000, 30_000);
267
- await raceWithDisconnect(waitForAttachmentCompletion(Runtime, waitBudget, attachmentNames, logger));
268
- logger('All attachments uploaded');
269
309
  }
270
- await raceWithDisconnect(submitPrompt({ runtime: Runtime, input: Input, attachmentNames }, promptText, logger));
271
310
  stopThinkingMonitor = startThinkingStatusMonitor(Runtime, logger, options.verbose ?? false);
272
311
  const answer = await raceWithDisconnect(waitForAssistantResponse(Runtime, config.timeoutMs, logger));
273
312
  answerText = answer.text;
@@ -502,57 +541,21 @@ async function maybeReuseRunningChrome(userDataDir, logger) {
502
541
  const port = await readDevToolsPort(userDataDir);
503
542
  if (!port)
504
543
  return null;
505
- const versionUrl = `http://127.0.0.1:${port}/json/version`;
506
- try {
507
- const controller = new AbortController();
508
- const timeout = setTimeout(() => controller.abort(), 1500);
509
- const response = await fetch(versionUrl, { signal: controller.signal });
510
- clearTimeout(timeout);
511
- if (!response.ok)
512
- throw new Error(`HTTP ${response.status}`);
513
- const pidPath = path.join(userDataDir, 'chrome.pid');
514
- let pid;
515
- try {
516
- const rawPid = (await readFile(pidPath, 'utf8')).trim();
517
- pid = Number.parseInt(rawPid, 10);
518
- if (Number.isNaN(pid))
519
- pid = undefined;
520
- }
521
- catch {
522
- pid = undefined;
523
- }
524
- logger(`Found running Chrome for ${userDataDir}; reusing (DevTools port ${port}${pid ? `, pid ${pid}` : ''})`);
525
- return {
526
- port,
527
- pid,
528
- kill: async () => { },
529
- process: undefined,
530
- };
531
- }
532
- catch (error) {
533
- const message = error instanceof Error ? error.message : String(error);
534
- logger(`DevToolsActivePort found for ${userDataDir} but unreachable (${message}); launching new Chrome.`);
544
+ const probe = await verifyDevToolsReachable({ port });
545
+ if (!probe.ok) {
546
+ logger(`DevToolsActivePort found for ${userDataDir} but unreachable (${probe.error}); launching new Chrome.`);
547
+ // Safe cleanup: remove stale DevToolsActivePort; only remove lock files if this was an Oracle-owned pid that died.
548
+ await cleanupStaleProfileState(userDataDir, logger, { lockRemovalMode: 'if_oracle_pid_dead' });
535
549
  return null;
536
550
  }
537
- }
538
- async function readDevToolsPort(userDataDir) {
539
- const candidates = [
540
- path.join(userDataDir, 'DevToolsActivePort'),
541
- path.join(userDataDir, 'Default', 'DevToolsActivePort'),
542
- ];
543
- for (const candidate of candidates) {
544
- try {
545
- const raw = await readFile(candidate, 'utf8');
546
- const firstLine = raw.split(/\r?\n/u)[0]?.trim();
547
- const port = Number.parseInt(firstLine ?? '', 10);
548
- if (Number.isFinite(port)) {
549
- return port;
550
- }
551
- }
552
- catch {
553
- }
554
- }
555
- return null;
551
+ const pid = await readChromePid(userDataDir);
552
+ logger(`Found running Chrome for ${userDataDir}; reusing (DevTools port ${port}${pid ? `, pid ${pid}` : ''})`);
553
+ return {
554
+ port,
555
+ pid: pid ?? undefined,
556
+ kill: async () => { },
557
+ process: undefined,
558
+ };
556
559
  }
557
560
  async function runRemoteBrowserMode(promptText, attachments, config, logger, options) {
558
561
  const remoteChromeConfig = config.remoteChrome;
@@ -636,21 +639,50 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
636
639
  await ensurePromptReady(Runtime, config.inputTimeoutMs, logger);
637
640
  logger(`Prompt textarea ready (after model switch, ${promptText.length.toLocaleString()} chars queued)`);
638
641
  }
639
- const attachmentNames = attachments.map((a) => path.basename(a.path));
640
- if (attachments.length > 0) {
641
- if (!DOM) {
642
- throw new Error('Chrome DOM domain unavailable while uploading attachments.');
642
+ if (config.extendedThinking) {
643
+ await withRetries(() => ensureExtendedThinking(Runtime, logger), {
644
+ retries: 2,
645
+ delayMs: 300,
646
+ onRetry: (attempt, error) => {
647
+ if (options.verbose) {
648
+ logger(`[retry] Extended thinking attempt ${attempt + 1}: ${error instanceof Error ? error.message : error}`);
649
+ }
650
+ },
651
+ });
652
+ }
653
+ const submitOnce = async (prompt, submissionAttachments) => {
654
+ const attachmentNames = submissionAttachments.map((a) => path.basename(a.path));
655
+ if (submissionAttachments.length > 0) {
656
+ if (!DOM) {
657
+ throw new Error('Chrome DOM domain unavailable while uploading attachments.');
658
+ }
659
+ // Use remote file transfer for remote Chrome (reads local files and injects via CDP)
660
+ for (const attachment of submissionAttachments) {
661
+ logger(`Uploading attachment: ${attachment.displayPath}`);
662
+ await uploadAttachmentViaDataTransfer({ runtime: Runtime, dom: DOM }, attachment, logger);
663
+ }
664
+ const waitBudget = Math.max(config.inputTimeoutMs ?? 30_000, 30_000);
665
+ await waitForAttachmentCompletion(Runtime, waitBudget, attachmentNames, logger);
666
+ logger('All attachments uploaded');
643
667
  }
644
- // Use remote file transfer for remote Chrome (reads local files and injects via CDP)
645
- for (const attachment of attachments) {
646
- logger(`Uploading attachment: ${attachment.displayPath}`);
647
- await uploadAttachmentViaDataTransfer({ runtime: Runtime, dom: DOM }, attachment, logger);
668
+ await submitPrompt({ runtime: Runtime, input: Input, attachmentNames }, prompt, logger);
669
+ };
670
+ try {
671
+ await submitOnce(promptText, attachments);
672
+ }
673
+ catch (error) {
674
+ const isPromptTooLarge = error instanceof BrowserAutomationError &&
675
+ error.details?.code === 'prompt-too-large';
676
+ if (options.fallbackSubmission && isPromptTooLarge) {
677
+ logger('[browser] Inline prompt too large; retrying with file uploads.');
678
+ await clearPromptComposer(Runtime, logger);
679
+ await ensurePromptReady(Runtime, config.inputTimeoutMs, logger);
680
+ await submitOnce(options.fallbackSubmission.prompt, options.fallbackSubmission.attachments);
681
+ }
682
+ else {
683
+ throw error;
648
684
  }
649
- const waitBudget = Math.max(config.inputTimeoutMs ?? 30_000, 30_000);
650
- await waitForAttachmentCompletion(Runtime, waitBudget, attachmentNames, logger);
651
- logger('All attachments uploaded');
652
685
  }
653
- await submitPrompt({ runtime: Runtime, input: Input, attachmentNames }, promptText, logger);
654
686
  stopThinkingMonitor = startThinkingStatusMonitor(Runtime, logger, options.verbose ?? false);
655
687
  const answer = await waitForAssistantResponse(Runtime, config.timeoutMs, logger);
656
688
  answerText = answer.text;
@@ -1,5 +1,5 @@
1
1
  export { navigateToChatGPT, ensureNotBlocked, ensureLoggedIn, ensurePromptReady } from './actions/navigation.js';
2
2
  export { ensureModelSelection } from './actions/modelSelection.js';
3
- export { submitPrompt } from './actions/promptComposer.js';
3
+ export { submitPrompt, clearPromptComposer } from './actions/promptComposer.js';
4
4
  export { uploadAttachmentFile, waitForAttachmentCompletion } from './actions/attachments.js';
5
5
  export { waitForAssistantResponse, readAssistantSnapshot, captureAssistantMarkdown, buildAssistantExtractorForTest, buildConversationDebugExpressionForTest, } from './actions/assistantResponse.js';
@@ -0,0 +1,171 @@
1
+ import path from 'node:path';
2
+ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
3
+ import { execFile } from 'node:child_process';
4
+ import { promisify } from 'node:util';
5
+ const DEVTOOLS_ACTIVE_PORT_FILENAME = 'DevToolsActivePort';
6
+ const DEVTOOLS_ACTIVE_PORT_RELATIVE_PATHS = [
7
+ DEVTOOLS_ACTIVE_PORT_FILENAME,
8
+ path.join('Default', DEVTOOLS_ACTIVE_PORT_FILENAME),
9
+ ];
10
+ const CHROME_PID_FILENAME = 'chrome.pid';
11
+ const execFileAsync = promisify(execFile);
12
+ export function getDevToolsActivePortPaths(userDataDir) {
13
+ return DEVTOOLS_ACTIVE_PORT_RELATIVE_PATHS.map((relative) => path.join(userDataDir, relative));
14
+ }
15
+ export async function readDevToolsPort(userDataDir) {
16
+ for (const candidate of getDevToolsActivePortPaths(userDataDir)) {
17
+ try {
18
+ const raw = await readFile(candidate, 'utf8');
19
+ const firstLine = raw.split(/\r?\n/u)[0]?.trim();
20
+ const port = Number.parseInt(firstLine ?? '', 10);
21
+ if (Number.isFinite(port)) {
22
+ return port;
23
+ }
24
+ }
25
+ catch {
26
+ // ignore missing/unreadable candidates
27
+ }
28
+ }
29
+ return null;
30
+ }
31
+ export async function writeDevToolsActivePort(userDataDir, port) {
32
+ const contents = `${port}\n/devtools/browser`;
33
+ for (const candidate of getDevToolsActivePortPaths(userDataDir)) {
34
+ try {
35
+ await mkdir(path.dirname(candidate), { recursive: true });
36
+ await writeFile(candidate, contents, 'utf8');
37
+ }
38
+ catch {
39
+ // best effort
40
+ }
41
+ }
42
+ }
43
+ export async function readChromePid(userDataDir) {
44
+ const pidPath = path.join(userDataDir, CHROME_PID_FILENAME);
45
+ try {
46
+ const raw = (await readFile(pidPath, 'utf8')).trim();
47
+ const pid = Number.parseInt(raw, 10);
48
+ if (!Number.isFinite(pid) || pid <= 0) {
49
+ return null;
50
+ }
51
+ return pid;
52
+ }
53
+ catch {
54
+ return null;
55
+ }
56
+ }
57
+ export async function writeChromePid(userDataDir, pid) {
58
+ if (!Number.isFinite(pid) || pid <= 0)
59
+ return;
60
+ const pidPath = path.join(userDataDir, CHROME_PID_FILENAME);
61
+ try {
62
+ await mkdir(path.dirname(pidPath), { recursive: true });
63
+ await writeFile(pidPath, `${Math.trunc(pid)}\n`, 'utf8');
64
+ }
65
+ catch {
66
+ // best effort
67
+ }
68
+ }
69
+ export function isProcessAlive(pid) {
70
+ if (!Number.isFinite(pid) || pid <= 0)
71
+ return false;
72
+ try {
73
+ process.kill(pid, 0);
74
+ return true;
75
+ }
76
+ catch (error) {
77
+ // EPERM means "exists but no permission"; treat as alive.
78
+ if (error && typeof error === 'object' && 'code' in error && error.code === 'EPERM') {
79
+ return true;
80
+ }
81
+ return false;
82
+ }
83
+ }
84
+ export async function verifyDevToolsReachable({ port, host = '127.0.0.1', attempts = 3, timeoutMs = 3000, }) {
85
+ const versionUrl = `http://${host}:${port}/json/version`;
86
+ for (let attempt = 0; attempt < attempts; attempt++) {
87
+ try {
88
+ const controller = new AbortController();
89
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
90
+ const response = await fetch(versionUrl, { signal: controller.signal });
91
+ clearTimeout(timeout);
92
+ if (!response.ok) {
93
+ throw new Error(`HTTP ${response.status}`);
94
+ }
95
+ return { ok: true };
96
+ }
97
+ catch (error) {
98
+ if (attempt < attempts - 1) {
99
+ await new Promise((resolve) => setTimeout(resolve, 500 * (attempt + 1)));
100
+ continue;
101
+ }
102
+ const message = error instanceof Error ? error.message : String(error);
103
+ return { ok: false, error: message };
104
+ }
105
+ }
106
+ return { ok: false, error: 'unreachable' };
107
+ }
108
+ export async function cleanupStaleProfileState(userDataDir, logger, options = {}) {
109
+ for (const candidate of getDevToolsActivePortPaths(userDataDir)) {
110
+ try {
111
+ await rm(candidate, { force: true });
112
+ logger?.(`Removed stale DevToolsActivePort: ${candidate}`);
113
+ }
114
+ catch {
115
+ // ignore cleanup errors
116
+ }
117
+ }
118
+ const lockRemovalMode = options.lockRemovalMode ?? 'never';
119
+ if (lockRemovalMode === 'never') {
120
+ return;
121
+ }
122
+ const pid = await readChromePid(userDataDir);
123
+ if (!pid) {
124
+ return;
125
+ }
126
+ if (isProcessAlive(pid)) {
127
+ logger?.(`Chrome pid ${pid} still alive; skipping profile lock cleanup`);
128
+ return;
129
+ }
130
+ // Extra safety: if Chrome is running with this profile (but with a different PID, e.g. user relaunched
131
+ // without remote debugging), never delete lock files.
132
+ if (await isChromeUsingUserDataDir(userDataDir)) {
133
+ logger?.('Detected running Chrome using this profile; skipping profile lock cleanup');
134
+ return;
135
+ }
136
+ const lockFiles = [
137
+ path.join(userDataDir, 'lockfile'),
138
+ path.join(userDataDir, 'SingletonLock'),
139
+ path.join(userDataDir, 'SingletonSocket'),
140
+ path.join(userDataDir, 'SingletonCookie'),
141
+ ];
142
+ for (const lock of lockFiles) {
143
+ await rm(lock, { force: true }).catch(() => undefined);
144
+ }
145
+ logger?.('Cleaned up stale Chrome profile locks');
146
+ }
147
+ async function isChromeUsingUserDataDir(userDataDir) {
148
+ if (process.platform === 'win32') {
149
+ // On Windows, lockfiles are typically held open and removal should fail anyway; avoid expensive process scans.
150
+ return false;
151
+ }
152
+ try {
153
+ const { stdout } = await execFileAsync('ps', ['-ax', '-o', 'command='], { maxBuffer: 10 * 1024 * 1024 });
154
+ const lines = String(stdout ?? '').split('\n');
155
+ const needle = userDataDir;
156
+ for (const line of lines) {
157
+ if (!line)
158
+ continue;
159
+ const lower = line.toLowerCase();
160
+ if (!lower.includes('chrome') && !lower.includes('chromium'))
161
+ continue;
162
+ if (line.includes(needle) && lower.includes('user-data-dir')) {
163
+ return true;
164
+ }
165
+ }
166
+ }
167
+ catch {
168
+ // best effort
169
+ }
170
+ return false;
171
+ }
@@ -5,6 +5,7 @@ import { readFiles, createFileSections, MODEL_CONFIGS, TOKENIZER_OPTIONS, format
5
5
  import { isKnownModel } from '../oracle/modelResolver.js';
6
6
  import { buildPromptMarkdown } from '../oracle/promptAssembly.js';
7
7
  import { buildAttachmentPlan } from './policies.js';
8
+ const DEFAULT_BROWSER_INLINE_CHAR_BUDGET = 60_000;
8
9
  export async function assembleBrowserPrompt(runOptions, deps = {}) {
9
10
  const cwd = deps.cwd ?? process.cwd();
10
11
  const readFilesFn = deps.readFilesImpl ?? readFiles;
@@ -14,22 +15,33 @@ export async function assembleBrowserPrompt(runOptions, deps = {}) {
14
15
  const systemPrompt = runOptions.system?.trim() || '';
15
16
  const sections = createFileSections(files, cwd);
16
17
  const markdown = buildPromptMarkdown(systemPrompt, userPrompt, sections);
17
- const inlineFiles = Boolean(runOptions.browserInlineFiles);
18
- const composerSections = [];
18
+ const attachmentsPolicy = runOptions.browserInlineFiles
19
+ ? 'never'
20
+ : runOptions.browserAttachments ?? 'auto';
21
+ const bundleRequested = Boolean(runOptions.browserBundleFiles);
22
+ const inlinePlan = buildAttachmentPlan(sections, { inlineFiles: true, bundleRequested });
23
+ const uploadPlan = buildAttachmentPlan(sections, { inlineFiles: false, bundleRequested });
24
+ const baseComposerSections = [];
19
25
  if (systemPrompt)
20
- composerSections.push(systemPrompt);
26
+ baseComposerSections.push(systemPrompt);
21
27
  if (userPrompt)
22
- composerSections.push(userPrompt);
23
- const attachmentPlan = buildAttachmentPlan(sections, {
24
- inlineFiles,
25
- bundleRequested: Boolean(runOptions.browserBundleFiles),
26
- });
27
- if (attachmentPlan.inlineBlock) {
28
- composerSections.push(attachmentPlan.inlineBlock);
29
- }
30
- const composerText = composerSections.join('\n\n').trim();
31
- const attachments = attachmentPlan.attachments.slice();
32
- const shouldBundle = attachmentPlan.shouldBundle;
28
+ baseComposerSections.push(userPrompt);
29
+ const inlineComposerText = [...baseComposerSections, inlinePlan.inlineBlock].filter(Boolean).join('\n\n').trim();
30
+ const selectedPlan = attachmentsPolicy === 'always'
31
+ ? uploadPlan
32
+ : attachmentsPolicy === 'never'
33
+ ? inlinePlan
34
+ : inlineComposerText.length <= DEFAULT_BROWSER_INLINE_CHAR_BUDGET || sections.length === 0
35
+ ? inlinePlan
36
+ : uploadPlan;
37
+ const composerText = (selectedPlan.inlineBlock
38
+ ? [...baseComposerSections, selectedPlan.inlineBlock]
39
+ : baseComposerSections)
40
+ .filter(Boolean)
41
+ .join('\n\n')
42
+ .trim();
43
+ const attachments = selectedPlan.attachments.slice();
44
+ const shouldBundle = selectedPlan.shouldBundle;
33
45
  let bundleText = null;
34
46
  if (shouldBundle) {
35
47
  const bundleDir = await fs.mkdtemp(path.join(os.tmpdir(), 'oracle-browser-bundle-'));
@@ -48,11 +60,11 @@ export async function assembleBrowserPrompt(runOptions, deps = {}) {
48
60
  sizeBytes: Buffer.byteLength(bundleText, 'utf8'),
49
61
  });
50
62
  }
51
- const inlineFileCount = attachmentPlan.inlineFileCount;
63
+ const inlineFileCount = selectedPlan.inlineFileCount;
52
64
  const modelConfig = isKnownModel(runOptions.model) ? MODEL_CONFIGS[runOptions.model] : MODEL_CONFIGS['gpt-5.1'];
53
- const tokenizer = modelConfig.tokenizer;
54
- const tokenizerUserContent = inlineFileCount > 0 && attachmentPlan.inlineBlock
55
- ? [userPrompt, attachmentPlan.inlineBlock].filter((value) => Boolean(value?.trim())).join('\n\n').trim()
65
+ const tokenizer = deps.tokenizeImpl ?? modelConfig.tokenizer;
66
+ const tokenizerUserContent = inlineFileCount > 0 && selectedPlan.inlineBlock
67
+ ? [userPrompt, selectedPlan.inlineBlock].filter((value) => Boolean(value?.trim())).join('\n\n').trim()
56
68
  : userPrompt;
57
69
  const tokenizerMessages = [
58
70
  systemPrompt ? { role: 'system', content: systemPrompt } : null,
@@ -61,7 +73,7 @@ export async function assembleBrowserPrompt(runOptions, deps = {}) {
61
73
  let estimatedInputTokens = tokenizer(tokenizerMessages.length > 0
62
74
  ? tokenizerMessages
63
75
  : [{ role: 'user', content: '' }], TOKENIZER_OPTIONS);
64
- const tokenEstimateIncludesInlineFiles = inlineFileCount > 0 && Boolean(attachmentPlan.inlineBlock);
76
+ const tokenEstimateIncludesInlineFiles = inlineFileCount > 0 && Boolean(selectedPlan.inlineBlock);
65
77
  if (!tokenEstimateIncludesInlineFiles && sections.length > 0) {
66
78
  const attachmentText = bundleText ??
67
79
  sections
@@ -70,6 +82,35 @@ export async function assembleBrowserPrompt(runOptions, deps = {}) {
70
82
  const attachmentTokens = tokenizer([{ role: 'user', content: attachmentText }], TOKENIZER_OPTIONS);
71
83
  estimatedInputTokens += attachmentTokens;
72
84
  }
85
+ let fallback = null;
86
+ if (attachmentsPolicy === 'auto' && selectedPlan.mode === 'inline' && sections.length > 0) {
87
+ const fallbackComposerText = baseComposerSections.join('\n\n').trim();
88
+ const fallbackAttachments = uploadPlan.attachments.slice();
89
+ let fallbackBundled = null;
90
+ if (uploadPlan.shouldBundle) {
91
+ const bundleDir = await fs.mkdtemp(path.join(os.tmpdir(), 'oracle-browser-bundle-'));
92
+ const bundlePath = path.join(bundleDir, 'attachments-bundle.txt');
93
+ const bundleLines = [];
94
+ sections.forEach((section) => {
95
+ bundleLines.push(formatFileSection(section.displayPath, section.content).trimEnd());
96
+ bundleLines.push('');
97
+ });
98
+ const fallbackBundleText = `${bundleLines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd()}\n`;
99
+ await fs.writeFile(bundlePath, fallbackBundleText, 'utf8');
100
+ fallbackAttachments.length = 0;
101
+ fallbackAttachments.push({
102
+ path: bundlePath,
103
+ displayPath: bundlePath,
104
+ sizeBytes: Buffer.byteLength(fallbackBundleText, 'utf8'),
105
+ });
106
+ fallbackBundled = { originalCount: sections.length, bundlePath };
107
+ }
108
+ fallback = {
109
+ composerText: fallbackComposerText,
110
+ attachments: fallbackAttachments,
111
+ bundled: fallbackBundled,
112
+ };
113
+ }
73
114
  return {
74
115
  markdown,
75
116
  composerText,
@@ -77,6 +118,9 @@ export async function assembleBrowserPrompt(runOptions, deps = {}) {
77
118
  attachments,
78
119
  inlineFileCount,
79
120
  tokenEstimateIncludesInlineFiles,
121
+ attachmentsPolicy,
122
+ attachmentMode: selectedPlan.mode,
123
+ fallback,
80
124
  bundled: shouldBundle && attachments.length === 1 && attachments[0]?.displayPath
81
125
  ? { originalCount: sections.length, bundlePath: attachments[0].displayPath }
82
126
  : null,
@@ -25,8 +25,8 @@ export async function runBrowserSessionExecution({ runOptions, browserConfig, cw
25
25
  log(chalk.yellow(`[browser] Bundled ${promptArtifacts.bundled.originalCount} files into ${promptArtifacts.bundled.bundlePath}.`));
26
26
  }
27
27
  }
28
- else if (runOptions.file && runOptions.file.length > 0 && runOptions.browserInlineFiles) {
29
- log(chalk.dim('[verbose] Browser inline file fallback enabled (pasting file contents).'));
28
+ else if (runOptions.file && runOptions.file.length > 0 && promptArtifacts.attachmentMode === 'inline') {
29
+ log(chalk.dim('[verbose] Browser will paste file contents inline (no uploads).'));
30
30
  }
31
31
  }
32
32
  if (promptArtifacts.bundled) {
@@ -34,11 +34,12 @@ export async function runBrowserSessionExecution({ runOptions, browserConfig, cw
34
34
  }
35
35
  const headerLine = `Launching browser mode (${runOptions.model}) with ~${promptArtifacts.estimatedInputTokens.toLocaleString()} tokens.`;
36
36
  const automationLogger = ((message) => {
37
- if (!runOptions.verbose)
37
+ if (typeof message !== 'string')
38
38
  return;
39
- if (typeof message === 'string') {
40
- log(message);
41
- }
39
+ const shouldAlwaysPrint = message.startsWith('[browser] ') && /fallback|retry/i.test(message);
40
+ if (!runOptions.verbose && !shouldAlwaysPrint)
41
+ return;
42
+ log(message);
42
43
  });
43
44
  automationLogger.verbose = Boolean(runOptions.verbose);
44
45
  automationLogger.sessionLog = runOptions.verbose ? log : (() => { });
@@ -53,6 +54,9 @@ export async function runBrowserSessionExecution({ runOptions, browserConfig, cw
53
54
  browserResult = await executeBrowser({
54
55
  prompt: promptArtifacts.composerText,
55
56
  attachments: promptArtifacts.attachments,
57
+ fallbackSubmission: promptArtifacts.fallback
58
+ ? { prompt: promptArtifacts.fallback.composerText, attachments: promptArtifacts.fallback.attachments }
59
+ : undefined,
56
60
  config: browserConfig,
57
61
  log: automationLogger,
58
62
  heartbeatIntervalMs: runOptions.heartbeatIntervalMs,
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import os from 'node:os';
4
3
  import { CHATGPT_URL, DEFAULT_MODEL_TARGET, normalizeChatgptUrl, parseDuration } from '../browserMode.js';
4
+ import { getOracleHomeDir } from '../oracleHome.js';
5
5
  const DEFAULT_BROWSER_TIMEOUT_MS = 1_200_000;
6
6
  const DEFAULT_BROWSER_INPUT_TIMEOUT_MS = 30_000;
7
7
  const DEFAULT_CHROME_PROFILE = 'Default';
@@ -9,6 +9,9 @@ const BROWSER_MODEL_LABELS = {
9
9
  'gpt-5-pro': 'GPT-5 Pro',
10
10
  'gpt-5.1-pro': 'GPT-5.1 Pro',
11
11
  'gpt-5.1': 'GPT-5.1',
12
+ 'gpt-5.2': 'GPT-5.2 Thinking',
13
+ 'gpt-5.2-instant': 'GPT-5.2 Instant',
14
+ 'gpt-5.2-pro': 'GPT-5.2 Pro',
12
15
  'gemini-3-pro': 'Gemini 3 Pro',
13
16
  };
14
17
  export async function buildBrowserConfig(options) {
@@ -53,6 +56,7 @@ export async function buildBrowserConfig(options) {
53
56
  // Allow cookie failures by default so runs can continue without Chrome/Keychain secrets.
54
57
  allowCookieErrors: options.browserAllowCookieErrors ?? true,
55
58
  remoteChrome,
59
+ extendedThinking: options.browserExtendedThinking ? true : undefined,
56
60
  };
57
61
  }
58
62
  function selectBrowserPort(options) {
@@ -155,7 +159,7 @@ async function resolveInlineCookies({ inlineArg, inlineFileArg, envPayload, envF
155
159
  return { cookies: parsed, source };
156
160
  }
157
161
  // fallback: ~/.oracle/cookies.{json,base64}
158
- const oracleHome = process.env.ORACLE_HOME_DIR ?? path.join(os.homedir(), '.oracle');
162
+ const oracleHome = getOracleHomeDir();
159
163
  const candidates = ['cookies.json', 'cookies.base64'];
160
164
  for (const file of candidates) {
161
165
  const fullPath = path.join(oracleHome, file);
@@ -18,7 +18,14 @@ function isProcessAlive(pid) {
18
18
  return true;
19
19
  }
20
20
  catch (error) {
21
- return !(error instanceof Error && error.code === 'ESRCH');
21
+ const code = error instanceof Error ? error.code : undefined;
22
+ if (code === 'ESRCH' || code === 'EINVAL') {
23
+ return false;
24
+ }
25
+ if (code === 'EPERM') {
26
+ return true;
27
+ }
28
+ return true;
22
29
  }
23
30
  }
24
31
  const CLEANUP_TIP = 'Tip: Run "oracle session --clear --hours 24" to prune cached runs (add --all to wipe everything).';