@steipete/oracle 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +14 -6
  2. package/dist/.DS_Store +0 -0
  3. package/dist/bin/oracle-cli.js +161 -44
  4. package/dist/src/browser/config.js +6 -0
  5. package/dist/src/browser/cookies.js +49 -11
  6. package/dist/src/browser/index.js +18 -5
  7. package/dist/src/browser/sessionRunner.js +10 -1
  8. package/dist/src/cli/browserConfig.js +109 -2
  9. package/dist/src/cli/detach.js +12 -0
  10. package/dist/src/cli/dryRun.js +19 -3
  11. package/dist/src/cli/help.js +2 -0
  12. package/dist/src/cli/options.js +22 -0
  13. package/dist/src/cli/runOptions.js +16 -2
  14. package/dist/src/cli/sessionRunner.js +11 -0
  15. package/dist/src/cli/tui/index.js +68 -47
  16. package/dist/src/oracle/client.js +24 -6
  17. package/dist/src/oracle/config.js +10 -0
  18. package/dist/src/oracle/files.js +8 -2
  19. package/dist/src/oracle/format.js +2 -7
  20. package/dist/src/oracle/fsAdapter.js +4 -1
  21. package/dist/src/oracle/gemini.js +161 -0
  22. package/dist/src/oracle/logging.js +36 -0
  23. package/dist/src/oracle/oscProgress.js +7 -1
  24. package/dist/src/oracle/run.js +111 -48
  25. package/dist/src/oracle.js +1 -0
  26. package/dist/src/sessionManager.js +2 -0
  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 +16 -26
  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
package/README.md CHANGED
@@ -21,7 +21,10 @@ Oracle gives your agents a simple, reliable way to **bundle a prompt plus the ri
21
21
  If you omit `--engine`, Oracle prefers the API engine when `OPENAI_API_KEY` is present; otherwise it falls back to browser mode. Switch explicitly with `-e, --engine {api|browser}` when you want to override the auto choice. Everything else (prompt assembly, file handling, session logging) stays the same.
22
22
 
23
23
  Note: Browser engine is considered experimental, requires an OpenAI Pro account and only works on macOS with Chrome.
24
- Your system password is needed to copy cookies. API engine is stable and should be preferred.
24
+ Windows/Linux browser support is in progress; until then, use `--engine api` or bundle files and paste manually.
25
+ Your system password is needed to copy cookies. To skip Chrome/Keychain entirely, pass inline cookies via
26
+ `--browser-inline-cookies <json|base64>` or `--browser-inline-cookies-file <path>` (fallback files at
27
+ `~/.oracle/cookies.json` or `~/.oracle/cookies.base64`). API engine is stable and should be preferred.
25
28
 
26
29
  ## Quick start
27
30
 
@@ -38,6 +41,12 @@ npx -y @steipete/oracle -p "Review the TS data layer" --file "src/**/*.ts" --fil
38
41
  # Mixed glob + single file
39
42
  npx -y @steipete/oracle -p "Audit data layer" --file "src/**/*.ts" --file README.md
40
43
 
44
+ # Dry-run (no API call) with summary estimate
45
+ oracle --dry-run summary -p "Check release notes" --file docs/release-notes.md
46
+
47
+ # Alternate base URL (LiteLLM, Azure, self-hosted gateways)
48
+ OPENAI_API_KEY=sk-... oracle --base-url https://litellm.example.com/v1 -p "Summarize the risk register"
49
+
41
50
  # Inspect past sessions
42
51
  oracle status --clear --hours 168 # prune a week of cached runs
43
52
  oracle status # list runs; grab an ID
