@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
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2025 Peter Steinberger
3
+ \g<1>2026 Peter Steinberger
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -119,6 +119,11 @@ npx -y @steipete/oracle oracle-mcp
119
119
  | `--browser-timeout`, `--browser-input-timeout` | Control overall/browser input timeouts (supports h/m/s/ms). |
120
120
  | `--render`, `--copy` | Print and/or copy the assembled markdown bundle. |
121
121
  | `--wait` | Block for background API runs (e.g., GPT‑5.1 Pro) instead of detaching. |
122
+ | `--timeout <seconds\|auto>` | Overall API deadline (auto = 60m for pro, 120s otherwise). |
123
+ | `--background`, `--no-background` | Force Responses API background mode (create + retrieve) for API runs. |
124
+ | `--http-timeout <ms\|s\|m\|h>` | HTTP client timeout (default 20m). |
125
+ | `--zombie-timeout <ms\|s\|m\|h>` | Override stale-session cutoff used by `oracle status`. |
126
+ | `--zombie-last-activity` | Use last log activity to detect stale sessions. |
122
127
  | `--write-output <path>` | Save only the final answer (multi-model adds `.<model>`). |
123
128
  | `--files-report` | Print per-file token usage. |
124
129
  | `--dry-run [summary\|json\|full]` | Preview without sending. |
@@ -150,6 +155,7 @@ Advanced flags
150
155
  | Area | Flags |
151
156
  | --- | --- |
152
157
  | Browser | `--browser-manual-login`, `--browser-thinking-time`, `--browser-timeout`, `--browser-input-timeout`, `--browser-cookie-wait`, `--browser-inline-cookies[(-file)]`, `--browser-attachments`, `--browser-inline-files`, `--browser-bundle-files`, `--browser-keep-browser`, `--browser-headless`, `--browser-hide-window`, `--browser-no-cookie-sync`, `--browser-allow-cookie-errors`, `--browser-chrome-path`, `--browser-cookie-path`, `--chatgpt-url` |
158
+ | Run control | `--background`, `--no-background`, `--http-timeout`, `--zombie-timeout`, `--zombie-last-activity` |
153
159
  | Azure/OpenAI | `--azure-endpoint`, `--azure-deployment`, `--azure-api-version`, `--base-url` |
154
160
 
155
161
  Remote browser example
