@steipete/oracle 0.8.3 → 0.8.5

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 (37) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +7 -0
  3. package/dist/bin/oracle-cli.js +102 -9
  4. package/dist/scripts/debug/extract-chatgpt-response.js +53 -0
  5. package/dist/src/bridge/connection.js +103 -0
  6. package/dist/src/bridge/userConfigFile.js +28 -0
  7. package/dist/src/browser/actions/assistantResponse.js +13 -5
  8. package/dist/src/browser/actions/attachments.js +44 -20
  9. package/dist/src/browser/chromeLifecycle.js +62 -9
  10. package/dist/src/browser/detect.js +164 -0
  11. package/dist/src/browser/index.js +55 -2
  12. package/dist/src/cli/bridge/claudeConfig.js +54 -0
  13. package/dist/src/cli/bridge/client.js +73 -0
  14. package/dist/src/cli/bridge/codexConfig.js +43 -0
  15. package/dist/src/cli/bridge/doctor.js +107 -0
  16. package/dist/src/cli/bridge/host.js +259 -0
  17. package/dist/src/cli/engine.js +17 -1
  18. package/dist/src/cli/options.js +14 -0
  19. package/dist/src/cli/runOptions.js +4 -0
  20. package/dist/src/mcp/tools/consult.js +80 -15
  21. package/dist/src/mcp/tools/sessions.js +15 -6
  22. package/dist/src/mcp/types.js +4 -0
  23. package/dist/src/mcp/utils.js +12 -2
  24. package/dist/src/oracle/background.js +1 -2
  25. package/dist/src/oracle/client.js +5 -2
  26. package/dist/src/oracle/files.js +2 -2
  27. package/dist/src/oracle/run.js +1 -0
  28. package/dist/src/remote/client.js +6 -5
  29. package/dist/src/remote/health.js +113 -0
  30. package/dist/src/remote/remoteServiceConfig.js +31 -0
  31. package/dist/src/remote/server.js +28 -1
  32. package/dist/src/sessionManager.js +63 -5
  33. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  34. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  35. package/package.json +16 -15
  36. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  37. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