@@ -86,13 +95,12 @@ Put per-user defaults in `~/.oracle/config.json` (parsed as JSON5, so comments/t
86
95
  | `-f, --file <paths...>` | Attach files/dirs (supports globs and `!` excludes). |
87
96
  | `-e, --engine <api\|browser>` | Choose API or browser automation. Omitted: API when `OPENAI_API_KEY` is set, otherwise browser. |
88
97
  | `-m, --model <name>` | `gpt-5-pro` (default) or `gpt-5.1`. |
98
+ | `--base-url <url>` | Point the API engine at any OpenAI-compatible endpoint (LiteLLM, Azure, etc.). |
99
+ | `--azure-endpoint <url>` | Use Azure OpenAI (switches client automatically). |
89
100
  | `--files-report` | Print per-file token usage. |
90
- | `--preview [summary\|json\|full]` | Inspect the request without sending. |
91
- | `--render-markdown` | Print the assembled `[SYSTEM]/[USER]/[FILE]` bundle. |
92
- | `--wait` / `--no-wait` | Block until completion. Default: `wait` for gpt-5.1/browser; `no-wait` for gpt-5-pro API (reattach later). |
93
- | `-v, --verbose` | Extra logging (also surfaces advanced flags with `--help`). |
101
+ | `--dry-run [summary\|json\|full]` | Inspect the request without sending (alias: `--preview`). |
94
102
 
95
- More knobs (`--max-input`, cookie sync controls for browser mode, etc.) live behind `oracle --help --verbose`.
103
+ See [docs/openai-endpoints.md](docs/openai-endpoints.md) for advanced Azure/LiteLLM configuration.
96
104
 
97
105
  ## Sessions & background runs
98
106
 
package/dist/.DS_Store ADDED
Binary file
@@ -2,19 +2,22 @@
2
2
  import 'dotenv/config';
3
3
  import { spawn } from 'node:child_process';
4
4
  import { fileURLToPath } from 'node:url';
5
+ import { once } from 'node:events';
5
6
  import { Command, Option } from 'commander';
6
7
  import { resolveEngine, defaultWaitPreference } from '../src/cli/engine.js';
7
8
  import { shouldRequirePrompt } from '../src/cli/promptRequirement.js';
8
9
  import chalk from 'chalk';
9
10
  import { ensureSessionStorage, initializeSession, readSessionMetadata, createSessionLogWriter, deleteSessionsOlderThan, } from '../src/sessionManager.js';
10
- import { runOracle, renderPromptMarkdown, readFiles } from '../src/oracle.js';
11
+ import { renderPromptMarkdown, readFiles } from '../src/oracle.js';
11
12
  import { CHATGPT_URL } from '../src/browserMode.js';
12
13
  import { applyHelpStyling } from '../src/cli/help.js';
13
- import { collectPaths, parseFloatOption, parseIntOption, parseSearchOption, usesDefaultStatusFilters, resolvePreviewMode, normalizeModelOption, resolveApiModel, inferModelFromLabel, parseHeartbeatOption, } from '../src/cli/options.js';
14
+ import { collectPaths, parseFloatOption, parseIntOption, parseSearchOption, usesDefaultStatusFilters, resolvePreviewMode, normalizeModelOption, normalizeBaseUrl, resolveApiModel, inferModelFromLabel, parseHeartbeatOption, parseTimeoutOption, } from '../src/cli/options.js';
15
+ import { shouldDetachSession } from '../src/cli/detach.js';
14
16
  import { applyHiddenAliases } from '../src/cli/hiddenAliases.js';
15
17
  import { buildBrowserConfig, resolveBrowserModelLabel } from '../src/cli/browserConfig.js';
16
18
  import { performSessionRun } from '../src/cli/sessionRunner.js';
17
19
  import { attachSession, showStatus, formatCompletionSummary } from '../src/cli/sessionDisplay.js';
20
+ import { resolveGeminiModelId } from '../src/oracle/gemini.js';
18
21
  import { handleSessionCommand, formatSessionCleanupMessage } from '../src/cli/sessionCommand.js';
19
22
  import { isErrorLogged } from '../src/cli/errorUtils.js';
20
23
  import { handleSessionAlias, handleStatusFlag } from '../src/cli/rootAlias.js';
@@ -76,10 +79,17 @@ program
76
79
  .addOption(new Option('--[no-]notify', 'Desktop notification when a session finishes (default on unless CI/SSH).')
77
80
  .default(undefined))
78
81
  .addOption(new Option('--[no-]notify-sound', 'Play a notification sound on completion (default off).').default(undefined))
79
- .option('--dry-run', 'Validate inputs and show token estimates without calling the model.', false)
80
- .addOption(new Option('--preview [mode]', 'Preview the request without calling the API (summary | json | full).')
82
+ .addOption(new Option('--timeout <seconds|auto>', 'Overall timeout before aborting the API call (auto = 20m for gpt-5-pro, 30s otherwise).')
83
+ .argParser(parseTimeoutOption)
84
+ .default('auto'))
85
+ .addOption(new Option('--preview [mode]', '(alias) Preview the request without calling the model (summary | json | full). Deprecated: use --dry-run instead.')
86
+ .hideHelp()
81
87
  .choices(['summary', 'json', 'full'])
82
88
  .preset('summary'))
89
+ .addOption(new Option('--dry-run [mode]', 'Preview without calling the model (summary | json | full).')
90
+ .choices(['summary', 'json', 'full'])
91
+ .preset('summary')
92
+ .default(false))
83
93
  .addOption(new Option('--exec-session <id>').hideHelp())
84
94
  .addOption(new Option('--session <id>').hideHelp())
85
95
  .addOption(new Option('--status', 'Show stored sessions (alias for `oracle status`).').default(false).hideHelp())
@@ -94,12 +104,19 @@ program
94
104
  .addOption(new Option('--max-output <tokens>', 'Override the max output tokens for the selected model.')
95
105
  .argParser(parseIntOption)
96
106
  .hideHelp())
107
+ .option('--base-url <url>', 'Override the OpenAI-compatible base URL for API runs (e.g. LiteLLM proxy endpoint).')
108
+ .option('--azure-endpoint <url>', 'Azure OpenAI Endpoint (e.g. https://resource.openai.azure.com/).')
109
+ .option('--azure-deployment <name>', 'Azure OpenAI Deployment Name.')
110
+ .option('--azure-api-version <version>', 'Azure OpenAI API Version.')
97
111
  .addOption(new Option('--browser', '(deprecated) Use --engine browser instead.').default(false).hideHelp())
98
112
  .addOption(new Option('--browser-chrome-profile <name>', 'Chrome profile name/path for cookie reuse.').hideHelp())
99
113
  .addOption(new Option('--browser-chrome-path <path>', 'Explicit Chrome or Chromium executable path.').hideHelp())
100
114
  .addOption(new Option('--browser-url <url>', `Override the ChatGPT URL (default ${CHATGPT_URL}).`).hideHelp())
101
115
  .addOption(new Option('--browser-timeout <ms|s|m>', 'Maximum time to wait for an answer (default 900s).').hideHelp())
102
116
  .addOption(new Option('--browser-input-timeout <ms|s|m>', 'Maximum time to wait for the prompt textarea (default 30s).').hideHelp())
117
+ .addOption(new Option('--browser-cookie-names <names>', 'Comma-separated cookie allowlist for sync.').hideHelp())
118
+ .addOption(new Option('--browser-inline-cookies <jsonOrBase64>', 'Inline cookies payload (JSON array or base64-encoded JSON).').hideHelp())
119
+ .addOption(new Option('--browser-inline-cookies-file <path>', 'Load inline cookies from file (JSON or base64 JSON).').hideHelp())
103
120
  .addOption(new Option('--browser-no-cookie-sync', 'Skip copying cookies from Chrome.').hideHelp())
104
121
  .addOption(new Option('--browser-headless', 'Launch Chrome in headless mode.').hideHelp())
105
122
  .addOption(new Option('--browser-hide-window', 'Hide the Chrome window after launch (macOS headful only).').hideHelp())
@@ -188,20 +205,32 @@ function buildRunOptions(options, overrides = {}) {
188
205
  if (!options.prompt) {
189
206
  throw new Error('Prompt is required.');
190
207
  }
208
+ const normalizedBaseUrl = normalizeBaseUrl(overrides.baseUrl ?? options.baseUrl);
209
+ const azure = options.azureEndpoint || overrides.azure?.endpoint
210
+ ? {
211
+ endpoint: overrides.azure?.endpoint ?? options.azureEndpoint,
212
+ deployment: overrides.azure?.deployment ?? options.azureDeployment,
213
+ apiVersion: overrides.azure?.apiVersion ?? options.azureApiVersion,
214
+ }
215
+ : undefined;
191
216
  return {
192
217
  prompt: options.prompt,
193
218
  model: options.model,
219
+ effectiveModelId: overrides.effectiveModelId ?? options.effectiveModelId ?? options.model,
194
220
  file: overrides.file ?? options.file ?? [],
195
221
  slug: overrides.slug ?? options.slug,
196
222
  filesReport: overrides.filesReport ?? options.filesReport,
197
223
  maxInput: overrides.maxInput ?? options.maxInput,
198
224
  maxOutput: overrides.maxOutput ?? options.maxOutput,
199
225
  system: overrides.system ?? options.system,
226
+ timeoutSeconds: overrides.timeoutSeconds ?? options.timeout,
200
227
  silent: overrides.silent ?? options.silent,
201
228
  search: overrides.search ?? options.search,
202
229
  preview: overrides.preview ?? undefined,
203
230
  previewMode: overrides.previewMode ?? options.previewMode,
204
231
  apiKey: overrides.apiKey ?? options.apiKey,
232
+ baseUrl: normalizedBaseUrl,
233
+ azure,
205
234
  sessionId: overrides.sessionId ?? options.sessionId,
206
235
  verbose: overrides.verbose ?? options.verbose,
207
236
  heartbeatIntervalMs: overrides.heartbeatIntervalMs ?? resolveHeartbeatIntervalMs(options.heartbeat),
@@ -227,6 +256,7 @@ function buildRunOptionsFromMetadata(metadata) {
227
256
  return {
228
257
  prompt: stored.prompt ?? '',
229
258
  model: stored.model ?? 'gpt-5-pro',
259
+ effectiveModelId: stored.effectiveModelId ?? stored.model,
230
260
  file: stored.file ?? [],
231
261
  slug: stored.slug,
232
262
  filesReport: stored.filesReport,
@@ -238,6 +268,8 @@ function buildRunOptionsFromMetadata(metadata) {
238
268
  preview: false,
239
269
  previewMode: undefined,
240
270
  apiKey: undefined,
271
+ baseUrl: normalizeBaseUrl(stored.baseUrl),
272
+ azure: stored.azure,
241
273
  sessionId: metadata.id,
242
274
  verbose: stored.verbose,
243
275
  heartbeatIntervalMs: stored.heartbeatIntervalMs,
@@ -253,8 +285,18 @@ function getBrowserConfigFromMetadata(metadata) {
253
285
  return metadata.options?.browserConfig ?? metadata.browser?.config;
254
286
  }
255
287
  async function runRootCommand(options) {
288
+ if (process.env.ORACLE_FORCE_TUI === '1') {
289
+ await ensureSessionStorage();
290
+ await launchTui({ version: VERSION });
291
+ return;
292
+ }
256
293
  const userConfig = (await loadUserConfig()).config;
257
294
  const helpRequested = rawCliArgs.some((arg) => arg === '--help' || arg === '-h');
295
+ const optionUsesDefault = (name) => {
296
+ // Commander reports undefined for untouched options, so treat undefined/default the same
297
+ const source = program.getOptionValueSource?.(name);
298
+ return source == null || source === 'default';
299
+ };
258
300
  if (helpRequested) {
259
301
  if (options.verbose) {
260
302
  console.log('');
@@ -264,7 +306,7 @@ async function runRootCommand(options) {
264
306
  program.help({ error: false });
265
307
  return;
266
308
  }
267
- const previewMode = resolvePreviewMode(options.preview);
309
+ const previewMode = resolvePreviewMode(options.dryRun || options.preview);
268
310
  if (userCliArgs.length === 0) {
269
311
  if (tuiEnabled()) {
270
312
  await launchTui({ version: VERSION });
@@ -278,32 +320,68 @@ async function runRootCommand(options) {
278
320
  printDebugHelp(program.name());
279
321
  return;
280
322
  }
281
- if (options.dryRun && previewMode) {
282
- throw new Error('--dry-run cannot be combined with --preview.');
283
- }
284
323
  if (options.dryRun && options.renderMarkdown) {
285
324
  throw new Error('--dry-run cannot be combined with --render-markdown.');
286
325
  }
287
326
  const preferredEngine = options.engine ?? userConfig.engine;
288
- const engine = resolveEngine({ engine: preferredEngine, browserFlag: options.browser, env: process.env });
327
+ let engine = resolveEngine({ engine: preferredEngine, browserFlag: options.browser, env: process.env });
289
328
  if (options.browser) {
290
329
  console.log(chalk.yellow('`--browser` is deprecated; use `--engine browser` instead.'));
291
330
  }
292
- if (program.getOptionValueSource?.('model') === 'default' && userConfig.model) {
331
+ if (optionUsesDefault('model') && userConfig.model) {
293
332
  options.model = userConfig.model;
294
333
  }
295
- if (program.getOptionValueSource?.('search') === 'default' && userConfig.search) {
334
+ if (optionUsesDefault('search') && userConfig.search) {
296
335
  options.search = userConfig.search === 'on';
297
336
  }
298
- if (program.getOptionValueSource?.('filesReport') === 'default' && userConfig.filesReport != null) {
337
+ if (optionUsesDefault('filesReport') && userConfig.filesReport != null) {
299
338
  options.filesReport = Boolean(userConfig.filesReport);
300
339
  }
301
- if (program.getOptionValueSource?.('heartbeat') === 'default' && typeof userConfig.heartbeatSeconds === 'number') {
340
+ if (optionUsesDefault('heartbeat') && typeof userConfig.heartbeatSeconds === 'number') {
302
341
  options.heartbeat = userConfig.heartbeatSeconds;
303
342
  }
343
+ if (optionUsesDefault('baseUrl') && userConfig.apiBaseUrl) {
344
+ options.baseUrl = userConfig.apiBaseUrl;
345
+ }
346
+ if (optionUsesDefault('azureEndpoint')) {
347
+ if (process.env.AZURE_OPENAI_ENDPOINT) {
348
+ options.azureEndpoint = process.env.AZURE_OPENAI_ENDPOINT;
349
+ }
350
+ else if (userConfig.azure?.endpoint) {
351
+ options.azureEndpoint = userConfig.azure.endpoint;
352
+ }
353
+ }
354
+ if (optionUsesDefault('azureDeployment')) {
355
+ if (process.env.AZURE_OPENAI_DEPLOYMENT) {
356
+ options.azureDeployment = process.env.AZURE_OPENAI_DEPLOYMENT;
357
+ }
358
+ else if (userConfig.azure?.deployment) {
359
+ options.azureDeployment = userConfig.azure.deployment;
360
+ }
361
+ }
362
+ if (optionUsesDefault('azureApiVersion')) {
363
+ if (process.env.AZURE_OPENAI_API_VERSION) {
364
+ options.azureApiVersion = process.env.AZURE_OPENAI_API_VERSION;
365
+ }
366
+ else if (userConfig.azure?.apiVersion) {
367
+ options.azureApiVersion = userConfig.azure.apiVersion;
368
+ }
369
+ }
304
370
  const cliModelArg = normalizeModelOption(options.model) || 'gpt-5-pro';
305
- const resolvedModel = engine === 'browser' ? inferModelFromLabel(cliModelArg) : resolveApiModel(cliModelArg);
371
+ const resolvedModelCandidate = engine === 'browser' ? inferModelFromLabel(cliModelArg) : resolveApiModel(cliModelArg);
372
+ const isGemini = resolvedModelCandidate.startsWith('gemini');
373
+ const userForcedBrowser = options.browser || options.engine === 'browser';
374
+ if (isGemini && userForcedBrowser) {
375
+ throw new Error('Gemini is only supported via API. Use --engine api.');
376
+ }
377
+ if (isGemini && engine === 'browser') {
378
+ engine = 'api';
379
+ }
380
+ const resolvedModel = isGemini ? resolveApiModel(cliModelArg) : resolvedModelCandidate;
381
+ const effectiveModelId = resolvedModel.startsWith('gemini') ? resolveGeminiModelId(resolvedModel) : resolvedModel;
382
+ const resolvedBaseUrl = normalizeBaseUrl(options.baseUrl ?? process.env.OPENAI_BASE_URL);
306
383
  const resolvedOptions = { ...options, model: resolvedModel };
384
+ resolvedOptions.baseUrl = resolvedBaseUrl;
307
385
  // Decide whether to block until completion:
308
386
  // - explicit --wait / --no-wait wins
309
387
  // - otherwise block for fast models (gpt-5.1, browser) and detach by default for gpt-5-pro API
@@ -333,13 +411,13 @@ async function runRootCommand(options) {
333
411
  }
334
412
  if (previewMode) {
335
413
  if (!options.prompt) {
336
- throw new Error('Prompt is required when using --preview.');
414
+ throw new Error('Prompt is required when using --dry-run/preview.');
337
415
  }
338
416
  if (userConfig.promptSuffix) {
339
417
  options.prompt = `${options.prompt.trim()}\n${userConfig.promptSuffix}`;
340
418
  }
341
419
  resolvedOptions.prompt = options.prompt;
342
- const runOptions = buildRunOptions(resolvedOptions, { preview: true, previewMode });
420
+ const runOptions = buildRunOptions(resolvedOptions, { preview: true, previewMode, baseUrl: resolvedBaseUrl });
343
421
  if (engine === 'browser') {
344
422
  await runBrowserPreview({
345
423
  runOptions,
@@ -350,7 +428,24 @@ async function runRootCommand(options) {
350
428
  }, {});
351
429
  return;
352
430
  }
353
- await runOracle(runOptions, { log: console.log, write: (chunk) => process.stdout.write(chunk) });
431
+ // API dry-run/preview path
432
+ if (previewMode === 'summary') {
433
+ await runDryRunSummary({
434
+ engine,
435
+ runOptions,
436
+ cwd: process.cwd(),
437
+ version: VERSION,
438
+ log: console.log,
439
+ }, {});
440
+ return;
441
+ }
442
+ await runDryRunSummary({
443
+ engine,
444
+ runOptions,
445
+ cwd: process.cwd(),
446
+ version: VERSION,
447
+ log: console.log,
448
+ }, {});
354
449
  return;
355
450
  }
356
451
  if (!options.prompt) {
@@ -360,17 +455,6 @@ async function runRootCommand(options) {
360
455
  options.prompt = `${options.prompt.trim()}\n${userConfig.promptSuffix}`;
361
456
  }
362
457
  resolvedOptions.prompt = options.prompt;
363
- if (options.dryRun) {
364
- const baseRunOptions = buildRunOptions(resolvedOptions, { preview: false, previewMode: undefined });
365
- await runDryRunSummary({
366
- engine,
367
- runOptions: baseRunOptions,
368
- cwd: process.cwd(),
369
- version: VERSION,
370
- log: console.log,
371
- }, {});
372
- return;
373
- }
374
458
  if (options.file && options.file.length > 0) {
375
459
  await readFiles(options.file, { cwd: process.cwd() });
376
460
  }
@@ -384,17 +468,34 @@ async function runRootCommand(options) {
384
468
  const sessionMode = engine === 'browser' ? 'browser' : 'api';
385
469
  const browserModelLabelOverride = sessionMode === 'browser' ? resolveBrowserModelLabel(cliModelArg, resolvedModel) : undefined;
386
470
  const browserConfig = sessionMode === 'browser'
387
- ? buildBrowserConfig({
471
+ ? await buildBrowserConfig({
388
472
  ...options,
389
473
  model: resolvedModel,
390
474
  browserModelLabel: browserModelLabelOverride,
391
475
  })
392
476
  : undefined;
477
+ if (options.dryRun) {
478
+ const baseRunOptions = buildRunOptions(resolvedOptions, {
479
+ preview: false,
480
+ previewMode: undefined,
481
+ baseUrl: resolvedBaseUrl,
482
+ });
483
+ await runDryRunSummary({
484
+ engine,
485
+ runOptions: baseRunOptions,
486
+ cwd: process.cwd(),
487
+ version: VERSION,
488
+ log: console.log,
489
+ browserConfig,
490
+ }, {});
491
+ return;
492
+ }
393
493
  await ensureSessionStorage();
394
494
  const baseRunOptions = buildRunOptions(resolvedOptions, {
395
495
  preview: false,
396
496
  previewMode: undefined,
397
497
  background: userConfig.background ?? resolvedOptions.background,
498
+ baseUrl: resolvedBaseUrl,
398
499
  });
399
500
  enforceBrowserSearchFlag(baseRunOptions, sessionMode, console.log);
400
501
  if (sessionMode === 'browser' && baseRunOptions.search === false) {
@@ -406,12 +507,19 @@ async function runRootCommand(options) {
406
507
  mode: sessionMode,
407
508
  browserConfig,
408
509
  }, process.cwd(), notifications);
409
- const reattachCommand = `pnpm oracle session ${sessionMeta.id}`;
410
- console.log(chalk.bold(`Reattach later with: ${chalk.cyan(reattachCommand)}`));
411
- console.log('');
412
- const liveRunOptions = { ...baseRunOptions, sessionId: sessionMeta.id };
413
- const disableDetach = process.env.ORACLE_NO_DETACH === '1';
414
- const detached = disableDetach
510
+ const liveRunOptions = {
511
+ ...baseRunOptions,
512
+ sessionId: sessionMeta.id,
513
+ effectiveModelId,
514
+ };
515
+ const disableDetachEnv = process.env.ORACLE_NO_DETACH === '1';
516
+ const detachAllowed = shouldDetachSession({
517
+ engine,
518
+ model: resolvedModel,
519
+ waitPreference,
520
+ disableDetachEnv,
521
+ });
522
+ const detached = !detachAllowed
415
523
  ? false
416
524
  : await launchDetachedSession(sessionMeta.id).catch((error) => {
417
525
  const message = error instanceof Error ? error.message : String(error);
@@ -429,17 +537,15 @@ async function runRootCommand(options) {
429
537
  return;
430
538
  }
431
539
  if (detached === false) {
432
- await runInteractiveSession(sessionMeta, liveRunOptions, sessionMode, browserConfig, true, notifications, userConfig);
433
- console.log(chalk.bold(`Session ${sessionMeta.id} completed`));
540
+ await runInteractiveSession(sessionMeta, liveRunOptions, sessionMode, browserConfig, false, notifications, userConfig, true);
434
541
  return;
435
542
  }
436
543
  if (detached) {
437
544
  console.log(chalk.blue(`Reattach via: oracle session ${sessionMeta.id}`));
438
545
  await attachSession(sessionMeta.id, { suppressMetadata: true });
439
- console.log(chalk.bold(`Session ${sessionMeta.id} completed`));
440
546
  }
441
547
  }
442
- async function runInteractiveSession(sessionMeta, runOptions, mode, browserConfig, showReattachHint = true, notifications, userConfig) {
548
+ async function runInteractiveSession(sessionMeta, runOptions, mode, browserConfig, showReattachHint = true, notifications, userConfig, suppressSummary = false) {
443
549
  const { logLine, writeChunk, stream } = createSessionLogWriter(sessionMeta.id);
444
550
  let headerAugmented = false;
445
551
  const combinedLog = (message = '') => {
@@ -474,10 +580,12 @@ async function runInteractiveSession(sessionMeta, runOptions, mode, browserConfi
474
580
  notifications: notifications ?? deriveNotificationSettingsFromMetadata(sessionMeta, process.env, userConfig?.notify),
475
581
  });
476
582
  const latest = await readSessionMetadata(sessionMeta.id);
477
- const summary = latest ? formatCompletionSummary(latest, { includeSlug: true }) : null;
478
- if (summary) {
479
- console.log('\n' + chalk.green.bold(summary));
480
- logLine(summary); // plain text in log, colored on stdout
583
+ if (!suppressSummary) {
584
+ const summary = latest ? formatCompletionSummary(latest, { includeSlug: true }) : null;
585
+ if (summary) {
586
+ console.log('\n' + chalk.green.bold(summary));
587
+ logLine(summary); // plain text in log, colored on stdout
588
+ }
481
589
  }
482
590
  }
483
591
  catch (error) {
@@ -611,7 +719,16 @@ program.action(async function () {
611
719
  const options = this.optsWithGlobals();
612
720
  await runRootCommand(options);
613
721
  });
614
- await program.parseAsync(process.argv).catch((error) => {
722
+ async function main() {
723
+ const parsePromise = program.parseAsync(process.argv);
724
+ const sigintPromise = once(process, 'SIGINT').then(() => 'sigint');
725
+ const result = await Promise.race([parsePromise.then(() => 'parsed'), sigintPromise]);
726
+ if (result === 'sigint') {
727
+ console.log(chalk.yellow('\nCancelled.'));
728
+ process.exitCode = 130;
729
+ }
730
+ }
731
+ void main().catch((error) => {
615
732
  if (error instanceof Error) {
616
733
  if (!isErrorLogged(error)) {
617
734
  console.error(chalk.red('✖'), error.message);
@@ -6,6 +6,9 @@ export const DEFAULT_BROWSER_CONFIG = {
6
6
  timeoutMs: 900_000,
7
7
  inputTimeoutMs: 30_000,
8
8
  cookieSync: true,
9
+ cookieNames: null,
10
+ inlineCookies: null,
11
+ inlineCookiesSource: null,
9
12
  headless: false,
10
13
  keepBrowser: false,
11
14
  hideWindow: false,
@@ -21,6 +24,9 @@ export function resolveBrowserConfig(config) {
21
24
  timeoutMs: config?.timeoutMs ?? DEFAULT_BROWSER_CONFIG.timeoutMs,
22
25
  inputTimeoutMs: config?.inputTimeoutMs ?? DEFAULT_BROWSER_CONFIG.inputTimeoutMs,
23
26
  cookieSync: config?.cookieSync ?? DEFAULT_BROWSER_CONFIG.cookieSync,
27
+ cookieNames: config?.cookieNames ?? DEFAULT_BROWSER_CONFIG.cookieNames,
28
+ inlineCookies: config?.inlineCookies ?? DEFAULT_BROWSER_CONFIG.inlineCookies,
29
+ inlineCookiesSource: config?.inlineCookiesSource ?? DEFAULT_BROWSER_CONFIG.inlineCookiesSource,
24
30
  headless: config?.headless ?? DEFAULT_BROWSER_CONFIG.headless,
25
31
  keepBrowser: config?.keepBrowser ?? DEFAULT_BROWSER_CONFIG.keepBrowser,
26
32
  hideWindow: config?.hideWindow ?? DEFAULT_BROWSER_CONFIG.hideWindow,
@@ -4,21 +4,18 @@ import { fileURLToPath } from 'node:url';
4
4
  import { COOKIE_URLS } from './constants.js';
5
5
  export class ChromeCookieSyncError extends Error {
6
6
  }
7
- export async function syncCookies(Network, url, profile, logger, allowErrors = false) {
7
+ export async function syncCookies(Network, url, profile, logger, options = {}) {
8
+ const { allowErrors = false, filterNames, inlineCookies } = options;
8
9
  try {
9
- const cookies = await readChromeCookies(url, profile);
10
+ const cookies = inlineCookies?.length
11
+ ? normalizeInlineCookies(inlineCookies, new URL(url).hostname)
12
+ : await readChromeCookies(url, profile, filterNames ?? undefined);
10
13
  if (!cookies.length) {
11
14
  return 0;
12
15
  }
13
16
  let applied = 0;
14
17
  for (const cookie of cookies) {
15
- const cookieWithUrl = { ...cookie };
16
- if (!cookieWithUrl.domain || cookieWithUrl.domain === 'localhost') {
17
- cookieWithUrl.url = url;
18
- }
19
- else if (!cookieWithUrl.domain.startsWith('.')) {
20
- cookieWithUrl.url = `https://${cookieWithUrl.domain}`;
21
- }
18
+ const cookieWithUrl = attachUrl(cookie, url);
22
19
  try {
23
20
  const result = await Network.setCookie(cookieWithUrl);
24
21
  if (result?.success) {
@@ -41,10 +38,11 @@ export async function syncCookies(Network, url, profile, logger, allowErrors = f
41
38
  throw error instanceof ChromeCookieSyncError ? error : new ChromeCookieSyncError(message);
42
39
  }
43
40
  }
44
- async function readChromeCookies(url, profile) {
41
+ async function readChromeCookies(url, profile, filterNames) {
45
42
  const chromeModule = await loadChromeCookiesModule();
46
43
  const urlsToCheck = Array.from(new Set([stripQuery(url), ...COOKIE_URLS]));
47
44
  const merged = new Map();
45
+ const allowlist = normalizeCookieNames(filterNames);
48
46
  for (const candidateUrl of urlsToCheck) {
49
47
  let rawCookies;
50
48
  rawCookies = await chromeModule.getCookiesPromised(candidateUrl, 'puppeteer', profile ?? undefined);
@@ -54,7 +52,7 @@ async function readChromeCookies(url, profile) {
54
52
  const fallbackHostname = new URL(candidateUrl).hostname;
55
53
  for (const cookie of rawCookies) {
56
54
  const normalized = normalizeCookie(cookie, fallbackHostname);
57
- if (!normalized) {
55
+ if (!normalized || (allowlist && !allowlist.has(normalized.name))) {
58
56
  continue;
59
57
  }
60
58
  const key = `${normalized.domain ?? fallbackHostname}:${normalized.name}`;
@@ -83,6 +81,46 @@ function normalizeCookie(cookie, fallbackHost) {
83
81
  httpOnly,
84
82
  };
85
83
  }
84
+ function normalizeInlineCookies(rawCookies, fallbackHost) {
85
+ const merged = new Map();
86
+ for (const cookie of rawCookies) {
87
+ if (!cookie?.name)
88
+ continue;
89
+ const normalized = {
90
+ ...cookie,
91
+ name: cookie.name,
92
+ value: cookie.value ?? '',
93
+ domain: cookie.domain ?? fallbackHost,
94
+ path: cookie.path ?? '/',
95
+ expires: normalizeExpiration(cookie.expires),
96
+ secure: cookie.secure ?? true,
97
+ httpOnly: cookie.httpOnly ?? false,
98
+ };
99
+ const key = `${normalized.domain ?? fallbackHost}:${normalized.name}`;
100
+ if (!merged.has(key)) {
101
+ merged.set(key, normalized);
102
+ }
103
+ }
104
+ return Array.from(merged.values());
105
+ }
106
+ function normalizeCookieNames(names) {
107
+ if (!names || names.length === 0) {
108
+ return null;
109
+ }
110
+ return new Set(names.map((name) => name.trim()).filter(Boolean));
111
+ }
112
+ function attachUrl(cookie, fallbackUrl) {
113
+ const cookieWithUrl = { ...cookie };
114
+ if (!cookieWithUrl.url) {
115
+ if (!cookieWithUrl.domain || cookieWithUrl.domain === 'localhost') {
116
+ cookieWithUrl.url = fallbackUrl;
117
+ }
118
+ else if (!cookieWithUrl.domain.startsWith('.')) {
119
+ cookieWithUrl.url = `https://${cookieWithUrl.domain}`;
120
+ }
121
+ }
122
+ return cookieWithUrl;
123
+ }
86
124
  function stripQuery(url) {
87
125
  try {
88
126
  const parsed = new URL(url);
@@ -64,11 +64,24 @@ export async function runBrowserMode(options) {
64
64
  await Promise.all(domainEnablers);
65
65
  await Network.clearBrowserCookies();
66
66
  if (config.cookieSync) {
67
- logger('Heads-up: macOS may prompt for your Keychain password to read Chrome cookies; approve it to stay signed in or rerun with --browser-no-cookie-sync / --browser-allow-cookie-errors.');
68
- const cookieCount = await syncCookies(Network, config.url, config.chromeProfile, logger, config.allowCookieErrors ?? false);
67
+ if (!config.inlineCookies) {
68
+ logger('Heads-up: macOS may prompt for your Keychain password to read Chrome cookies; approve it to stay signed in or rerun with --browser-no-cookie-sync / --browser-allow-cookie-errors / --browser-inline-cookies[(-file)]. Inline cookies skip Chrome + Keychain entirely.');
69
+ }
70
+ else {
71
+ logger('Applying inline cookies (skipping Chrome profile read and Keychain prompt)');
72
+ }
73
+ const cookieCount = await syncCookies(Network, config.url, config.chromeProfile, logger, {
74
+ allowErrors: config.allowCookieErrors ?? false,
75
+ filterNames: config.cookieNames ?? undefined,
76
+ inlineCookies: config.inlineCookies ?? undefined,
77
+ });
69
78
  logger(cookieCount > 0
70
- ? `Copied ${cookieCount} cookies from Chrome profile ${config.chromeProfile ?? 'Default'}`
71
- : 'No Chrome cookies found; continuing without session reuse');
79
+ ? config.inlineCookies
80
+ ? `Applied ${cookieCount} inline cookies`
81
+ : `Copied ${cookieCount} cookies from Chrome profile ${config.chromeProfile ?? 'Default'}`
82
+ : config.inlineCookies
83
+ ? 'No inline cookies applied; continuing without session reuse'
84
+ : 'No Chrome cookies found; continuing without session reuse');
72
85
  }
73
86
  else {
74
87
  logger('Skipping Chrome cookie sync (--browser-no-cookie-sync)');
@@ -212,7 +225,7 @@ export function formatThinkingLog(startedAt, now, message, locatorSuffix) {
212
225
  .toString()
213
226
  .padStart(3, ' ');
214
227
  const statusLabel = message ? ` — ${message}` : '';
215
- return `[${elapsedText} / ~10m] ${bar} ${pct}%${statusLabel}${locatorSuffix}`;
228
+ return `${bar} ${pct}% [${elapsedText} / ~10m]${statusLabel}${locatorSuffix}`;
216
229
  }
217
230
  function startThinkingStatusMonitor(Runtime, logger, includeDiagnostics = false) {
218
231
  let stopped = false;
@@ -4,6 +4,11 @@ import { runBrowserMode } from '../browserMode.js';
4
4
  import { assembleBrowserPrompt } from './prompt.js';
5
5
  import { BrowserAutomationError } from '../oracle/errors.js';
6
6
  export async function runBrowserSessionExecution({ runOptions, browserConfig, cwd, log, cliVersion }, deps = {}) {
7
+ if (runOptions.model.startsWith('gemini')) {
8
+ throw new BrowserAutomationError('Gemini models are not available in browser mode. Re-run with --engine api.', {
9
+ stage: 'preflight',
10
+ });
11
+ }
7
12
  const assemblePrompt = deps.assemblePrompt ?? assembleBrowserPrompt;
8
13
  const executeBrowser = deps.executeBrowser ?? runBrowserMode;
9
14
  const promptArtifacts = await assemblePrompt(runOptions, { cwd });
@@ -24,6 +29,9 @@ export async function runBrowserSessionExecution({ runOptions, browserConfig, cw
24
29
  }
25
30
  }
26
31
  const headerLine = `oracle (${cliVersion}) launching browser mode (${runOptions.model}) with ~${promptArtifacts.estimatedInputTokens.toLocaleString()} tokens`;
32
+ if (promptArtifacts.bundled) {
33
+ log(chalk.yellow(`[browser] Packed ${promptArtifacts.bundled.originalCount} files into ${promptArtifacts.bundled.bundlePath}. If automation fails, you can drag this file into ChatGPT manually.`));
34
+ }
27
35
  const automationLogger = ((message) => {
28
36
  if (typeof message === 'string') {
29
37
  log(message);
@@ -64,7 +72,8 @@ export async function runBrowserSessionExecution({ runOptions, browserConfig, cw
64
72
  totalTokens: promptArtifacts.estimatedInputTokens + browserResult.answerTokens,
65
73
  };
66
74
  const tokensDisplay = `${usage.inputTokens}/${usage.outputTokens}/${usage.reasoningTokens}/${usage.totalTokens}`;
67
- const statsParts = [`${runOptions.model}[browser]`, `tok(i/o/r/t)=${tokensDisplay}`];
75
+ const tokensLabel = runOptions.verbose ? 'tokens (input/output/reasoning/total)' : 'tok(i/o/r/t)';
76
+ const statsParts = [`${runOptions.model}[browser]`, `${tokensLabel}=${tokensDisplay}`];
68
77
  if (runOptions.file && runOptions.file.length > 0) {
69
78
  statsParts.push(`files=${runOptions.file.length}`);
70
79
  }