@steipete/oracle 0.8.6 → 0.9.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 (41) hide show
  1. package/README.md +76 -4
  2. package/dist/bin/oracle-cli.js +188 -7
  3. package/dist/src/browser/actions/modelSelection.js +60 -8
  4. package/dist/src/browser/actions/navigation.js +2 -1
  5. package/dist/src/browser/constants.js +1 -1
  6. package/dist/src/browser/index.js +73 -19
  7. package/dist/src/browser/providerDomFlow.js +17 -0
  8. package/dist/src/browser/providers/chatgptDomProvider.js +49 -0
  9. package/dist/src/browser/providers/geminiDeepThinkDomProvider.js +245 -0
  10. package/dist/src/browser/providers/index.js +2 -0
  11. package/dist/src/cli/browserConfig.js +12 -6
  12. package/dist/src/cli/detach.js +5 -2
  13. package/dist/src/cli/fileSize.js +11 -0
  14. package/dist/src/cli/help.js +3 -3
  15. package/dist/src/cli/markdownBundle.js +5 -1
  16. package/dist/src/cli/options.js +40 -3
  17. package/dist/src/cli/runOptions.js +11 -3
  18. package/dist/src/cli/sessionDisplay.js +91 -2
  19. package/dist/src/cli/sessionLineage.js +56 -0
  20. package/dist/src/cli/sessionRunner.js +20 -2
  21. package/dist/src/cli/sessionTable.js +2 -1
  22. package/dist/src/cli/tui/index.js +2 -0
  23. package/dist/src/gemini-web/browserSessionManager.js +76 -0
  24. package/dist/src/gemini-web/client.js +16 -5
  25. package/dist/src/gemini-web/executionClients.js +1 -0
  26. package/dist/src/gemini-web/executionMode.js +18 -0
  27. package/dist/src/gemini-web/executor.js +273 -120
  28. package/dist/src/mcp/tools/consult.js +34 -21
  29. package/dist/src/oracle/client.js +42 -13
  30. package/dist/src/oracle/config.js +43 -7
  31. package/dist/src/oracle/errors.js +2 -2
  32. package/dist/src/oracle/files.js +20 -5
  33. package/dist/src/oracle/gemini.js +3 -0
  34. package/dist/src/oracle/request.js +7 -2
  35. package/dist/src/oracle/run.js +22 -12
  36. package/dist/src/sessionManager.js +4 -0
  37. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  38. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  39. package/package.json +18 -18
  40. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  41. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
package/README.md CHANGED
@@ -11,7 +11,39 @@
11
11
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-green?style=for-the-badge" alt="MIT License"></a>
12
12
  </p>
13
13
 
14
- Oracle bundles your prompt and files so another AI can answer with real context. It speaks GPT-5.1 Pro (default alias to GPT-5.2 Pro on the API), GPT-5.1 Codex (API-only), GPT-5.1, GPT-5.2, Gemini 3 Pro, Claude Sonnet 4.5, Claude Opus 4.1, and more—and it can ask one or multiple models in a single run. Browser automation is available; use `--browser-model-strategy current` to keep the active ChatGPT model (or `ignore` to skip the picker). API remains the most reliable path, and `--copy` is an easy manual fallback.
14
+ Oracle bundles your prompt and files so another AI can answer with real context. It speaks GPT-5.4 Pro (default), GPT-5.4, GPT-5.1 Pro, GPT-5.1 Codex (API-only), GPT-5.1, GPT-5.2, Gemini 3.1 Pro (API-only), Gemini 3 Pro, Claude Sonnet 4.5, Claude Opus 4.1, and more—and it can ask one or multiple models in a single run. Browser automation is available; use `--browser-model-strategy current` to keep the active ChatGPT model (or `ignore` to skip the picker). API remains the most reliable path, and `--copy` is an easy manual fallback.
15
+
16
+ ## Setting up (macOS Browser Mode)
17
+
18
+ Browser mode lets you use GPT-5.2 Pro without any API keys — it automates your Chrome browser directly.
19
+
20
+ ### First-time login
21
+
22
+ Run this once to create an automation profile and log into ChatGPT. The browser will stay open so you can complete the login:
23
+
24
+ ```bash
25
+ oracle --engine browser --browser-manual-login \
26
+ --browser-keep-browser --browser-input-timeout 120000 \
27
+ -p "HI"
28
+ ```
29
+
30
+ ### Subsequent runs
31
+
32
+ Once logged in, the automation profile is saved. Use this for all future runs:
33
+
34
+ ```bash
35
+ oracle --engine browser --browser-manual-login \
36
+ --browser-auto-reattach-delay 5s \
37
+ --browser-auto-reattach-interval 3s \
38
+ --browser-auto-reattach-timeout 60s \
39
+ -p "your prompt"
40
+ ```
41
+
42
+ > **Why these flags?**
43
+ > - `--browser-manual-login` — Skips macOS Keychain cookie access (avoids repeated permission popups)
44
+ > - `--browser-auto-reattach-*` — Reconnects when ChatGPT redirects mid-page-load (fixes "Inspected target navigated or closed" error)
45
+ > - `--browser-keep-browser` — Keeps browser open for first-time login (not needed after)
46
+ > - `--browser-input-timeout 120000` — Gives you 2 minutes to log in on first run
15
47
 