@@ -0,0 +1,259 @@
1
+ import { spawn } from 'node:child_process';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { randomBytes } from 'node:crypto';
5
+ import chalk from 'chalk';
6
+ import { getOracleHomeDir } from '../../oracleHome.js';
7
+ import { parseHostPort, normalizeHostPort, formatBridgeConnectionString } from '../../bridge/connection.js';
8
+ import { serveRemote } from '../../remote/server.js';
9
+ export async function runBridgeHost(options) {
10
+ const bindRaw = options.bind?.trim() || '127.0.0.1:9473';
11
+ const { hostname: bindHost, port: bindPort } = parseHostPort(bindRaw);
12
+ const tokenRaw = options.token?.trim() || 'auto';
13
+ const token = tokenRaw === 'auto' ? randomBytes(16).toString('hex') : tokenRaw;
14
+ if (!token.trim()) {
15
+ throw new Error('Token is required (use --token auto to generate one).');
16
+ }
17
+ const writeConnectionPath = options.writeConnection?.trim() || path.join(getOracleHomeDir(), 'bridge-connection.json');
18
+ const sshTarget = options.ssh?.trim();
19
+ const sshRemotePort = typeof options.sshRemotePort === 'number' ? options.sshRemotePort : bindPort;
20
+ if (sshRemotePort <= 0 || sshRemotePort > 65_535) {
21
+ throw new Error(`Invalid --ssh-remote-port: ${sshRemotePort}. Expected 1-65535.`);
22
+ }
23
+ const connectionHostForClient = sshTarget ? normalizeHostPort('127.0.0.1', sshRemotePort) : normalizeHostPort(bindHost === '0.0.0.0' || bindHost === '::' ? '127.0.0.1' : bindHost, bindPort);
24
+ const artifact = await upsertConnectionArtifact(writeConnectionPath, {
25
+ remoteHost: connectionHostForClient,
26
+ remoteToken: token,
27
+ tunnel: sshTarget
28
+ ? {
29
+ ssh: sshTarget,
30
+ remotePort: sshRemotePort,
31
+ localPort: bindPort,
32
+ identity: options.sshIdentity?.trim() || undefined,
33
+ extraArgs: options.sshExtraArgs?.trim() || undefined,
34
+ }
35
+ : undefined,
36
+ });
37
+ if (options.printToken) {
38
+ console.log(token);
39
+ }
40
+ if (options.print) {
41
+ console.log(formatBridgeConnectionString({ remoteHost: artifact.remoteHost, remoteToken: token }, { includeToken: true }));
42
+ }
43
+ if (options.background) {
44
+ await spawnBridgeHostInBackground({
45
+ bind: bindRaw,
46
+ token,
47
+ writeConnectionPath,
48
+ sshTarget,
49
+ sshRemotePort,
50
+ sshIdentity: options.sshIdentity?.trim(),
51
+ sshExtraArgs: options.sshExtraArgs?.trim(),
52
+ });
53
+ return;
54
+ }
55
+ console.log(chalk.cyanBright('Bridge host starting...'));
56
+ console.log(chalk.dim(`- Local bind: ${normalizeHostPort(bindHost, bindPort)}`));
57
+ console.log(chalk.dim(`- Connection artifact: ${writeConnectionPath}`));
58
+ console.log(chalk.dim(`- Client remoteHost: ${artifact.remoteHost}`));
59
+ console.log(chalk.dim('Token stored in connection artifact (not printed). Use --print or --print-token if needed.'));
60
+ let tunnel = null;
61
+ if (sshTarget) {
62
+ tunnel = startReverseTunnel({
63
+ sshTarget,
64
+ remotePort: sshRemotePort,
65
+ localPort: bindPort,
66
+ identity: options.sshIdentity?.trim() || undefined,
67
+ extraArgs: options.sshExtraArgs?.trim() || undefined,
68
+ log: (msg) => console.log(chalk.dim(msg)),
69
+ });
70
+ console.log(chalk.dim(`Reverse SSH tunnel active (remote 127.0.0.1:${sshRemotePort} -> local 127.0.0.1:${bindPort})`));
71
+ }
72
+ const filteredServeLogger = (message) => {
73
+ if (message.includes('Access token:')) {
74
+ return;
75
+ }
76
+ console.log(message);
77
+ };
78
+ try {
79
+ await serveRemote({
80
+ host: bindHost,
81
+ port: bindPort,
82
+ token,
83
+ logger: filteredServeLogger,
84
+ });
85
+ }
86
+ finally {
87
+ tunnel?.stop();
88
+ }
89
+ }
90
+ async function upsertConnectionArtifact(filePath, input) {
91
+ const dir = path.dirname(filePath);
92
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 });
93
+ const now = new Date().toISOString();
94
+ const existing = await fs.readFile(filePath, 'utf8').catch(() => null);
95
+ let createdAt = now;
96
+ if (existing) {
97
+ try {
98
+ const parsed = JSON.parse(existing);
99
+ if (typeof parsed.createdAt === 'string' && parsed.createdAt.trim().length > 0) {
100
+ createdAt = parsed.createdAt;
101
+ }
102
+ }
103
+ catch {
104
+ // ignore invalid previous content
105
+ }
106
+ }
107
+ const artifact = {
108
+ remoteHost: input.remoteHost,
109
+ remoteToken: input.remoteToken,
110
+ createdAt,
111
+ updatedAt: now,
112
+ tunnel: input.tunnel,
113
+ };
114
+ const contents = `${JSON.stringify(artifact, null, 2)}\n`;
115
+ const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
116
+ await fs.writeFile(tempPath, contents, { encoding: 'utf8', mode: 0o600 });
117
+ await fs.rename(tempPath, filePath);
118
+ if (process.platform !== 'win32') {
119
+ await fs.chmod(filePath, 0o600).catch(() => undefined);
120
+ }
121
+ return artifact;
122
+ }
123
+ function startReverseTunnel({ sshTarget, remotePort, localPort, identity, extraArgs, log, }) {
124
+ let stopped = false;
125
+ let child = null;
126
+ let attempt = 0;
127
+ let timer = null;
128
+ const spawnOnce = () => {
129
+ if (stopped)
130
+ return;
131
+ const args = [
132
+ '-N',
133
+ '-R',
134
+ `${remotePort}:127.0.0.1:${localPort}`,
135
+ '-o',
136
+ 'ExitOnForwardFailure=yes',
137
+ '-o',
138
+ 'ServerAliveInterval=30',
139
+ '-o',
140
+ 'ServerAliveCountMax=3',
141
+ ];
142
+ if (identity) {
143
+ args.push('-i', identity);
144
+ }
145
+ if (extraArgs) {
146
+ args.push(...splitArgs(extraArgs));
147
+ }
148
+ args.push(sshTarget);
149
+ child = spawn('ssh', args, { stdio: 'ignore' });
150
+ const pid = child.pid;
151
+ log(`[bridge host] ssh tunnel started${pid ? ` (pid ${pid})` : ''}: ${sshTarget}`);
152
+ child.once('exit', (code, signal) => {
153
+ child = null;
154
+ if (stopped)
155
+ return;
156
+ const label = signal ? `signal ${signal}` : `code ${code ?? 0}`;
157
+ const delayMs = Math.min(30_000, 1_000 * 2 ** attempt);
158
+ attempt += 1;
159
+ log(`[bridge host] ssh tunnel exited (${label}); restarting in ${delayMs}ms`);
160
+ timer = setTimeout(spawnOnce, delayMs);
161
+ timer.unref?.();
162
+ });
163
+ };
164
+ spawnOnce();
165
+ return {
166
+ stop: () => {
167
+ stopped = true;
168
+ if (timer) {
169
+ clearTimeout(timer);
170
+ timer = null;
171
+ }
172
+ if (child) {
173
+ child.removeAllListeners();
174
+ child.kill();
175
+ child = null;
176
+ }
177
+ },
178
+ };
179
+ }
180
+ function splitArgs(input) {
181
+ const args = [];
182
+ let current = '';
183
+ let quote = null;
184
+ const push = () => {
185
+ const trimmed = current.trim();
186
+ if (trimmed.length)
187
+ args.push(trimmed);
188
+ current = '';
189
+ };
190
+ for (let i = 0; i < input.length; i += 1) {
191
+ const ch = input[i] ?? '';
192
+ if (quote) {
193
+ if (ch === quote) {
194
+ quote = null;
195
+ }
196
+ else {
197
+ current += ch;
198
+ }
199
+ continue;
200
+ }
201
+ if (ch === '"' || ch === "'") {
202
+ quote = ch;
203
+ continue;
204
+ }
205
+ if (/\s/.test(ch)) {
206
+ push();
207
+ continue;
208
+ }
209
+ current += ch;
210
+ }
211
+ push();
212
+ return args;
213
+ }
214
+ async function spawnBridgeHostInBackground({ bind, token, writeConnectionPath, sshTarget, sshRemotePort, sshIdentity, sshExtraArgs, }) {
215
+ const oracleHome = getOracleHomeDir();
216
+ await fs.mkdir(oracleHome, { recursive: true, mode: 0o700 });
217
+ const logPath = path.join(oracleHome, 'bridge-host.log');
218
+ const pidPath = path.join(oracleHome, 'bridge-host.pid');
219
+ const logHandle = await fs.open(logPath, 'a');
220
+ const stdio = ['ignore', logHandle.fd, logHandle.fd];
221
+ const scriptPath = process.argv[1];
222
+ if (!scriptPath) {
223
+ throw new Error('Unable to determine CLI entrypoint for background mode.');
224
+ }
225
+ const args = [
226
+ scriptPath,
227
+ 'bridge',
228
+ 'host',
229
+ '--foreground',
230
+ '--bind',
231
+ bind,
232
+ '--token',
233
+ token,
234
+ '--write-connection',
235
+ writeConnectionPath,
236
+ ];
237
+ if (sshTarget) {
238
+ args.push('--ssh', sshTarget);
239
+ }
240
+ if (typeof sshRemotePort === 'number') {
241
+ args.push('--ssh-remote-port', String(sshRemotePort));
242
+ }
243
+ if (sshIdentity) {
244
+ args.push('--ssh-identity', sshIdentity);
245
+ }
246
+ if (sshExtraArgs) {
247
+ args.push('--ssh-extra-args', sshExtraArgs);
248
+ }
249
+ const child = spawn(process.execPath, args, { detached: true, stdio });
250
+ child.unref();
251
+ await fs.writeFile(pidPath, `${child.pid ?? ''}\n`, { encoding: 'utf8', mode: 0o600 });
252
+ if (process.platform !== 'win32') {
253
+ await fs.chmod(pidPath, 0o600).catch(() => undefined);
254
+ }
255
+ await logHandle.close();
256
+ console.log(chalk.green(`Bridge host running in background (pid ${child.pid ?? '?'})`));
257
+ console.log(chalk.dim(`- Log: ${logPath}`));
258
+ console.log(chalk.dim(`- PID: ${pidPath}`));
259
+ }
@@ -12,7 +12,8 @@ export function defaultWaitPreference(model, engine) {
12
12
  * Precedence:
13
13
  * 1) Legacy --browser flag forces browser.