@@ -171,6 +177,7 @@ oracle status --clear --hours 168
171
177
  ```
172
178
 
173
179
  ## More docs
180
+ - Bridge (Windows host → Linux client): [docs/bridge.md](docs/bridge.md)
174
181
  - Browser mode & forks: [docs/browser-mode.md](docs/browser-mode.md) (includes `oracle serve` remote service), [docs/chromium-forks.md](docs/chromium-forks.md), [docs/linux.md](docs/linux.md)
175
182
  - MCP: [docs/mcp.md](docs/mcp.md)
176
183
  - OpenAI/Azure/OpenRouter endpoints: [docs/openai-endpoints.md](docs/openai-endpoints.md), [docs/openrouter.md](docs/openrouter.md)
@@ -20,7 +20,7 @@ import { CHATGPT_URL } from '../src/browserMode.js';
20
20
  import { createRemoteBrowserExecutor } from '../src/remote/client.js';
21
21
  import { createGeminiWebExecutor } from '../src/gemini-web/index.js';
22
22
  import { applyHelpStyling } from '../src/cli/help.js';
23
- import { collectPaths, collectModelList, parseFloatOption, parseIntOption, parseSearchOption, usesDefaultStatusFilters, resolvePreviewMode, normalizeModelOption, normalizeBaseUrl, resolveApiModel, inferModelFromLabel, parseHeartbeatOption, parseTimeoutOption, mergePathLikeOptions, dedupePathInputs, } from '../src/cli/options.js';
23
+ import { collectPaths, collectModelList, parseFloatOption, parseIntOption, parseSearchOption, usesDefaultStatusFilters, resolvePreviewMode, normalizeModelOption, normalizeBaseUrl, resolveApiModel, inferModelFromLabel, parseHeartbeatOption, parseTimeoutOption, parseDurationOption, mergePathLikeOptions, dedupePathInputs, } from '../src/cli/options.js';
24
24
  import { copyToClipboard } from '../src/cli/clipboard.js';
25
25
  import { buildMarkdownBundle } from '../src/cli/markdownBundle.js';
26
26
  import { shouldDetachSession } from '../src/cli/detach.js';
@@ -46,9 +46,20 @@ import { resolveNotificationSettings, deriveNotificationSettingsFromMetadata, }
46
46
  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
+ import { resolveRemoteServiceConfig } from '../src/remote/remoteServiceConfig.js';
49
50
  const VERSION = getCliVersion();
50
51
  const CLI_ENTRYPOINT = fileURLToPath(import.meta.url);
51
- const rawCliArgs = process.argv.slice(2);
52
+ const LEGACY_FLAG_ALIASES = new Map([
53
+ ['--[no-]notify', '--notify'],
54
+ ['--[no-]notify-sound', '--notify-sound'],
55
+ ['--[no-]background', '--background'],
56
+ ]);
57
+ const normalizedArgv = process.argv.map((arg, index) => {
58
+ if (index < 2)
59
+ return arg;
60
+ return LEGACY_FLAG_ALIASES.get(arg) ?? arg;
61
+ });
62
+ const rawCliArgs = normalizedArgv.slice(2);
52
63
  const userCliArgs = rawCliArgs[0] === CLI_ENTRYPOINT ? rawCliArgs.slice(1) : rawCliArgs;
53
64
  const isTty = process.stdout.isTTY;
54
65
  const program = new Command();
@@ -120,12 +131,22 @@ program
120
131
  .addOption(new Option('--mode <mode>', 'Alias for --engine (api | browser).').choices(['api', 'browser']).hideHelp())
121
132
  .option('--files-report', 'Show token usage per attached file (also prints automatically when files exceed the token budget).', false)
122
133
  .option('-v, --verbose', 'Enable verbose logging for all operations.', false)
123
- .addOption(new Option('--[no-]notify', 'Desktop notification when a session finishes (default on unless CI/SSH).')
124
- .default(undefined))
125
- .addOption(new Option('--[no-]notify-sound', 'Play a notification sound on completion (default off).').default(undefined))
134
+ .addOption(new Option('--notify', 'Desktop notification when a session finishes (default on unless CI/SSH).').default(undefined))
135
+ .addOption(new Option('--no-notify', 'Disable desktop notifications.').default(undefined))
136
+ .addOption(new Option('--notify-sound', 'Play a notification sound on completion (default off).').default(undefined))
137
+ .addOption(new Option('--no-notify-sound', 'Disable notification sounds.').default(undefined))
126
138
  .addOption(new Option('--timeout <seconds|auto>', 'Overall timeout before aborting the API call (auto = 60m for gpt-5.2-pro, 120s otherwise).')
127
139
  .argParser(parseTimeoutOption)
128
140
  .default('auto'))
141
+ .addOption(new Option('--background', 'Use Responses API background mode (create + retrieve) for API runs.').default(undefined))
142
+ .addOption(new Option('--no-background', 'Disable Responses API background mode.').default(undefined))
143
+ .addOption(new Option('--http-timeout <ms|s|m|h>', 'HTTP client timeout for API requests (default 20m).')
144
+ .argParser((value) => parseDurationOption(value, 'HTTP timeout'))
145
+ .default(undefined))
146
+ .addOption(new Option('--zombie-timeout <ms|s|m|h>', 'Override stale-session cutoff used by `oracle status` (default 60m).')
147
+ .argParser((value) => parseDurationOption(value, 'Zombie timeout'))
148
+ .default(undefined))
149
+ .option('--zombie-last-activity', 'Base stale-session detection on last log activity instead of start time.', false)
129
150
  .addOption(new Option('--preview [mode]', '(alias) Preview the request without calling the model (summary | json | full). Deprecated: use --dry-run instead.')
130
151
  .hideHelp()
131
152
  .choices(['summary', 'json', 'full'])
@@ -219,14 +240,73 @@ program
219
240
  .option('--host <address>', 'Interface to bind (default 0.0.0.0).')
220
241
  .option('--port <number>', 'Port to listen on (default random).', parseIntOption)
221
242
  .option('--token <value>', 'Access token clients must provide (random if omitted).')
243
+ .option('--manual-login', 'Use a dedicated Chrome profile for manual login (recommended when cookie sync is unavailable).', false)
244
+ .option('--manual-login-profile-dir <path>', 'Chrome profile directory for manual login (default ~/.oracle/browser-profile).')
222
245
  .action(async (commandOptions) => {
223
246
  const { serveRemote } = await import('../src/remote/server.js');
224
247
  await serveRemote({
225
248
  host: commandOptions.host,
226
249
  port: commandOptions.port,
227
250
  token: commandOptions.token,
251
+ manualLoginDefault: commandOptions.manualLogin,
252
+ manualLoginProfileDir: commandOptions.manualLoginProfileDir,
228
253
  });
229
254
  });
255
+ const bridgeCommand = program.command('bridge').description('Bridge a Windows-hosted ChatGPT session to Linux clients.');
256
+ bridgeCommand
257
+ .command('host')
258
+ .description('Start a secure oracle serve host (optionally with an SSH reverse tunnel).')
259
+ .option('--bind <host:port>', 'Local bind address for the host service (default 127.0.0.1:9473).')
260
+ .option('--token <token|auto>', 'Service access token (default auto).', 'auto')
261
+ .option('--write-connection <path>', 'Write a connection artifact JSON (default ~/.oracle/bridge-connection.json).')
262
+ .option('--ssh <user@host>', 'Maintain an SSH reverse tunnel to the Linux host (ssh -N -R ...).')
263
+ .option('--ssh-remote-port <port>', 'Remote port to bind on the Linux host (default matches --bind port).', parseIntOption)
264
+ .option('--ssh-identity <path>', 'SSH identity file (ssh -i).')
265
+ .option('--ssh-extra-args <args>', 'Extra args passed to ssh (quoted string).')
266
+ .option('--background', 'Run the host in the background and write pid/log files.', false)
267
+ .option('--foreground', 'Run the host in the foreground (default).', false)
268
+ .option('--print', 'Print the client connection string (includes token).', false)
269
+ .option('--print-token', 'Print only the token.', false)
270
+ .action(async (commandOptions) => {
271
+ const { runBridgeHost } = await import('../src/cli/bridge/host.js');
272
+ await runBridgeHost(commandOptions);
273
+ });
274
+ bridgeCommand
275
+ .command('client')
276
+ .description('Configure this machine to use a remote oracle serve host.')
277
+ .requiredOption('--connect <connection>', 'Connection string or path to bridge-connection.json.')
278
+ .option('--config <path>', 'Override the oracle config file location (default ~/.oracle/config.json).')
279
+ .option('--no-write-config', 'Do not write ~/.oracle/config.json (just validate).')
280
+ .option('--no-test', 'Skip remote /health check.')
281
+ .option('--print-env', 'Print env var exports (includes token).', false)
282
+ .action(async (commandOptions) => {
283
+ const { runBridgeClient } = await import('../src/cli/bridge/client.js');
284
+ await runBridgeClient(commandOptions);
285
+ });
286
+ bridgeCommand
287
+ .command('doctor')
288
+ .description('Diagnose bridge connectivity and browser engine prerequisites.')
289
+ .option('--verbose', 'Show extra diagnostics.', false)
290
+ .action(async (commandOptions) => {
291
+ const { runBridgeDoctor } = await import('../src/cli/bridge/doctor.js');
292
+ await runBridgeDoctor(commandOptions);
293
+ });
294
+ bridgeCommand
295
+ .command('codex-config')
296
+ .description('Print a Codex CLI MCP server config snippet for oracle-mcp.')
297
+ .option('--print-token', 'Include ORACLE_REMOTE_TOKEN in the snippet.', false)
298
+ .action(async (commandOptions) => {
299
+ const { runBridgeCodexConfig } = await import('../src/cli/bridge/codexConfig.js');
300
+ await runBridgeCodexConfig(commandOptions);
301
+ });
302
+ bridgeCommand
303
+ .command('claude-config')
304
+ .description('Print a Claude Code MCP config snippet (.mcp.json) for oracle-mcp.')
305
+ .option('--print-token', 'Include ORACLE_REMOTE_TOKEN in the snippet.', false)
306
+ .action(async (commandOptions) => {
307
+ const { runBridgeClaudeConfig } = await import('../src/cli/bridge/claudeConfig.js');
308
+ await runBridgeClaudeConfig(commandOptions);
309
+ });
230
310
  program
231
311
  .command('tui')
232
312
  .description('Launch the interactive terminal UI for humans (no automation).')
@@ -323,6 +403,9 @@ function buildRunOptions(options, overrides = {}) {
323
403
  maxOutput: overrides.maxOutput ?? options.maxOutput,
324
404
  system: overrides.system ?? options.system,
325
405
  timeoutSeconds: overrides.timeoutSeconds ?? options.timeout,
406
+ httpTimeoutMs: overrides.httpTimeoutMs ?? options.httpTimeout,
407
+ zombieTimeoutMs: overrides.zombieTimeoutMs ?? options.zombieTimeout,
408
+ zombieUseLastActivity: overrides.zombieUseLastActivity ?? options.zombieLastActivity,
326
409
  silent: overrides.silent ?? options.silent,
327
410
  search: overrides.search ?? options.search,
328
411
  preview: overrides.preview ?? undefined,
@@ -373,6 +456,10 @@ function buildRunOptionsFromMetadata(metadata) {
373
456
  apiKey: undefined,
374
457
  baseUrl: normalizeBaseUrl(stored.baseUrl),
375
458
  azure: stored.azure,
459
+ timeoutSeconds: stored.timeoutSeconds,
460
+ httpTimeoutMs: stored.httpTimeoutMs,
461
+ zombieTimeoutMs: stored.zombieTimeoutMs,
462
+ zombieUseLastActivity: stored.zombieUseLastActivity,
376
463
  sessionId: metadata.id,
377
464
  verbose: stored.verbose,
378
465
  heartbeatIntervalMs: stored.heartbeatIntervalMs,
@@ -446,8 +533,14 @@ async function runRootCommand(options) {
446
533
  }
447
534
  };
448
535
  applyRetentionOption();
449
- const remoteHost = options.remoteHost ?? userConfig.remoteHost ?? userConfig.remote?.host ?? process.env.ORACLE_REMOTE_HOST;
450
- const remoteToken = options.remoteToken ?? userConfig.remoteToken ?? userConfig.remote?.token ?? process.env.ORACLE_REMOTE_TOKEN;
536
+ const remoteConfig = resolveRemoteServiceConfig({
537
+ cliHost: options.remoteHost,
538
+ cliToken: options.remoteToken,
539
+ userConfig,
540
+ env: process.env,
541
+ });
542
+ const remoteHost = remoteConfig.host;
543
+ const remoteToken = remoteConfig.token;
451
544
  if (remoteHost) {
452
545
  console.log(chalk.dim(`Remote browser host detected: ${remoteHost}`));
453
546
  }
@@ -752,7 +845,7 @@ async function runRootCommand(options) {
752
845
  const baseRunOptions = buildRunOptions(resolvedOptions, {
753
846
  preview: false,
754
847
  previewMode: undefined,
755
- background: userConfig.background ?? resolvedOptions.background,
848
+ background: resolvedOptions.background ?? userConfig.background,
756
849
  baseUrl: resolvedBaseUrl,
757
850
  });
758
851
  enforceBrowserSearchFlag(baseRunOptions, sessionMode, console.log);
@@ -956,7 +1049,7 @@ program.action(async function () {
956
1049
  await runRootCommand(options);
957
1050
  });
958
1051
  async function main() {
959
- const parsePromise = program.parseAsync(process.argv);
1052
+ const parsePromise = program.parseAsync(normalizedArgv);
960
1053
  const sigintPromise = once(process, 'SIGINT').then(() => 'sigint');
961
1054
  const result = await Promise.race([parsePromise.then(() => 'parsed'), sigintPromise]);
962
1055
  if (result === 'sigint') {
@@ -0,0 +1,53 @@
1
+ import puppeteer from 'puppeteer-core';
2
+ const port = parseInt(process.argv[2] || '52990', 10);
3
+ async function main() {
4
+ const browser = await puppeteer.connect({
5
+ browserURL: `http://127.0.0.1:${port}`,
6
+ defaultViewport: null,
7
+ });
8
+ const pages = await browser.pages();
9
+ let targetPage = null;
10
+ for (const page of pages) {
11
+ const url = page.url();
12
+ if (url.includes('chatgpt.com/c/')) {
13
+ targetPage = page;
14
+ break;
15
+ }
16
+ }
17
+ if (!targetPage) {
18
+ console.error('ChatGPT conversation page not found');
19
+ process.exit(1);
20
+ }
21
+ console.error('Found page:', await targetPage.url());
22
+ // Extract the last assistant message
23
+ const content = (await targetPage.evaluate(() => {
24
+ // Try multiple selectors for ChatGPT's assistant messages
25
+ const selectors = [
26
+ '[data-message-author-role="assistant"] .markdown',
27
+ '[data-message-author-role="assistant"]',
28
+ '.agent-turn .markdown',
29
+ '.agent-turn',
30
+ ];
31
+ for (const selector of selectors) {
32
+ const elements = document.querySelectorAll(selector);
33
+ if (elements.length > 0) {
34
+ const lastEl = elements[elements.length - 1];
35
+ return {
36
+ selector,
37
+ count: elements.length,
38
+ text: lastEl.innerText,
39
+ };
40
+ }
41
+ }
42
+ // Debug: show what's on the page
43
+ const body = document.body.innerHTML;
44
+ return { error: 'No messages found', bodyLength: body.length, sample: body.slice(0, 2000) };
45
+ }));
46
+ if ('error' in content) {
47
+ console.error('Error:', JSON.stringify(content, null, 2));
48
+ process.exit(1);
49
+ }
50
+ console.log(content.text);
51
+ browser.disconnect();
52
+ }
53
+ main().catch(console.error);
@@ -0,0 +1,103 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ export function normalizeHostPort(hostname, port) {
4
+ const trimmed = hostname.trim();
5
+ const unwrapped = trimmed.startsWith('[') && trimmed.endsWith(']') ? trimmed.slice(1, -1) : trimmed;
6
+ if (unwrapped.includes(':')) {
7
+ return `[${unwrapped}]:${port}`;
8
+ }
9
+ return `${unwrapped}:${port}`;
10
+ }
11
+ export function parseHostPort(raw) {
12
+ const target = raw.trim();
13
+ if (!target) {
14
+ throw new Error('Expected host:port but received an empty value.');
15
+ }
16
+ const ipv6Match = target.match(/^\[(.+)]:(\d+)$/);
17
+ let hostname;
18
+ let portSegment;
19
+ if (ipv6Match) {
20
+ hostname = ipv6Match[1]?.trim();
21
+ portSegment = ipv6Match[2]?.trim();
22
+ }
23
+ else {
24
+ const lastColon = target.lastIndexOf(':');
25
+ if (lastColon === -1) {
26
+ throw new Error(`Invalid host:port format: ${target}. Expected host:port (IPv6 must use [host]:port notation).`);
27
+ }
28
+ hostname = target.slice(0, lastColon).trim();
29
+ portSegment = target.slice(lastColon + 1).trim();
30
+ if (hostname.includes(':')) {
31
+ throw new Error(`Invalid host:port format: ${target}. Wrap IPv6 addresses in brackets, e.g. "[2001:db8::1]:9473".`);
32
+ }
33
+ }
34
+ if (!hostname) {
35
+ throw new Error(`Invalid host:port format: ${target}. Host portion is missing.`);
36
+ }
37
+ const port = Number.parseInt(portSegment ?? '', 10);
38
+ if (!Number.isFinite(port) || port <= 0 || port > 65_535) {
39
+ throw new Error(`Invalid port: "${portSegment ?? ''}". Expected a number between 1 and 65535.`);
40
+ }
41
+ return { hostname, port };
42
+ }
43
+ export function parseBridgeConnectionString(input) {
44
+ const raw = input.trim();
45
+ if (!raw) {
46
+ throw new Error('Missing connection string.');
47
+ }
48
+ let url;
49
+ try {
50
+ url = raw.includes('://') ? new URL(raw) : new URL(`oracle+tcp://${raw}`);
51
+ }
52
+ catch (error) {
53
+ throw new Error(`Invalid connection string: ${error instanceof Error ? error.message : String(error)}`);
54
+ }
55
+ const hostname = url.hostname?.trim();
56
+ const port = Number.parseInt(url.port ?? '', 10);
57
+ if (!hostname || !Number.isFinite(port) || port <= 0 || port > 65_535) {
58
+ throw new Error(`Invalid connection string host: ${raw}. Expected host:port.`);
59
+ }
60
+ const token = url.searchParams.get('token')?.trim() ?? '';
61
+ if (!token) {
62
+ throw new Error('Connection string is missing token. Expected "?token=...".');
63
+ }
64
+ const remoteHost = normalizeHostPort(hostname, port);
65
+ return { remoteHost, remoteToken: token };
66
+ }
67
+ export function formatBridgeConnectionString(connection, options = {}) {
68
+ const { hostname, port } = parseHostPort(connection.remoteHost);
69
+ const base = `oracle+tcp://${normalizeHostPort(hostname, port)}`;
70
+ if (!options.includeToken) {
71
+ return base;
72
+ }
73
+ const params = new URLSearchParams({ token: connection.remoteToken });
74
+ return `${base}?${params.toString()}`;
75
+ }
76
+ export function looksLikePath(value) {
77
+ return value.includes('/') || value.includes('\\') || value.endsWith('.json');
78
+ }
79
+ export async function readBridgeConnectionArtifact(filePath) {
80
+ const resolved = path.resolve(process.cwd(), filePath);
81
+ const raw = await fs.readFile(resolved, 'utf8');
82
+ let parsed;
83
+ try {
84
+ parsed = JSON.parse(raw);
85
+ }
86
+ catch (error) {
87
+ throw new Error(`Failed to parse connection artifact JSON at ${resolved}: ${error instanceof Error ? error.message : String(error)}`);
88
+ }
89
+ if (!parsed || typeof parsed !== 'object') {
90
+ throw new Error(`Invalid connection artifact at ${resolved}: expected an object.`);
91
+ }
92
+ const remoteHost = parsed.remoteHost;
93
+ const remoteToken = parsed.remoteToken;
94
+ if (typeof remoteHost !== 'string' || remoteHost.trim().length === 0) {
95
+ throw new Error(`Invalid connection artifact at ${resolved}: remoteHost is missing.`);
96
+ }
97
+ if (typeof remoteToken !== 'string' || remoteToken.trim().length === 0) {
98
+ throw new Error(`Invalid connection artifact at ${resolved}: remoteToken is missing.`);
99
+ }
100
+ // Validate host formatting early so downstream checks don't crash.
101
+ parseHostPort(remoteHost);
102
+ return parsed;
103
+ }
@@ -0,0 +1,28 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import JSON5 from 'json5';
4
+ export async function readUserConfigFile(configPath) {
5
+ try {
6
+ const raw = await fs.readFile(configPath, 'utf8');
7
+ const parsed = JSON5.parse(raw);
8
+ return { config: parsed ?? {}, loaded: true };
9
+ }
10
+ catch (error) {
11
+ const code = error.code;
12
+ if (code === 'ENOENT') {
13
+ return { config: {}, loaded: false };
14
+ }
15
+ throw new Error(`Failed to read ${configPath}: ${error instanceof Error ? error.message : String(error)}`);
16
+ }
17
+ }
18
+ export async function writeUserConfigFile(configPath, config) {
19
+ const dir = path.dirname(configPath);
20
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 });
21
+ const contents = `${JSON.stringify(config, null, 2)}\n`;
22
+ const tempPath = `${configPath}.tmp-${process.pid}-${Date.now()}`;
23
+ await fs.writeFile(tempPath, contents, { encoding: 'utf8', mode: 0o600 });
24
+ await fs.rename(tempPath, configPath);
25
+ if (process.platform !== 'win32') {
26
+ await fs.chmod(configPath, 0o600).catch(() => undefined);
27
+ }
28
+ }
@@ -286,11 +286,15 @@ async function pollAssistantCompletion(Runtime, timeoutMs, minTurnIndex) {
286
286
  isCompletionVisible(Runtime),
287
287
  ]);