16
48
  ## Quick start
17
49
 
@@ -30,6 +62,12 @@ npx -y @steipete/oracle -p "Write a concise architecture note for the storage ad
30
62
  # Multi-model API run
31
63
  npx -y @steipete/oracle -p "Cross-check the data layer assumptions" --models gpt-5.1-pro,gemini-3-pro --file "src/**/*.ts"
32
64
 
65
+ # Follow up from an existing OpenAI/Azure session id
66
+ npx -y @steipete/oracle --engine api --model gpt-5.2-pro --followup release-readiness-audit --followup-model gpt-5.2-pro -p "Re-evaluate with this new context" --file "src/**/*.ts"
67
+
68
+ # Follow up directly from an OpenAI Responses API id
69
+ npx -y @steipete/oracle --engine api --model gpt-5.2-pro --followup resp_abc1234567890 -p "Continue from this response" --file docs/notes.md
70
+
33
71
  # Preview without spending tokens
34
72
  npx -y @steipete/oracle --dry-run summary -p "Check release notes" --file docs/release-notes.md
35
73
 
@@ -53,7 +91,7 @@ Engine auto-picks API when `OPENAI_API_KEY` is set, otherwise browser; browser i
53
91
  ## Integration
54
92
 
55
93
  **CLI**
56
- - API mode expects API keys in your environment: `OPENAI_API_KEY` (GPT-5.x), `GEMINI_API_KEY` (Gemini 3 Pro), `ANTHROPIC_API_KEY` (Claude Sonnet 4.5 / Opus 4.1).
94
+ - API mode expects API keys in your environment: `OPENAI_API_KEY` (GPT-5.x), `GEMINI_API_KEY` (Gemini 3.1 Pro / Gemini 3 Pro), `ANTHROPIC_API_KEY` (Claude Sonnet 4.5 / Opus 4.1).
57
95
  - Gemini browser mode uses Chrome cookies instead of an API key—just be logged into `gemini.google.com` in Chrome (no Python/venv required).
58
96
  - If your Gemini account can’t access “Pro”, Oracle auto-falls back to a supported model for web runs (and logs the fallback in verbose mode).
59
97
  - Prefer API mode or `--copy` + manual paste; browser automation is experimental.
@@ -96,11 +134,43 @@ npx -y @steipete/oracle oracle-mcp
96
134
  - Multi-model API runs with aggregated cost/usage, including OpenRouter IDs alongside first-party models.
97
135
  - Render/copy bundles for manual paste into ChatGPT when automation is blocked.
98
136
  - GPT‑5 Pro API runs detach by default; reattach via `oracle session <id>` / `oracle status` or block with `--wait`.
137
+ - OpenAI/Azure follow-up API runs can continue from `--followup <sessionId|responseId>`; for multi-model parents, add `--followup-model <model>`.
99
138
  - Azure endpoints supported via `--azure-endpoint/--azure-deployment/--azure-api-version` or `AZURE_OPENAI_*` envs.
100
139
  - File safety: globs/excludes, size guards, `--files-report`.
101
140
  - Sessions you can replay (`oracle status`, `oracle session <id> --render`).
102
141
  - Session logs and bundles live in `~/.oracle/sessions` (override with `ORACLE_HOME_DIR`).
103
142
 
143
+ ## Follow-up and lineage
144
+
145
+ Use `--followup` to continue an existing OpenAI/Azure Responses API run with additional context/files:
146
+
147
+ ```bash
148
+ oracle \
149
+ --engine api \
150
+ --model gpt-5.2-pro \
151
+ --followup <existing-session-id-or-resp_id> \
152
+ --followup-model gpt-5.2-pro \
153
+ --slug "my-followup-run" \
154
+ --wait \
155
+ -p "Follow-up: re-evaluate the previous recommendation with the attached files." \
156
+ --file "server/src/strategy/plan.ts" \
157
+ --file "server/src/strategy/executor.ts"
158
+ ```
159
+
160
+ When the parent session used `--models`, `--followup-model` picks which model's response id to chain from.
161
+ Custom `--base-url` providers plus Gemini/Claude API runs are excluded here because they do not preserve `previous_response_id` in Oracle.
162
+
163
+ `oracle status` shows parent/child lineage in tree form:
164
+
165
+ ```text
166
+ Recent Sessions
167
+ Status Model Mode Timestamp Chars Cost Slug
168
+ completed gpt-5.2-pro api 03/01/2026 09:00 AM 1800 $2.110 architecture-review-parent
169
+ completed gpt-5.2-pro api 03/01/2026 09:14 AM 2200 $2.980 ├─ architecture-review-followup
170
+ running gpt-5.2-pro api 03/01/2026 09:22 AM 1400 - │ └─ architecture-review-implementation-pass
171
+ pending gpt-5.2-pro api 03/01/2026 09:25 AM 900 - └─ architecture-review-risk-check
172
+ ```
173
+
104
174
  ## Browser auto-reattach (long Pro runs)
105
175
 
106
176
  When browser runs time out (common with long GPT‑5.x Pro responses), Oracle can keep polling the existing ChatGPT tab and capture the final answer without manual `oracle session <id>` commands.