14
14
  * 2) Explicit --engine value.
15
- * 3) OPENAI_API_KEY decides: api when set, otherwise browser.
15
+ * 3) ORACLE_ENGINE environment override (api|browser).
16
+ * 4) OPENAI_API_KEY decides: api when set, otherwise browser.
16
17
  */
17
18
  export function resolveEngine({ engine, browserFlag, env, }) {
18
19
  if (browserFlag) {
@@ -21,5 +22,20 @@ export function resolveEngine({ engine, browserFlag, env, }) {
21
22
  if (engine) {
22
23
  return engine;
23
24
  }
25
+ const envEngine = normalizeEngineMode(env.ORACLE_ENGINE);
26
+ if (envEngine) {
27
+ return envEngine;
28
+ }
24
29
  return env.OPENAI_API_KEY ? 'api' : 'browser';
25
30
  }
31
+ function normalizeEngineMode(raw) {
32
+ if (typeof raw !== 'string') {
33
+ return null;
34
+ }
35
+ const normalized = raw.trim().toLowerCase();
36
+ if (normalized === 'api')
37
+ return 'api';
38
+ if (normalized === 'browser')
39
+ return 'browser';
40
+ return null;
41
+ }
@@ -1,4 +1,5 @@
1
1
  import { InvalidArgumentError } from 'commander';
2
+ import { parseDuration } from '../browserMode.js';
2
3
  import path from 'node:path';
3
4
  import fg from 'fast-glob';
4
5
  import { DEFAULT_MODEL, MODEL_CONFIGS } from '../oracle.js';
@@ -138,6 +139,19 @@ export function parseTimeoutOption(value) {
138
139
  }
139
140
  return parsed;
140
141
  }
142
+ export function parseDurationOption(value, label) {
143
+ if (value == null)
144
+ return undefined;
145
+ const trimmed = value.trim();
146
+ if (!trimmed) {
147
+ throw new InvalidArgumentError(`${label} must be a duration like 30m, 10s, 500ms, or 2h.`);
148
+ }
149
+ const parsed = parseDuration(trimmed, Number.NaN);
150
+ if (!Number.isFinite(parsed) || parsed <= 0) {
151
+ throw new InvalidArgumentError(`${label} must be a positive duration like 30m, 10s, 500ms, or 2h.`);
152
+ }
153
+ return parsed;
154
+ }
141
155
  export function resolveApiModel(modelValue) {
142
156
  const normalized = normalizeModelOption(modelValue).toLowerCase();
143
157
  if (normalized in MODEL_CONFIGS) {
@@ -61,6 +61,10 @@ export function resolveRunOptionsFromConfig({ prompt, files = [], model, models,
61
61
  function resolveEngineWithConfig({ engine, configEngine, env, }) {
62
62
  if (engine)
63
63
  return engine;
64
+ const envOverride = (env.ORACLE_ENGINE ?? '').trim().toLowerCase();
65
+ if (envOverride === 'api' || envOverride === 'browser') {
66
+ return envOverride;
67
+ }
64
68
  if (configEngine)
65
69
  return configEngine;
66
70
  return resolveEngine({ engine: undefined, env });
@@ -3,6 +3,8 @@ import { getCliVersion } from '../../version.js';
3
3
  import { LoggingMessageNotificationParamsSchema } from '@modelcontextprotocol/sdk/types.js';
4
4
  import { ensureBrowserAvailable, mapConsultToRunOptions } from '../utils.js';
5
5
  import { sessionStore } from '../../sessionStore.js';
6
+ import { resolveRemoteServiceConfig } from '../../remote/remoteServiceConfig.js';
7
+ import { createRemoteBrowserExecutor } from '../../remote/client.js';
6
8
  async function readSessionLogTail(sessionId, maxBytes) {
7
9
  try {
8
10
  const log = await sessionStore.readLog(sessionId);
@@ -23,14 +25,54 @@ import { resolveNotificationSettings } from '../../cli/notifier.js';
23
25
  import { mapModelToBrowserLabel, resolveBrowserModelLabel } from '../../cli/browserConfig.js';
24
26
  // Use raw shapes so the MCP SDK (with its bundled Zod) wraps them and emits valid JSON Schema.
25
27
  const consultInputShape = {
26
- prompt: z.string().min(1, 'Prompt is required.'),
27
- files: z.array(z.string()).default([]),
28
- model: z.string().optional(),
29
- models: z.array(z.string()).optional(),
30
- engine: z.enum(['api', 'browser']).optional(),
31
- browserModelLabel: z.string().optional(),
32
- search: z.boolean().optional(),
33
- slug: z.string().optional(),
28
+ prompt: z
29
+ .string()
30
+ .min(1, 'Prompt is required.')
31
+ .describe('User prompt to run.'),
32
+ files: z
33
+ .array(z.string())
34
+ .default([])
35
+ .describe('Optional file paths or glob patterns (like the CLI `--file`). Resolved relative to the MCP server working directory.'),
36
+ model: z
37
+ .string()
38
+ .optional()
39
+ .describe('Single model name/label. Prefer setting `engine` explicitly to avoid default surprises.'),
40
+ models: z
41
+ .array(z.string())
42
+ .optional()
43
+ .describe('Multi-model fan-out (API engine only). Cannot be combined with browser automation.'),
44
+ engine: z
45
+ .enum(['api', 'browser'])
46
+ .optional()
47
+ .describe('Execution engine. `api` uses OpenAI/other providers. `browser` automates the ChatGPT web UI (supports attachments and ChatGPT-only model labels).'),
48
+ browserModelLabel: z
49
+ .string()
50
+ .optional()
51
+ .describe('Browser-only: explicit ChatGPT UI label to select (overrides model mapping). Example: "GPT-5.2 Thinking".'),
52
+ browserAttachments: z
53
+ .enum(['auto', 'never', 'always'])
54
+ .optional()
55
+ .describe('Browser-only: how to deliver `files`. Use "always" for real ChatGPT file uploads (including images/PDFs). Use "never" to paste file contents inline. "auto" chooses based on prompt size.'),
56
+ browserBundleFiles: z
57
+ .boolean()
58
+ .optional()
59
+ .describe('Browser-only: bundle many files into a single upload (helps with upload limits).'),
60
+ browserThinkingTime: z
61
+ .enum(['light', 'standard', 'extended', 'heavy'])
62
+ .optional()
63
+ .describe('Browser-only: set ChatGPT thinking time when supported by the chosen model.'),
64
+ browserKeepBrowser: z
65
+ .boolean()
66
+ .optional()
67
+ .describe('Browser-only: keep Chrome running after completion (useful for debugging).'),
68
+ search: z
69
+ .boolean()
70
+ .optional()
71
+ .describe('API-only: enable/disable the provider search tool (browser engine ignores this).'),
72
+ slug: z
73
+ .string()
74
+ .optional()
75
+ .describe('Optional human-friendly session id (used for later `oracle sessions` lookups).'),
34
76
  };
35
77
  const consultModelSummaryShape = z.object({
36
78
  model: z.string(),
@@ -100,13 +142,13 @@ export function summarizeModelRunsForConsult(runs) {
100
142
  export function registerConsultTool(server) {
101
143
  server.registerTool('consult', {
102
144
  title: 'Run an oracle session',
103
- description: 'Run a one-shot Oracle session (API or browser). Attach files/dirs for context, optional model/engine overrides, and an optional slug. Background handling follows the CLI defaults; browser runs only start when Chrome is available.',
145
+ description: 'Run a one-shot Oracle session (API or ChatGPT browser automation). Use `files` to attach project context. For browser-based image/file uploads, set `browserAttachments:"always"`. Sessions are stored under `ORACLE_HOME_DIR` (shared with the CLI).',
104
146
  // Cast to any to satisfy SDK typings across differing Zod versions.
105
147
  inputSchema: consultInputShape,
106
148
  outputSchema: consultOutputShape,
107
149
  }, async (input) => {
108
150
  const textContent = (text) => [{ type: 'text', text }];
109
- const { prompt, files, model, models, engine, search, browserModelLabel, slug } = consultInputSchema.parse(input);
151
+ const { prompt, files, model, models, engine, search, browserModelLabel, browserAttachments, browserBundleFiles, browserThinkingTime, browserKeepBrowser, slug, } = consultInputSchema.parse(input);
110
152
  const { config: userConfig } = await loadUserConfig();
111
153
  const { runOptions, resolvedEngine } = mapConsultToRunOptions({
112
154
  prompt,
@@ -115,31 +157,53 @@ export function registerConsultTool(server) {
115
157
  models,
116
158
  engine,
117
159
  search,
160
+ browserAttachments,
161
+ browserBundleFiles,
118
162
  userConfig,
119
163
  env: process.env,
120
164
  });
121
165
  const cwd = process.cwd();
122
- const browserGuard = ensureBrowserAvailable(resolvedEngine);
166
+ const resolvedRemote = resolveRemoteServiceConfig({ userConfig, env: process.env });
167
+ const browserGuard = ensureBrowserAvailable(resolvedEngine, { remoteHost: resolvedRemote.host });
123
168
  if (resolvedEngine === 'browser' && browserGuard) {
124
169
  return {
125
170
  isError: true,
126
171
  content: textContent(browserGuard),
127
172
  };
128
173
  }
174
+ let browserDeps;
175
+ if (resolvedEngine === 'browser' && resolvedRemote.host) {
176
+ if (!resolvedRemote.token) {
177
+ return {
178
+ isError: true,
179
+ content: textContent(`Remote host configured (${resolvedRemote.host}) but remote token is missing. Run \`oracle bridge client --connect <...>\` or set ORACLE_REMOTE_TOKEN.`),
180
+ };
181
+ }
182
+ browserDeps = {
183
+ executeBrowser: createRemoteBrowserExecutor({ host: resolvedRemote.host, token: resolvedRemote.token }),
184
+ };
185
+ }
129
186
  let browserConfig;
130
187
  if (resolvedEngine === 'browser') {
188
+ const envProfileDir = (process.env.ORACLE_BROWSER_PROFILE_DIR ?? '').trim();
189
+ const hasProfileDir = envProfileDir.length > 0;
131
190
  const preferredLabel = (browserModelLabel ?? model)?.trim();
132
191
  const isChatGptModel = runOptions.model.startsWith('gpt-') && !runOptions.model.includes('codex');
133
192
  const desiredModelLabel = isChatGptModel
134
193
  ? mapModelToBrowserLabel(runOptions.model)
135
194
  : resolveBrowserModelLabel(preferredLabel, runOptions.model);
136
- // Keep the browser path minimal; only forward a desired model label for the ChatGPT picker.
195
+ const configuredUrl = userConfig.browser?.chatgptUrl ?? userConfig.browser?.url ?? undefined;
196
+ // Default to manual-login when a persistent profile dir is provided (common for Codex/Claude).
197
+ const manualLogin = hasProfileDir;
137
198
  browserConfig = {
138
- url: CHATGPT_URL,
139
- cookieSync: true,
199
+ url: configuredUrl ?? CHATGPT_URL,
200
+ cookieSync: !manualLogin,
140
201
  headless: false,
141
202
  hideWindow: false,
142
- keepBrowser: false,
203
+ keepBrowser: browserKeepBrowser ?? false,
204
+ manualLogin,
205
+ manualLoginProfileDir: manualLogin ? envProfileDir : null,
206
+ thinkingTime: browserThinkingTime,
143
207
  desiredModel: desiredModelLabel || mapModelToBrowserLabel(runOptions.model),
144
208
  };
145
209
  }
@@ -187,6 +251,7 @@ export function registerConsultTool(server) {
187
251
  version: getCliVersion(),
188
252
  notifications,
189
253
  muteStdout: true,
254
+ browserDeps,
190
255
  });
191
256
  }
192
257
  catch (error) {
@@ -2,11 +2,20 @@ import { z } from 'zod';
2
2
  import { sessionStore } from '../../sessionStore.js';
3
3
  import { sessionsInputSchema } from '../types.js';
4
4
  const sessionsInputShape = {
5
- id: z.string().optional(),
6
- hours: z.number().optional(),
7
- limit: z.number().optional(),
8
- includeAll: z.boolean().optional(),
9
- detail: z.boolean().optional(),
5
+ id: z
6
+ .string()
7
+ .optional()
8
+ .describe('Session id or slug. If set, returns a single session (use detail:true to include metadata/request).'),
9
+ hours: z.number().optional().describe('Look back this many hours when listing sessions (default: 24).'),
10
+ limit: z.number().optional().describe('Maximum sessions to return when listing (default: 100).'),
11
+ includeAll: z
12
+ .boolean()
13
+ .optional()
14
+ .describe('Include sessions outside the time window when listing (mirrors `oracle status --all`).'),
15
+ detail: z
16
+ .boolean()
17
+ .optional()
18
+ .describe('When id is set, include session metadata + stored request + full log text.'),
10
19
  };
11
20
  const sessionsOutputShape = {
12
21
  entries: z
@@ -31,7 +40,7 @@ const sessionsOutputShape = {
31
40
  export function registerSessionsTool(server) {
32
41
  server.registerTool('sessions', {
33
42
  title: 'List or fetch oracle sessions',
34
- description: 'List stored sessions (same defaults as `oracle status`) or, with id/slug, return a summary row. Pass detail:true to include metadata, log, and stored request for that session.',
43
+ description: 'Inspect Oracle session history stored under `ORACLE_HOME_DIR` (shared with the CLI). List recent sessions or fetch one by id/slug (optionally including metadata + request + log).',
35
44
  inputSchema: sessionsInputShape,
36
45
  outputSchema: sessionsOutputShape,
37
46
  }, async (input) => {
@@ -6,6 +6,10 @@ export const consultInputSchema = z.object({
6
6
  models: z.array(z.string()).optional(),
7
7
  engine: z.enum(['api', 'browser']).optional(),
8
8
  browserModelLabel: z.string().optional(),
9
+ browserAttachments: z.enum(['auto', 'never', 'always']).optional(),
10
+ browserBundleFiles: z.boolean().optional(),
11
+ browserThinkingTime: z.enum(['light', 'standard', 'extended', 'heavy']).optional(),
12
+ browserKeepBrowser: z.boolean().optional(),
9
13
  search: z.boolean().optional(),
10
14
  slug: z.string().optional(),
11
15
  });
@@ -1,6 +1,6 @@
1
1
  import { resolveRunOptionsFromConfig } from '../cli/runOptions.js';
2
2
  import { Launcher } from 'chrome-launcher';
3
- export function mapConsultToRunOptions({ prompt, files, model, models, engine, search, userConfig, env = process.env, }) {
3
+ export function mapConsultToRunOptions({ prompt, files, model, models, engine, search, browserAttachments, browserBundleFiles, userConfig, env = process.env, }) {
4
4
  // Normalize CLI-style inputs through the shared resolver so config/env defaults apply,
5
5
  // then overlay MCP-only overrides such as explicit search toggles.
6
6
  const mergedModels = Array.isArray(models) && models.length > 0
@@ -10,12 +10,22 @@ export function mapConsultToRunOptions({ prompt, files, model, models, engine, s
10
10
  if (typeof search === 'boolean') {
11
11
  result.runOptions.search = search;
12
12
  }
13
+ if (browserAttachments) {
14
+ result.runOptions.browserAttachments = browserAttachments;
15
+ }
16
+ if (typeof browserBundleFiles === 'boolean') {
17
+ result.runOptions.browserBundleFiles = browserBundleFiles;
18
+ }
13
19
  return result;
14
20
  }
15
- export function ensureBrowserAvailable(engine) {
21
+ export function ensureBrowserAvailable(engine, options) {
16
22
  if (engine !== 'browser') {
17
23
  return null;
18
24
  }
25
+ const remoteHost = options?.remoteHost?.trim() || process.env.ORACLE_REMOTE_HOST?.trim();
26
+ if (remoteHost) {
27
+ return null;
28
+ }
19
29
  if (process.env.CHROME_PATH) {
20
30
  return null;
21
31
  }
@@ -3,7 +3,6 @@ import chalk from 'chalk';
3
3
  import { formatElapsed } from './format.js';
4
4
  import { startHeartbeat } from '../heartbeat.js';
5
5
  import { OracleResponseError, OracleTransportError, describeTransportError, toTransportError, } from './errors.js';
6
- const BACKGROUND_MAX_WAIT_MS = 30 * 60 * 1000;
7
6
  const BACKGROUND_POLL_INTERVAL_MS = 5000;
8
7
  const BACKGROUND_RETRY_BASE_MS = 3000;
9
8
  const BACKGROUND_RETRY_MAX_MS = 15000;
@@ -22,7 +21,7 @@ export async function executeBackgroundResponse(params) {
22
21
  throw new OracleResponseError('API did not return a response ID for the background run.', initialResponse);
23
22
  }
24
23
  const responseId = initialResponse.id;
25
- log(chalk.dim(`API scheduled background response ${responseId} (status=${initialResponse.status ?? 'unknown'}). Monitoring up to ${Math.round(BACKGROUND_MAX_WAIT_MS / 60000)} minutes for completion...`));
24
+ log(chalk.dim(`API scheduled background response ${responseId} (status=${initialResponse.status ?? 'unknown'}). Monitoring up to ${Math.round(maxWaitMs / 60000)} minutes for completion...`));
26
25
  let heartbeatActive = false;
27
26
  let stopHeartbeat = null;
28
27
  const stopHeartbeatNow = () => {
@@ -19,19 +19,22 @@ export function createDefaultClientFactory() {
19
19
  let instance;
20
20
  const openRouter = isOpenRouterBaseUrl(options?.baseUrl);
21
21
  const defaultHeaders = openRouter ? buildOpenRouterHeaders() : undefined;
22
+ const httpTimeoutMs = typeof options?.httpTimeoutMs === 'number' && Number.isFinite(options.httpTimeoutMs) && options.httpTimeoutMs > 0
23
+ ? options.httpTimeoutMs
24
+ : 20 * 60 * 1000;
22
25
  if (options?.azure?.endpoint) {
23
26
  instance = new AzureOpenAI({
24
27
  apiKey: key,
25
28
  endpoint: options.azure.endpoint,
26
29
  apiVersion: options.azure.apiVersion,
27
30
  deployment: options.azure.deployment,
28
- timeout: 20 * 60 * 1000,
31
+ timeout: httpTimeoutMs,
29
32
  });
30
33
  }
31
34
  else {
32
35
  instance = new OpenAI({
33
36
  apiKey: key,
34
- timeout: 20 * 60 * 1000,
37
+ timeout: httpTimeoutMs,
35
38
  baseURL: options?.baseUrl,
36
39
  defaultHeaders,
37
40
  });
@@ -5,7 +5,7 @@ import { FileValidationError } from './errors.js';
5
5
  const MAX_FILE_SIZE_BYTES = 1 * 1024 * 1024; // 1 MB
6
6
  const DEFAULT_FS = fs;
7
7
  const DEFAULT_IGNORED_DIRS = ['node_modules', 'dist', 'coverage', '.git', '.turbo', '.next', 'build', 'tmp'];
8
- export async function readFiles(filePaths, { cwd = process.cwd(), fsModule = DEFAULT_FS, maxFileSizeBytes = MAX_FILE_SIZE_BYTES } = {}) {
8
+ export async function readFiles(filePaths, { cwd = process.cwd(), fsModule = DEFAULT_FS, maxFileSizeBytes = MAX_FILE_SIZE_BYTES, readContents = true, } = {}) {
9
9
  if (!filePaths || filePaths.length === 0) {
10
10
  return [];
11
11
  }
@@ -83,7 +83,7 @@ export async function readFiles(filePaths, { cwd = process.cwd(), fsModule = DEF
83
83
  }
84
84
  const files = [];
85
85
  for (const filePath of accepted) {
86
- const content = await fsModule.readFile(filePath, 'utf8');
86
+ const content = readContents ? await fsModule.readFile(filePath, 'utf8') : '';
87
87
  files.push({ path: filePath, content });
88
88
  }
89
89
  return files;
@@ -297,6 +297,7 @@ export async function runOracle(options, deps = {}) {
297
297
  : modelConfig.model.startsWith('gemini')
298
298
  ? resolveGeminiModelId(effectiveModelId)
299
299
  : effectiveModelId,
300
+ httpTimeoutMs: options.httpTimeoutMs,
300
301
  });
301
302
  logVerbose('Dispatching request to API...');
302
303
  if (options.verbose) {
@@ -1,6 +1,7 @@
1
1
  import http from 'node:http';
2
2
  import path from 'node:path';
3
3
  import { readFile } from 'node:fs/promises';
4
+ import { parseHostPort } from '../bridge/connection.js';
4
5
  export function createRemoteBrowserExecutor({ host, token }) {
5
6
  // Return a drop-in replacement for runBrowserMode so the browser session runner can stay unchanged.
6
7
  return async function remoteBrowserExecutor(options) {
@@ -79,12 +80,12 @@ async function serializeAttachments(attachments) {
79
80
  return serialized;
80
81
  }
81
82
  function parseHost(input) {
82
- const [hostname, portStr] = input.split(':');
83
- const port = Number.parseInt(portStr ?? '', 10);
84
- if (!hostname || Number.isNaN(port)) {
85
- throw new Error(`Invalid remote host: ${input}`);
83
+ try {
84
+ return parseHostPort(input);
85
+ }
86
+ catch (error) {
87
+ throw new Error(`Invalid remote host: ${input} (${error instanceof Error ? error.message : String(error)})`);
86
88
  }
87
- return { hostname, port };
88
89
  }
89
90
  function handleEvent(line, options, onResult, onError) {
90
91
  let event;