288
288
  const shortAnswer = currentLength > 0 && currentLength < 16;
289
+ const mediumAnswer = currentLength >= 16 && currentLength < 40;
290
+ const longAnswer = currentLength >= 40 && currentLength < 500;
289
291
  // Learned: short answers need a longer stability window or they truncate.
290
- const completionStableTarget = shortAnswer ? 12 : currentLength < 40 ? 8 : 4;
291
- const requiredStableCycles = shortAnswer ? 12 : 6;
292
+ // Learned: long streaming responses (esp. thinking models) can pause mid-stream;
293
+ // use progressively longer windows to avoid truncation (#71).
294
+ const completionStableTarget = shortAnswer ? 12 : mediumAnswer ? 8 : longAnswer ? 6 : 8;
295
+ const requiredStableCycles = shortAnswer ? 12 : mediumAnswer ? 8 : longAnswer ? 8 : 10;
292
296
  const stableMs = Date.now() - lastChangeAt;
293
- const minStableMs = shortAnswer ? 8000 : 1200;
297
+ const minStableMs = shortAnswer ? 8000 : mediumAnswer ? 1200 : longAnswer ? 2000 : 3000;
294
298
  // Require stop button to disappear before treating completion as final.
295
299
  if (!stopVisible) {
296
300
  const stableEnough = stableCycles >= requiredStableCycles && stableMs >= minStableMs;
@@ -546,15 +550,19 @@ function buildResponseObserverExpression(timeoutMs, minTurnIndex) {
546
550
 
547
551
  const waitForSettle = async (snapshot) => {
548
552
  // Learned: short answers can be 1-2 tokens; enforce longer settle windows to avoid truncation.
553
+ // Learned: long streaming responses (esp. thinking models) can pause mid-stream;
554
+ // use progressively longer windows to avoid truncation (#71).
549
555
  const initialLength = snapshot?.text?.length ?? 0;
550
556
  const shortAnswer = initialLength > 0 && initialLength < 16;
551
- const settleWindowMs = shortAnswer ? 12_000 : 5_000;
557
+ const mediumAnswer = initialLength >= 16 && initialLength < 40;
558
+ const longAnswer = initialLength >= 40 && initialLength < 500;
559
+ const settleWindowMs = shortAnswer ? 12_000 : mediumAnswer ? 5_000 : longAnswer ? 8_000 : 10_000;
552
560
  const settleIntervalMs = 400;
553
561
  const deadline = Date.now() + settleWindowMs;
554
562
  let latest = snapshot;
555
563
  let lastLength = snapshot?.text?.length ?? 0;
556
564
  let stableCycles = 0;
557
- const stableTarget = shortAnswer ? 6 : 3;
565
+ const stableTarget = shortAnswer ? 6 : mediumAnswer ? 3 : longAnswer ? 5 : 6;
558
566
  while (Date.now() < deadline) {
559
567
  await new Promise((resolve) => setTimeout(resolve, settleIntervalMs));
560
568
  const refreshedRaw = extractFromTurns();
@@ -570,7 +570,7 @@ export async function uploadAttachmentFile(deps, attachment, logger, options) {
570
570
  // keep it as a fallback, but strongly prefer visible (even sr-only 1x1) inputs.
571
571
  const localSet = new Set(localInputs);
572
572
  let idx = 0;
573
- const candidates = inputs.map((el) => {
573
+ let candidates = inputs.map((el) => {
574
574
  const accept = el.getAttribute('accept') || '';
575
575
  const imageOnly = acceptIsImageOnly(accept);
576
576
  const rect = el instanceof HTMLElement ? el.getBoundingClientRect() : { width: 0, height: 0 };
@@ -583,9 +583,18 @@ export async function uploadAttachmentFile(deps, attachment, logger, options) {
583
583
  (!imageOnly ? 30 : isImageAttachment ? 20 : 5);
584
584
  el.setAttribute('data-oracle-upload-candidate', 'true');
585
585
  el.setAttribute('data-oracle-upload-idx', String(idx));
586
- return { idx: idx++, score, imageOnly, visible, local };
586
+ return { idx: idx++, score, imageOnly };
587
587
  });
588
588
 
589
+ // When the attachment isn't an image, avoid inputs that only accept images.
590
+ // Some ChatGPT surfaces expose multiple file inputs (e.g. image-only vs generic upload).
591
+ if (!isImageAttachment) {
592
+ const nonImage = candidates.filter((candidate) => !candidate.imageOnly);
593
+ if (nonImage.length > 0) {
594
+ candidates = nonImage;
595
+ }
596
+ }
597
+
589
598
  // Prefer higher scores first.
590
599
  candidates.sort((a, b) => b.score - a.score);
591
600
  return {
@@ -829,8 +838,8 @@ export async function uploadAttachmentFile(deps, attachment, logger, options) {
829
838
  continue;
830
839
  }
831
840
  const baselineInputSnapshot = await readInputSnapshot(idx);
832
- const gatherSignals = async () => {
833
- const signalResult = await waitForAttachmentUiSignal(attachmentUiSignalWaitMs);
841
+ const gatherSignals = async (waitMs = attachmentUiSignalWaitMs) => {
842
+ const signalResult = await waitForAttachmentUiSignal(waitMs);
834
843
  const postInputSnapshot = await readInputSnapshot(idx);
835
844
  const postInputSignals = inputSignalsFor(baselineInputSnapshot, postInputSnapshot);
836
845
  const snapshot = await runtime
@@ -890,22 +899,6 @@ export async function uploadAttachmentFile(deps, attachment, logger, options) {
890
899
  if (!hasExpectedFile) {
891
900
  if (mode === 'set') {
892
901
  await dom.setFileInputFiles({ nodeId: resultNode.nodeId, files: [attachment.path] });
893
- await runtime
894
- .evaluate({
895
- expression: `(() => {
896
- const input = document.querySelector('input[type="file"][data-oracle-upload-idx="${idx}"]');
897
- if (!(input instanceof HTMLInputElement)) return false;
898
- try {
899
- input.dispatchEvent(new Event('input', { bubbles: true }));
900
- input.dispatchEvent(new Event('change', { bubbles: true }));
901
- return true;
902
- } catch {
903
- return false;
904
- }
905
- })()`,
906
- returnByValue: true,
907
- })
908
- .catch(() => undefined);
909
902
  }
910
903
  else {
911
904
  const selector = `input[type="file"][data-oracle-upload-idx="${idx}"]`;
@@ -930,12 +923,43 @@ export async function uploadAttachmentFile(deps, attachment, logger, options) {
930
923
  const evaluation = await evaluateSignals(signalState.signalResult, signalState.postInputSignals, immediateInputMatch);
931
924
  return { evaluation, signalState, immediateInputMatch };
932
925
  };
926
+ const dispatchInputEvents = async () => {
927
+ await runtime
928
+ .evaluate({
929
+ expression: `(() => {
930
+ const input = document.querySelector('input[type="file"][data-oracle-upload-idx="${idx}"]');
931
+ if (!(input instanceof HTMLInputElement)) return false;
932
+ try {
933
+ input.dispatchEvent(new Event('input', { bubbles: true }));
934
+ input.dispatchEvent(new Event('change', { bubbles: true }));
935
+ return true;
936
+ } catch {
937
+ return false;
938
+ }
939
+ })()`,
940
+ returnByValue: true,
941
+ })
942
+ .catch(() => undefined);
943
+ };
933
944
  let result = await runInputAttempt('set');
934
945
  if (result.evaluation.status === 'ui') {
935
946
  confirmedAttachment = true;
936
947
  break;
937
948
  }
938
949
  if (result.evaluation.status === 'input') {
950
+ await dispatchInputEvents();
951
+ await delay(150);
952
+ const forcedState = await gatherSignals(1_500);
953
+ const forcedEvaluation = await evaluateSignals(forcedState.signalResult, forcedState.postInputSignals, result.immediateInputMatch);
954
+ if (forcedEvaluation.status === 'ui') {
955
+ confirmedAttachment = true;
956
+ break;
957
+ }
958
+ if (forcedEvaluation.status === 'input') {
959
+ logger('Attachment input set; proceeding without UI confirmation.');
960
+ inputConfirmed = true;
961
+ break;
962
+ }
939
963
  logger('Attachment input set; retrying with data transfer to trigger ChatGPT upload.');
940
964
  await dom.setFileInputFiles({ nodeId: resultNode.nodeId, files: [] }).catch(() => undefined);
941
965
  await delay(150);
@@ -124,15 +124,14 @@ export async function connectToChrome(port, logger, host) {
124
124
  }
125
125
  export async function connectToRemoteChrome(host, port, logger, targetUrl) {
126
126
  if (targetUrl) {
127
- try {
128
- const target = await CDP.New({ host, port, url: targetUrl });
129
- const client = await CDP({ host, port, target: target.id });
130
- logger(`Opened dedicated remote Chrome tab targeting ${targetUrl}`);
131
- return { client, targetId: target.id };
132
- }
133
- catch (error) {
134
- const message = error instanceof Error ? error.message : String(error);
135
- logger(`Failed to open dedicated remote Chrome tab (${message}); falling back to first target.`);
127
+ const targetConnection = await connectToNewTarget(host, port, targetUrl, logger, {
128
+ opened: () => `Opened dedicated remote Chrome tab targeting ${targetUrl}`,
129
+ openFailed: (message) => `Failed to open dedicated remote Chrome tab (${message}); falling back to first target.`,
130
+ attachFailed: (targetId, message) => `Failed to attach to dedicated remote Chrome tab ${targetId} (${message}); falling back to first target.`,
131
+ closeFailed: (targetId, message) => `Failed to close unused remote Chrome tab ${targetId}: ${message}`,
132
+ });
133
+ if (targetConnection) {
134
+ return { client: targetConnection.client, targetId: targetConnection.targetId };
136
135
  }
137
136
  }
138
137
  const fallbackClient = await CDP({ host, port });
@@ -154,6 +153,60 @@ export async function closeRemoteChromeTarget(host, port, targetId, logger) {
154
153
  logger(`Failed to close remote Chrome tab ${targetId}: ${message}`);
155
154
  }
156
155
  }
156
+ async function connectToNewTarget(host, port, url, logger, messages) {
157
+ try {
158
+ const target = await CDP.New({ host, port, url });
159
+ try {
160
+ const client = await CDP({ host, port, target: target.id });
161
+ if (messages.opened) {
162
+ logger(messages.opened(target.id));
163
+ }
164
+ return { client, targetId: target.id };
165
+ }
166
+ catch (error) {
167
+ const message = error instanceof Error ? error.message : String(error);
168
+ logger(messages.attachFailed(target.id, message));
169
+ try {
170
+ await CDP.Close({ host, port, id: target.id });
171
+ }
172
+ catch (closeError) {
173
+ const closeMessage = closeError instanceof Error ? closeError.message : String(closeError);
174
+ logger(messages.closeFailed(target.id, closeMessage));
175
+ }
176
+ }
177
+ }
178
+ catch (error) {
179
+ const message = error instanceof Error ? error.message : String(error);
180
+ logger(messages.openFailed(message));
181
+ }
182
+ return null;
183
+ }
184
+ export async function connectWithNewTab(port, logger, initialUrl, host) {
185
+ const effectiveHost = host ?? '127.0.0.1';
186
+ const url = initialUrl ?? 'about:blank';
187
+ const targetConnection = await connectToNewTarget(effectiveHost, port, url, logger, {
188
+ opened: (targetId) => `Opened isolated browser tab (target=${targetId})`,
189
+ openFailed: (message) => `Failed to open isolated browser tab (${message}); falling back to default target.`,
190
+ attachFailed: (targetId, message) => `Failed to attach to isolated browser tab ${targetId} (${message}); falling back to default target.`,
191
+ closeFailed: (targetId, message) => `Failed to close unused browser tab ${targetId}: ${message}`,
192
+ });
193
+ if (targetConnection) {
194
+ return targetConnection;
195
+ }
196
+ const client = await connectToChrome(port, logger, effectiveHost);
197
+ return { client };
198
+ }
199
+ export async function closeTab(port, targetId, logger, host) {
200
+ const effectiveHost = host ?? '127.0.0.1';
201
+ try {
202
+ await CDP.Close({ host: effectiveHost, port, id: targetId });
203
+ logger(`Closed isolated browser tab (target=${targetId})`);
204
+ }
205
+ catch (error) {
206
+ const message = error instanceof Error ? error.message : String(error);
207
+ logger(`Failed to close browser tab ${targetId}: ${message}`);
208
+ }
209
+ }
157
210
  function buildChromeFlags(headless, debugBindAddress) {
158
211
  const flags = [
159
212
  '--disable-background-networking',