@@ -126,8 +196,10 @@ oracle --engine browser \
126
196
  | `-p, --prompt <text>` | Required prompt. |
127
197
  | `-f, --file <paths...>` | Attach files/dirs (globs + `!` excludes). |
128
198
  | `-e, --engine <api\|browser>` | Choose API or browser (browser is experimental). |
129
- | `-m, --model <name>` | Built-ins (`gpt-5.1-pro` default, `gpt-5-pro`, `gpt-5.1`, `gpt-5.1-codex`, `gpt-5.2`, `gpt-5.2-instant`, `gpt-5.2-pro`, `gemini-3-pro`, `claude-4.5-sonnet`, `claude-4.1-opus`) plus any OpenRouter id (e.g., `minimax/minimax-m2`, `openai/gpt-4o-mini`). |
199
+ | `-m, --model <name>` | Built-ins (`gpt-5.4-pro` default, `gpt-5.4`, `gpt-5.1-pro`, `gpt-5-pro`, `gpt-5.1`, `gpt-5.1-codex`, `gpt-5.2`, `gpt-5.2-instant`, `gpt-5.2-pro`, `gemini-3.1-pro` API-only, `gemini-3-pro`, `claude-4.5-sonnet`, `claude-4.1-opus`) plus any OpenRouter id (e.g., `minimax/minimax-m2`, `openai/gpt-4o-mini`). |
130
200
  | `--models <list>` | Comma-separated API models (mix built-ins and OpenRouter ids) for multi-model runs. |
201
+ | `--followup <sessionId\|responseId>` | Continue an OpenAI/Azure Responses API run from a stored oracle session or `resp_...` response id. |
202
+ | `--followup-model <model>` | For multi-model OpenAI/Azure parent sessions, choose which model response to continue from. |
131
203
  | `--base-url <url>` | Point API runs at LiteLLM/Azure/OpenRouter/etc. |
132
204
  | `--chatgpt-url <url>` | Target a ChatGPT workspace/folder (browser). |
133
205
  | `--browser-model-strategy <select\|current\|ignore>` | Control ChatGPT model selection in browser mode (current keeps the active model; ignore skips the picker). |
@@ -161,7 +233,7 @@ oracle --engine browser \
161
233
  Put defaults in `~/.oracle/config.json` (JSON5). Example:
162
234
  ```json5
163
235
  {
164
- model: "gpt-5.1-pro",
236
+ model: "gpt-5.4-pro",
165
237
  engine: "api",
166
238
  filesReport: true,
167
239
  browser: {
@@ -47,6 +47,7 @@ import { loadUserConfig } from '../src/config.js';
47
47
  import { applyBrowserDefaultsFromConfig } from '../src/cli/browserDefaults.js';
48
48
  import { shouldBlockDuplicatePrompt } from '../src/cli/duplicatePromptGuard.js';
49
49
  import { resolveRemoteServiceConfig } from '../src/remote/remoteServiceConfig.js';
50
+ import { resolveConfiguredMaxFileSizeBytes } from '../src/cli/fileSize.js';
50
51
  const VERSION = getCliVersion();
51
52
  const CLI_ENTRYPOINT = fileURLToPath(import.meta.url);
52
53
  const LEGACY_FLAG_ALIASES = new Map([
@@ -98,12 +99,14 @@ program.hook('preAction', (thisCommand) => {
98
99
  });
99
100
  program
100
101
  .name('oracle')
101
- .description('One-shot GPT-5.2 Pro / GPT-5.2 / GPT-5.1 Codex tool for hard questions that benefit from large file context and server-side search.')
102
+ .description('One-shot GPT-5.4 Pro / GPT-5.4 / GPT-5.1 Codex tool for hard questions that benefit from large file context and server-side search.')
102
103
  .version(VERSION)
103
104
  .argument('[prompt]', 'Prompt text (shorthand for --prompt).')
104
105
  .option('-p, --prompt <text>', 'User prompt to send to the model.')
105
106
  .addOption(new Option('--message <text>', 'Alias for --prompt.').hideHelp())
106
- .option('-f, --file <paths...>', 'Files/directories or glob patterns to attach (prefix with !pattern to exclude). Files larger than 1 MB are rejected automatically.', collectPaths, [])
107
+ .option('--followup <sessionId|responseId>', 'Continue an OpenAI/Azure Responses API run from a stored response id (resp_...) or from a stored oracle session id.')
108
+ .option('--followup-model <model>', 'When following up a multi-model session, choose which model response to continue from.')
109
+ .option('-f, --file <paths...>', 'Files/directories or glob patterns to attach (prefix with !pattern to exclude). Oversized files are rejected automatically (default cap: 1 MB; configurable via ORACLE_MAX_FILE_SIZE_BYTES or config.maxFileSizeBytes).', collectPaths, [])
107
110
  .addOption(new Option('--include <paths...>', 'Alias for --file.')
108
111
  .argParser(collectPaths)
109
112
  .default([])
@@ -123,8 +126,8 @@ program
123
126
  .addOption(new Option('--copy-markdown', 'Copy the assembled markdown bundle to the clipboard; pair with --render to print it too.').default(false))
124
127
  .addOption(new Option('--copy').hideHelp().default(false))
125
128
  .option('-s, --slug <words>', 'Custom session slug (3-5 words).')
126
- .option('-m, --model <model>', 'Model to target (gpt-5.2-pro default; also supports gpt-5.1-pro alias). Also gpt-5-pro, gpt-5.1, gpt-5.1-codex API-only, gpt-5.2, gpt-5.2-instant, gpt-5.2-pro, gemini-3-pro, claude-4.5-sonnet, claude-4.1-opus, or ChatGPT labels like "5.2 Thinking" for browser runs).', normalizeModelOption)
127
- .addOption(new Option('--models <models>', 'Comma-separated API model list to query in parallel (e.g., "gpt-5.2-pro,gemini-3-pro").')
129
+ .option('-m, --model <model>', 'Model to target (gpt-5.4-pro default). Also gpt-5.4, gpt-5.1-pro, gpt-5-pro, gpt-5.1, gpt-5.1-codex API-only, gpt-5.2, gpt-5.2-instant, gpt-5.2-pro, gemini-3.1-pro API-only, gemini-3-pro, claude-4.5-sonnet, claude-4.1-opus, or ChatGPT labels like "5.2 Thinking" for browser runs).', normalizeModelOption)
130
+ .addOption(new Option('--models <models>', 'Comma-separated API model list to query in parallel (e.g., "gpt-5.4-pro,gemini-3-pro").')
128
131
  .argParser(collectModelList)
129
132
  .default([]))
130
133
  .addOption(new Option('-e, --engine <mode>', 'Execution engine (api | browser). Browser engine: GPT models automate ChatGPT; Gemini models use a cookie-based client for gemini.google.com. If omitted, oracle picks api when OPENAI_API_KEY is set, otherwise browser.').choices(['api', 'browser']))
@@ -135,7 +138,7 @@ program
135
138
  .addOption(new Option('--no-notify', 'Disable desktop notifications.').default(undefined))
136
139
  .addOption(new Option('--notify-sound', 'Play a notification sound on completion (default off).').default(undefined))
137
140
  .addOption(new Option('--no-notify-sound', 'Disable notification sounds.').default(undefined))
138
- .addOption(new Option('--timeout <seconds|auto>', 'Overall timeout before aborting the API call (auto = 60m for gpt-5.2-pro, 120s otherwise).')
141
+ .addOption(new Option('--timeout <seconds|auto>', 'Overall timeout before aborting the API call (auto = 60m for gpt-5.4-pro, 120s otherwise).')
139
142
  .argParser(parseTimeoutOption)
140
143
  .default('auto'))
141
144
  .addOption(new Option('--background', 'Use Responses API background mode (create + retrieve) for API runs.').default(undefined))
@@ -413,8 +416,10 @@ function buildRunOptions(options, overrides = {}) {
413
416
  prompt: options.prompt,
414
417
  model: options.model,
415
418
  models: overrides.models ?? options.models,
419
+ previousResponseId: overrides.previousResponseId ?? options.previousResponseId,
416
420
  effectiveModelId: overrides.effectiveModelId ?? options.effectiveModelId ?? options.model,
417
421
  file: overrides.file ?? options.file ?? [],
422
+ maxFileSizeBytes: overrides.maxFileSizeBytes ?? options.maxFileSizeBytes,
418
423
  slug: overrides.slug ?? options.slug,
419
424
  filesReport: overrides.filesReport ?? options.filesReport,
420
425
  maxInput: overrides.maxInput ?? options.maxInput,
@@ -454,14 +459,141 @@ function resolveHeartbeatIntervalMs(seconds) {
454
459
  }
455
460
  return Math.round(seconds * 1000);
456
461
  }
462
+ function assertFollowupSupported({ engine, model, baseUrl, azureEndpoint, }) {
463
+ if (engine !== 'api') {
464
+ throw new Error('--followup requires --engine api.');
465
+ }
466
+ if (model.startsWith('gemini') || model.startsWith('claude')) {
467
+ throw new Error(`--followup is only supported for OpenAI Responses API runs. Model ${model} uses a provider client without previous_response_id support.`);
468
+ }
469
+ if (baseUrl && !azureEndpoint) {
470
+ throw new Error('--followup is only supported for the default OpenAI Responses API or Azure OpenAI Responses. Custom --base-url providers are not supported.');
471
+ }
472
+ }
473
+ function levenshteinDistance(a, b) {
474
+ if (a === b)
475
+ return 0;
476
+ if (a.length === 0)
477
+ return b.length;
478
+ if (b.length === 0)
479
+ return a.length;
480
+ const previous = new Array(b.length + 1);
481
+ const current = new Array(b.length + 1);
482
+ for (let j = 0; j <= b.length; j += 1) {
483
+ previous[j] = j;
484
+ }
485
+ for (let i = 1; i <= a.length; i += 1) {
486
+ current[0] = i;
487
+ for (let j = 1; j <= b.length; j += 1) {
488
+ const substitutionCost = a[i - 1] === b[j - 1] ? 0 : 1;
489
+ current[j] = Math.min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + substitutionCost);
490
+ }
491
+ for (let j = 0; j <= b.length; j += 1) {
492
+ previous[j] = current[j];
493
+ }
494
+ }
495
+ return previous[b.length];
496
+ }
497
+ function scoreSessionSimilarity(input, candidate) {
498
+ if (input === candidate)
499
+ return 1;
500
+ if (candidate.startsWith(input) || input.startsWith(candidate))
501
+ return 0.95;
502
+ if (candidate.includes(input) || input.includes(candidate))
503
+ return 0.8;
504
+ const distance = levenshteinDistance(input, candidate);
505
+ const maxLength = Math.max(input.length, candidate.length);
506
+ if (maxLength === 0)
507
+ return 0;
508
+ return Math.max(0, 1 - distance / maxLength);
509
+ }
510
+ async function suggestFollowupSessionIds(input, limit = 3) {
511
+ const normalizedInput = input.trim().toLowerCase();
512
+ if (!normalizedInput)
513
+ return [];
514
+ const sessions = await sessionStore.listSessions().catch(() => []);
515
+ const seen = new Set();
516
+ const ranked = sessions
517
+ .map((meta) => meta.id)
518
+ .filter((id) => typeof id === 'string' && id.length > 0)
519
+ .filter((id) => {
520
+ if (seen.has(id))
521
+ return false;
522
+ seen.add(id);
523
+ return true;
524
+ })
525
+ .map((id) => ({ id, score: scoreSessionSimilarity(normalizedInput, id.toLowerCase()) }))
526
+ .filter((entry) => entry.score >= 0.45)
527
+ .sort((a, b) => b.score - a.score)
528
+ .slice(0, limit);
529
+ return ranked.map((entry) => entry.id);
530
+ }
531
+ async function resolveFollowupReference(value, followupModel) {
532
+ const trimmed = value.trim();
533
+ if (trimmed.length === 0) {
534
+ throw new Error('--followup requires a session id or response id.');
535
+ }
536
+ if (trimmed.startsWith('resp_')) {
537
+ return { responseId: trimmed };
538
+ }
539
+ // Treat as oracle session id (slug).
540
+ const meta = await sessionStore.readSession(trimmed);
541
+ if (!meta) {
542
+ const suggestions = await suggestFollowupSessionIds(trimmed);
543
+ const suggestionText = suggestions.length > 0 ? ` Did you mean: ${suggestions.map((id) => `"${id}"`).join(', ')}?` : '';
544
+ throw new Error(`No session found with ID ${trimmed}.${suggestionText} Run "oracle status --hours 72 --limit 20" to list recent sessions.`);
545
+ }
546
+ const fromMetadata = extractResponseIdFromSession(meta, followupModel);
547
+ if (fromMetadata) {
548
+ return { responseId: fromMetadata, sessionId: meta.id };
549
+ }
550
+ // Fallback: scrape the log for a response id (covers older sessions / edge cases).
551
+ const logText = await sessionStore.readLog(trimmed).catch(() => '');
552
+ const matches = logText.match(/resp_[A-Za-z0-9]+/g) ?? [];
553
+ const last = matches.length > 0 ? matches[matches.length - 1] : null;
554
+ if (last) {
555
+ return { responseId: last, sessionId: meta.id };
556
+ }
557
+ throw new Error(`Session ${trimmed} does not contain a stored response id. Ensure the original run produced a Responses API response id (background/store helps).`);
558
+ }
559
+ function extractResponseIdFromSession(meta, followupModel) {
560
+ // Single-model sessions store response metadata at the session root.
561
+ const rootResponse = meta.response ?? null;
562
+ const rootResponseId = rootResponse?.responseId ?? rootResponse?.id;
563
+ if (rootResponseId && rootResponseId.startsWith('resp_')) {
564
+ return rootResponseId;
565
+ }
566
+ const runs = Array.isArray(meta.models) ? meta.models : [];
567
+ if (runs.length === 0) {
568
+ return null;
569
+ }
570
+ const pickRun = () => {
571
+ if (followupModel) {
572
+ return runs.find((r) => r.model === followupModel) ?? null;
573
+ }
574
+ return runs.length === 1 ? runs[0] : null;
575
+ };
576
+ const chosen = pickRun();
577
+ if (!chosen) {
578
+ const models = runs.map((r) => r.model).join(', ');
579
+ throw new Error(followupModel
580
+ ? `Session ${meta.id} has no model named ${followupModel}. Available: ${models}`
581
+ : `Session ${meta.id} has multiple model runs. Re-run with --followup-model. Available: ${models}`);
582
+ }
583
+ const runResponse = chosen.response ?? null;
584
+ const runResponseId = runResponse?.responseId ?? runResponse?.id;
585
+ return runResponseId && runResponseId.startsWith('resp_') ? runResponseId : null;
586
+ }
457
587
  function buildRunOptionsFromMetadata(metadata) {
458
588
  const stored = metadata.options ?? {};
459
589
  return {
460
590
  prompt: stored.prompt ?? '',
461
591
  model: stored.model ?? DEFAULT_MODEL,
462
592
  models: stored.models,
593
+ previousResponseId: stored.previousResponseId,
463
594
  effectiveModelId: stored.effectiveModelId ?? stored.model,
464
595
  file: stored.file ?? [],
596
+ maxFileSizeBytes: stored.maxFileSizeBytes,
465
597
  slug: stored.slug,
466
598
  filesReport: stored.filesReport,
467
599
  maxInput: stored.maxInput,
@@ -664,6 +796,14 @@ async function runRootCommand(options) {
664
796
  throw new Error('--remote-host does not support --models yet. Use API engine locally instead.');
665
797
  }
666
798
  const resolvedModel = normalizedMultiModels[0] ?? (isGemini ? resolveApiModel(cliModelArg) : resolvedModelCandidate);
799
+ const includesGeminiApiOnly = (normalizedMultiModels.length > 0 ? normalizedMultiModels : [resolvedModel]).some((model) => model === 'gemini-3.1-pro');
800
+ if ((userForcedBrowser || userConfig.engine === 'browser') && includesGeminiApiOnly) {
801
+ throw new Error('gemini-3.1-pro is API-only today. Use --engine api or switch to gemini-3-pro for Gemini web.');
802
+ }
803
+ if (engine === 'browser' && includesGeminiApiOnly) {
804
+ console.log(chalk.dim('gemini-3.1-pro is API-only today; switching to API.'));
805
+ engine = 'api';
806
+ }
667
807
  const effectiveModelId = resolvedModel.startsWith('gemini')
668
808
  ? resolveGeminiModelId(resolvedModel)
669
809
  : isKnownModel(resolvedModel)
@@ -672,6 +812,7 @@ async function runRootCommand(options) {
672
812
  const resolvedBaseUrl = normalizeBaseUrl(options.baseUrl ?? (isClaude ? process.env.ANTHROPIC_BASE_URL : process.env.OPENAI_BASE_URL));
673
813
  const { models: _rawModels, ...optionsWithoutModels } = options;
674
814
  const resolvedOptions = { ...optionsWithoutModels, model: resolvedModel };
815
+ resolvedOptions.maxFileSizeBytes = resolveConfiguredMaxFileSizeBytes(userConfig, process.env);
675
816
  if (normalizedMultiModels.length > 0) {
676
817
  resolvedOptions.models = normalizedMultiModels;
677
818
  }
@@ -746,6 +887,21 @@ async function runRootCommand(options) {
746
887
  options.prompt = `${options.prompt.trim()}\n${userConfig.promptSuffix}`;
747
888
  }
748
889
  resolvedOptions.prompt = options.prompt;
890
+ if (options.followup) {
891
+ assertFollowupSupported({
892
+ engine,
893
+ model: resolvedModel,
894
+ baseUrl: resolvedBaseUrl,
895
+ azureEndpoint: resolvedOptions.azure?.endpoint,
896
+ });
897
+ if (normalizedMultiModels.length > 0) {
898
+ throw new Error('--followup cannot be combined with --models.');
899
+ }
900
+ const followup = await resolveFollowupReference(options.followup, options.followupModel);
901
+ resolvedOptions.previousResponseId = followup.responseId;
902
+ resolvedOptions.followupSessionId = followup.sessionId;
903
+ resolvedOptions.followupModel = options.followupModel;
904
+ }
749
905
  const runOptions = buildRunOptions(resolvedOptions, { preview: true, previewMode, baseUrl: resolvedBaseUrl });
750
906
  if (engine === 'browser') {
751
907
  await runBrowserPreview({
@@ -784,6 +940,21 @@ async function runRootCommand(options) {
784
940
  options.prompt = `${options.prompt.trim()}\n${userConfig.promptSuffix}`;
785
941
  }
786
942
  resolvedOptions.prompt = options.prompt;
943
+ if (options.followup) {
944
+ assertFollowupSupported({
945
+ engine,
946
+ model: resolvedModel,
947
+ baseUrl: resolvedBaseUrl,
948
+ azureEndpoint: resolvedOptions.azure?.endpoint,
949
+ });
950
+ if (normalizedMultiModels.length > 0) {
951
+ throw new Error('--followup cannot be combined with --models.');
952
+ }
953
+ const followup = await resolveFollowupReference(options.followup, options.followupModel);
954
+ resolvedOptions.previousResponseId = followup.responseId;
955
+ resolvedOptions.followupSessionId = followup.sessionId;
956
+ resolvedOptions.followupModel = options.followupModel;
957
+ }
787
958
  const duplicateBlocked = await shouldBlockDuplicatePrompt({
788
959
  prompt: resolvedOptions.prompt,
789
960
  force: options.force,
@@ -798,7 +969,10 @@ async function runRootCommand(options) {
798
969
  const isBrowserMode = engine === 'browser' || userForcedBrowser;
799
970
  const filesToValidate = isBrowserMode ? options.file.filter((f) => !isMediaFile(f)) : options.file;
800
971
  if (filesToValidate.length > 0) {
801
- await readFiles(filesToValidate, { cwd: process.cwd() });
972
+ await readFiles(filesToValidate, {
973
+ cwd: process.cwd(),
974
+ maxFileSizeBytes: resolvedOptions.maxFileSizeBytes,
975
+ });
802
976
  }
803
977
  }
804
978
  const getSource = (key) => program.getOptionValueSource?.(key) ?? undefined;
@@ -874,6 +1048,8 @@ async function runRootCommand(options) {
874
1048
  ...baseRunOptions,
875
1049
  mode: sessionMode,
876
1050
  browserConfig,
1051
+ followupSessionId: resolvedOptions.followupSessionId,
1052
+ followupModel: resolvedOptions.followupModel,
877
1053
  waitPreference,
878
1054
  youtube: options.youtube,
879
1055
  generateImage: options.generateImage,
@@ -1022,7 +1198,10 @@ async function restartSession(sessionId, options) {
1022
1198
  const isBrowserMode = engine === 'browser';
1023
1199
  const filesToValidate = isBrowserMode ? runOptions.file.filter((f) => !isMediaFile(f)) : runOptions.file;
1024
1200
  if (filesToValidate.length > 0) {
1025
- await readFiles(filesToValidate, { cwd });
1201
+ await readFiles(filesToValidate, {
1202
+ cwd,
1203
+ maxFileSizeBytes: runOptions.maxFileSizeBytes,
1204
+ });
1026
1205
  }
1027
1206
  }
1028
1207
  enforceBrowserSearchFlag(runOptions, sessionMode, console.log);
@@ -1080,6 +1259,8 @@ async function restartSession(sessionId, options) {
1080
1259
  ...runOptions,
1081
1260
  mode: sessionMode,
1082
1261
  browserConfig,
1262
+ followupSessionId: storedOptions.followupSessionId,
1263
+ followupModel: storedOptions.followupModel,
1083
1264
  waitPreference,
1084
1265
  youtube: storedOptions.youtube,
1085
1266
  generateImage: storedOptions.generateImage,
@@ -71,13 +71,15 @@ function buildModelSelectionExpression(targetModel, strategy) {
71
71
  .map((token) => normalizeText(token))
72
72
  .filter(Boolean);
73
73
  const targetWords = normalizedTarget.split(' ').filter(Boolean);
74
- const desiredVersion = normalizedTarget.includes('5 2')
75
- ? '5-2'
76
- : normalizedTarget.includes('5 1')
77
- ? '5-1'
78
- : normalizedTarget.includes('5 0')
79
- ? '5-0'
80
- : null;
74
+ const desiredVersion = normalizedTarget.includes('5 4')
75
+ ? '5-4'
76
+ : normalizedTarget.includes('5 2')
77
+ ? '5-2'
78
+ : normalizedTarget.includes('5 1')
79
+ ? '5-1'
80
+ : normalizedTarget.includes('5 0')
81
+ ? '5-0'
82
+ : null;
81
83
  const wantsPro = normalizedTarget.includes(' pro') || normalizedTarget.endsWith(' pro') || normalizedTokens.includes('pro');
82
84
  const wantsInstant = normalizedTarget.includes('instant');
83
85
  const wantsThinking = normalizedTarget.includes('thinking');
@@ -87,6 +89,26 @@ function buildModelSelectionExpression(targetModel, strategy) {
87
89
  return { status: 'button-missing' };
88
90
  }
89
91
 
92
+ const closeMenu = () => {
93
+ try {
94
+ if (dispatchClickSequence(button)) {
95
+ lastPointerClick = performance.now();
96
+ return;
97
+ }
98
+ } catch {}
99
+ try {
100
+ document.dispatchEvent(
101
+ new KeyboardEvent('keydown', {
102
+ key: 'Escape',
103
+ code: 'Escape',
104
+ keyCode: 27,
105
+ which: 27,
106
+ bubbles: true,
107
+ }),
108
+ );
109
+ } catch {}
110
+ };
111
+
90
112
  const getButtonLabel = () => (button.textContent ?? '').trim();
91
113
  if (MODEL_STRATEGY === 'current') {
92
114
  return { status: 'already-selected', label: getButtonLabel() };
@@ -95,6 +117,7 @@ function buildModelSelectionExpression(targetModel, strategy) {
95
117
  const normalizedLabel = normalizeText(getButtonLabel());
96
118
  if (!normalizedLabel) return false;
97
119
  if (desiredVersion) {
120
+ if (desiredVersion === '5-4' && !normalizedLabel.includes('5 4')) return false;
98
121
  if (desiredVersion === '5-2' && !normalizedLabel.includes('5 2')) return false;
99
122
  if (desiredVersion === '5-1' && !normalizedLabel.includes('5 1')) return false;
100
123
  if (desiredVersion === '5-0' && !normalizedLabel.includes('5 0')) return false;
@@ -159,6 +182,12 @@ function buildModelSelectionExpression(targetModel, strategy) {
159
182
  normalizedTestId.includes('gpt-5-2') ||
160
183
  normalizedTestId.includes('gpt-5.2') ||
161
184
  normalizedTestId.includes('gpt52');
185
+ const has54 =
186
+ normalizedTestId.includes('5-4') ||
187
+ normalizedTestId.includes('5.4') ||
188
+ normalizedTestId.includes('gpt-5-4') ||
189
+ normalizedTestId.includes('gpt-5.4') ||
190
+ normalizedTestId.includes('gpt54');
162
191
  const has51 =
163
192
  normalizedTestId.includes('5-1') ||
164
193
  normalizedTestId.includes('5.1') ||
@@ -171,7 +200,7 @@ function buildModelSelectionExpression(targetModel, strategy) {
171
200
  normalizedTestId.includes('gpt-5-0') ||
172
201
  normalizedTestId.includes('gpt-5.0') ||
173
202
  normalizedTestId.includes('gpt50');
174
- const candidateVersion = has52 ? '5-2' : has51 ? '5-1' : has50 ? '5-0' : null;
203
+ const candidateVersion = has54 ? '5-4' : has52 ? '5-2' : has51 ? '5-1' : has50 ? '5-0' : null;
175
204
  // If a candidate advertises a different version, ignore it entirely.
176
205
  if (candidateVersion && candidateVersion !== desiredVersion) {
177
206
  return 0;
@@ -317,6 +346,7 @@ function buildModelSelectionExpression(targetModel, strategy) {
317
346
  const match = findBestOption();
318
347
  if (match) {
319
348
  if (optionIsSelected(match.node)) {
349
+ closeMenu();
320
350
  resolve({ status: 'already-selected', label: getButtonLabel() || match.label });
321
351
  return;
322
352
  }
@@ -331,6 +361,7 @@ function buildModelSelectionExpression(targetModel, strategy) {
331
361
  // Wait for the top bar label to reflect the requested model; otherwise keep scanning.
332
362
  setTimeout(() => {
333
363
  if (buttonMatchesTarget()) {
364
+ closeMenu();
334
365
  resolve({ status: 'switched', label: getButtonLabel() || match.label });
335
366
  return;
336
367
  }
@@ -374,6 +405,22 @@ function buildModelMatchersLiteral(targetModel) {
374
405
  push(`chatgpt ${dotless}`, labelTokens);
375
406
  push(`gpt ${base}`, labelTokens);
376
407
  push(`gpt ${dotless}`, labelTokens);
408
+ // Numeric variations (5.4 ↔ 54 ↔ gpt-5-4)
409
+ if (base.includes('5.4') || base.includes('5-4') || base.includes('54')) {
410
+ push('5.4', labelTokens);
411
+ push('gpt-5.4', labelTokens);
412
+ push('gpt5.4', labelTokens);
413
+ push('gpt-5-4', labelTokens);
414
+ push('gpt5-4', labelTokens);
415
+ push('gpt54', labelTokens);
416
+ push('chatgpt 5.4', labelTokens);
417
+ if (!base.includes('pro')) {
418
+ testIdTokens.add('model-switcher-gpt-5-4');
419
+ }
420
+ testIdTokens.add('gpt-5-4');
421
+ testIdTokens.add('gpt5-4');
422
+ testIdTokens.add('gpt54');
423
+ }
377
424
  // Numeric variations (5.1 ↔ 51 ↔ gpt-5-1)
378
425
  if (base.includes('5.1') || base.includes('5-1') || base.includes('51')) {
379
426
  push('5.1', labelTokens);
@@ -436,6 +483,11 @@ function buildModelMatchersLiteral(targetModel) {
436
483
  push('proresearch', labelTokens);
437
484
  push('research grade', labelTokens);
438
485
  push('advanced reasoning', labelTokens);
486
+ if (base.includes('5.4') || base.includes('5-4') || base.includes('54')) {
487
+ testIdTokens.add('gpt-5.4-pro');
488
+ testIdTokens.add('gpt-5-4-pro');
489
+ testIdTokens.add('gpt54pro');
490
+ }
439
491
  if (base.includes('5.1') || base.includes('5-1') || base.includes('51')) {
440
492
  testIdTokens.add('gpt-5.1-pro');
441
493
  testIdTokens.add('gpt-5-1-pro');
@@ -1,6 +1,7 @@
1
1
  import { CLOUDFLARE_SCRIPT_SELECTOR, CLOUDFLARE_TITLE, INPUT_SELECTORS, } from '../constants.js';
2
2
  import { delay } from '../utils.js';
3
3
  import { logDomFailure } from '../domDebug.js';
4
+ import { BrowserAutomationError } from '../../oracle/errors.js';
4
5
  export function installJavaScriptDialogAutoDismissal(Page, logger) {
5
6
  const pageAny = Page;
6
7
  if (typeof pageAny.on !== 'function' || typeof pageAny.handleJavaScriptDialog !== 'function') {
@@ -127,7 +128,7 @@ export async function ensureNotBlocked(Runtime, headless, logger) {
127
128
  ? 'Cloudflare challenge detected in headless mode. Re-run with --headful so you can solve the challenge.'
128
129
  : 'Cloudflare challenge detected. Complete the “Just a moment…” check in the open browser, then rerun.';
129
130
  logger('Cloudflare anti-bot page detected');
130
- throw new Error(message);
131
+ throw new BrowserAutomationError(message, { stage: 'cloudflare-challenge', headless });
131
132
  }
132
133
  }
133
134
  const LOGIN_CHECK_TIMEOUT_MS = 5_000;
@@ -1,5 +1,5 @@
1
1
  export const CHATGPT_URL = 'https://chatgpt.com/';
2
- export const DEFAULT_MODEL_TARGET = 'GPT-5.2 Pro';
2
+ export const DEFAULT_MODEL_TARGET = 'GPT-5.4 Pro';
3
3
  export const DEFAULT_MODEL_STRATEGY = 'select';
4
4
  export const COOKIE_URLS = ['https://chatgpt.com', 'https://chat.openai.com', 'https://atlas.openai.com'];
5
5
  export const INPUT_SELECTORS = [