@steipete/oracle 0.7.1 → 0.7.3

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 (30) hide show
  1. package/dist/src/browser/actions/assistantResponse.js +53 -33
  2. package/dist/src/browser/actions/attachments.js +276 -133
  3. package/dist/src/browser/actions/modelSelection.js +33 -2
  4. package/dist/src/browser/actions/promptComposer.js +38 -45
  5. package/dist/src/browser/chromeLifecycle.js +2 -0
  6. package/dist/src/browser/config.js +7 -2
  7. package/dist/src/browser/index.js +12 -2
  8. package/dist/src/browser/pageActions.js +1 -1
  9. package/dist/src/browser/reattach.js +192 -17
  10. package/dist/src/browser/utils.js +10 -0
  11. package/dist/src/browserMode.js +1 -1
  12. package/dist/src/cli/browserConfig.js +11 -6
  13. package/dist/src/cli/notifier.js +8 -2
  14. package/dist/src/cli/oscUtils.js +1 -19
  15. package/dist/src/cli/sessionDisplay.js +6 -3
  16. package/dist/src/cli/sessionTable.js +5 -1
  17. package/dist/src/oracle/files.js +8 -1
  18. package/dist/src/oracle/modelResolver.js +11 -4
  19. package/dist/src/oracle/multiModelRunner.js +3 -14
  20. package/dist/src/oracle/oscProgress.js +12 -61
  21. package/dist/src/oracle/run.js +62 -34
  22. package/dist/src/sessionManager.js +91 -2
  23. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  24. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  25. package/dist/vendor/oracle-notifier/build-notifier.sh +0 -0
  26. package/package.json +43 -26
  27. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  28. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  29. package/vendor/oracle-notifier/README.md +24 -0
  30. package/vendor/oracle-notifier/build-notifier.sh +0 -0
@@ -135,7 +135,6 @@ export async function submitPrompt(deps, prompt, logger) {
135
135
  logger('Clicked send button');
136
136
  }
137
137
  await verifyPromptCommitted(runtime, prompt, 30_000, logger);
138
- await clickAnswerNowIfPresent(runtime, logger);
139
138
  }
140
139
  export async function clearPromptComposer(Runtime, logger) {
141
140
  const primarySelectorLiteral = JSON.stringify(PROMPT_PRIMARY_SELECTOR);
@@ -186,6 +185,43 @@ async function waitForDomReady(Runtime, logger) {
186
185
  }
187
186
  logger?.('Page did not reach ready/composer state within 10s; continuing cautiously.');
188
187
  }
188
+ function buildAttachmentReadyExpression(attachmentNames) {
189
+ const namesLiteral = JSON.stringify(attachmentNames.map((name) => name.toLowerCase()));
190
+ return `(() => {
191
+ const names = ${namesLiteral};
192
+ const composer =
193
+ document.querySelector('[data-testid*="composer"]') ||
194
+ document.querySelector('form') ||
195
+ document.body ||
196
+ document;
197
+ const match = (node, name) => (node?.textContent || '').toLowerCase().includes(name);
198
+
199
+ // Restrict to attachment affordances; never scan generic div/span nodes (prompt text can contain the file name).
200
+ const attachmentSelectors = [
201
+ '[data-testid*="chip"]',
202
+ '[data-testid*="attachment"]',
203
+ '[data-testid*="upload"]',
204
+ '[aria-label="Remove file"]',
205
+ 'button[aria-label="Remove file"]',
206
+ ];
207
+
208
+ const chipsReady = names.every((name) =>
209
+ Array.from(composer.querySelectorAll(attachmentSelectors.join(','))).some((node) => match(node, name)),
210
+ );
211
+ const inputsReady = names.every((name) =>
212
+ Array.from(composer.querySelectorAll('input[type="file"]')).some((el) =>
213
+ Array.from((el instanceof HTMLInputElement ? el.files : []) || []).some((file) =>
214
+ file?.name?.toLowerCase?.().includes(name),
215
+ ),
216
+ ),
217
+ );
218
+
219
+ return chipsReady || inputsReady;
220
+ })()`;
221
+ }
222
+ export function buildAttachmentReadyExpressionForTest(attachmentNames) {
223
+ return buildAttachmentReadyExpression(attachmentNames);
224
+ }
189
225
  async function attemptSendButton(Runtime, _logger, attachmentNames) {
190
226
  const script = `(() => {
191
227
  ${buildClickDispatcher()}
@@ -215,19 +251,7 @@ async function attemptSendButton(Runtime, _logger, attachmentNames) {
215
251
  const needAttachment = Array.isArray(attachmentNames) && attachmentNames.length > 0;
216
252
  if (needAttachment) {
217
253
  const ready = await Runtime.evaluate({
218
- expression: `(() => {
219
- const names = ${JSON.stringify(attachmentNames.map((n) => n.toLowerCase()))};
220
- const match = (n, name) => (n?.textContent || '').toLowerCase().includes(name);
221
- const chipsReady = names.every((name) =>
222
- Array.from(document.querySelectorAll('[data-testid*="chip"],[data-testid*="attachment"],a,div,span')).some((node) => match(node, name)),
223
- );
224
- const inputsReady = names.every((name) =>
225
- Array.from(document.querySelectorAll('input[type="file"]')).some((el) =>
226
- Array.from(el.files || []).some((f) => f?.name?.toLowerCase?.().includes(name)),
227
- ),
228
- );
229
- return chipsReady || inputsReady;
230
- })()`,
254
+ expression: buildAttachmentReadyExpression(attachmentNames),
231
255
  returnByValue: true,
232
256
  });
233
257
  if (!ready?.result?.value) {
@@ -246,37 +270,6 @@ async function attemptSendButton(Runtime, _logger, attachmentNames) {
246
270
  }
247
271
  return false;
248
272
  }
249
- async function clickAnswerNowIfPresent(Runtime, logger) {
250
- const script = `(() => {
251
- ${buildClickDispatcher()}
252
- const matchesText = (el) => (el?.textContent || '').trim().toLowerCase() === 'answer now';
253
- const candidate = Array.from(document.querySelectorAll('button,span')).find(matchesText);
254
- if (!candidate) return 'missing';
255
- const button = candidate.closest('button') ?? candidate;
256
- const style = window.getComputedStyle(button);
257
- const disabled =
258
- button.hasAttribute('disabled') ||
259
- button.getAttribute('aria-disabled') === 'true' ||
260
- style.pointerEvents === 'none' ||
261
- style.display === 'none';
262
- if (disabled) return 'disabled';
263
- dispatchClickSequence(button);
264
- return 'clicked';
265
- })()`;
266
- const deadline = Date.now() + 3_000;
267
- while (Date.now() < deadline) {
268
- const { result } = await Runtime.evaluate({ expression: script, returnByValue: true });
269
- const status = result.value;
270
- if (status === 'clicked') {
271
- logger?.('Clicked "Answer now" gate');
272
- await delay(500);
273
- return;
274
- }
275
- if (status === 'missing')
276
- return;
277
- await delay(100);
278
- }
279
- }
280
273
  async function verifyPromptCommitted(Runtime, prompt, timeoutMs, logger) {
281
274
  const deadline = Date.now() + timeoutMs;
282
275
  const encodedPrompt = JSON.stringify(prompt.trim());
@@ -25,6 +25,7 @@ export async function launchChrome(config, userDataDir, logger) {
25
25
  chromePath: config.chromePath ?? undefined,
26
26
  chromeFlags,
27
27
  userDataDir,
28
+ handleSIGINT: false,
28
29
  port: debugPort ?? undefined,
29
30
  });
30
31
  const pidLabel = typeof launcher.pid === 'number' ? ` (pid ${launcher.pid})` : '';
@@ -216,6 +217,7 @@ async function launchWithCustomHost({ chromeFlags, chromePath, userDataDir, host
216
217
  chromePath: chromePath ?? undefined,
217
218
  chromeFlags,
218
219
  userDataDir,
220
+ handleSIGINT: false,
219
221
  port: requestedPort ?? undefined,
220
222
  });
221
223
  if (host) {
@@ -1,5 +1,5 @@
1
1
  import { CHATGPT_URL, DEFAULT_MODEL_TARGET } from './constants.js';
2
- import { normalizeChatgptUrl } from './utils.js';
2
+ import { isTemporaryChatUrl, normalizeChatgptUrl } from './utils.js';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  export const DEFAULT_BROWSER_CONFIG = {
@@ -32,6 +32,11 @@ export function resolveBrowserConfig(config) {
32
32
  (process.env.ORACLE_BROWSER_ALLOW_COOKIE_ERRORS ?? '').trim() === '1';
33
33
  const rawUrl = config?.chatgptUrl ?? config?.url ?? DEFAULT_BROWSER_CONFIG.url;
34
34
  const normalizedUrl = normalizeChatgptUrl(rawUrl ?? DEFAULT_BROWSER_CONFIG.url, DEFAULT_BROWSER_CONFIG.url);
35
+ const desiredModel = config?.desiredModel ?? DEFAULT_BROWSER_CONFIG.desiredModel ?? DEFAULT_MODEL_TARGET;
36
+ if (isTemporaryChatUrl(normalizedUrl) && /\bpro\b/i.test(desiredModel)) {
37
+ throw new Error('Temporary Chat mode does not expose Pro models in the ChatGPT model picker. ' +
38
+ 'Remove "temporary-chat=true" from your browser URL, or use a non-Pro model label (e.g. "GPT-5.2").');
39
+ }
35
40
  const isWindows = process.platform === 'win32';
36
41
  const manualLogin = config?.manualLogin ?? (isWindows ? true : DEFAULT_BROWSER_CONFIG.manualLogin);
37
42
  const cookieSyncDefault = isWindows ? false : DEFAULT_BROWSER_CONFIG.cookieSync;
@@ -53,7 +58,7 @@ export function resolveBrowserConfig(config) {
53
58
  headless: config?.headless ?? DEFAULT_BROWSER_CONFIG.headless,
54
59
  keepBrowser: config?.keepBrowser ?? DEFAULT_BROWSER_CONFIG.keepBrowser,
55
60
  hideWindow: config?.hideWindow ?? DEFAULT_BROWSER_CONFIG.hideWindow,
56
- desiredModel: config?.desiredModel ?? DEFAULT_BROWSER_CONFIG.desiredModel,
61
+ desiredModel,
57
62
  chromeProfile: config?.chromeProfile ?? DEFAULT_BROWSER_CONFIG.chromeProfile,
58
63
  chromePath: config?.chromePath ?? DEFAULT_BROWSER_CONFIG.chromePath,
59
64
  chromeCookiePath: config?.chromeCookiePath ?? DEFAULT_BROWSER_CONFIG.chromeCookiePath,
@@ -5,7 +5,7 @@ import net from 'node:net';
5
5
  import { resolveBrowserConfig } from './config.js';
6
6
  import { launchChrome, registerTerminationHooks, hideChromeWindow, connectToChrome, connectToRemoteChrome, closeRemoteChromeTarget, } from './chromeLifecycle.js';
7
7
  import { syncCookies } from './cookies.js';
8
- import { navigateToChatGPT, ensureNotBlocked, ensureLoggedIn, ensurePromptReady, ensureModelSelection, submitPrompt, clearPromptComposer, waitForAssistantResponse, captureAssistantMarkdown, uploadAttachmentFile, waitForAttachmentCompletion, readAssistantSnapshot, } from './pageActions.js';
8
+ import { navigateToChatGPT, ensureNotBlocked, ensureLoggedIn, ensurePromptReady, ensureModelSelection, submitPrompt, clearPromptComposer, waitForAssistantResponse, captureAssistantMarkdown, uploadAttachmentFile, waitForAttachmentCompletion, waitForUserTurnAttachments, readAssistantSnapshot, } from './pageActions.js';
9
9
  import { uploadAttachmentViaDataTransfer } from './actions/remoteFileTransfer.js';
10
10
  import { ensureExtendedThinking } from './actions/thinkingTime.js';
11
11
  import { estimateTokenCount, withRetries, delay } from './utils.js';
@@ -14,7 +14,7 @@ import { CHATGPT_URL } from './constants.js';
14
14
  import { BrowserAutomationError } from '../oracle/errors.js';
15
15
  import { cleanupStaleProfileState, readChromePid, readDevToolsPort, verifyDevToolsReachable, writeChromePid, writeDevToolsActivePort, } from './profileState.js';
16
16
  export { CHATGPT_URL, DEFAULT_MODEL_TARGET } from './constants.js';
17
- export { parseDuration, delay, normalizeChatgptUrl } from './utils.js';
17
+ export { parseDuration, delay, normalizeChatgptUrl, isTemporaryChatUrl } from './utils.js';
18
18
  export async function runBrowserMode(options) {
19
19
  const promptText = options.prompt?.trim();
20
20
  if (!promptText) {
@@ -37,12 +37,14 @@ export async function runBrowserMode(options) {
37
37
  if (!runtimeHintCb || !chrome?.port) {
38
38
  return;
39
39
  }
40
+ const conversationId = lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined;
40
41
  const hint = {
41
42
  chromePid: chrome.pid,
42
43
  chromePort: chrome.port,
43
44
  chromeHost,
44
45
  chromeTargetId: lastTargetId,
45
46
  tabUrl: lastUrl,
47
+ conversationId,
46
48
  userDataDir,
47
49
  controllerPid: process.pid,
48
50
  };
@@ -293,6 +295,10 @@ export async function runBrowserMode(options) {
293
295
  logger('All attachments uploaded');
294
296
  }
295
297
  await submitPrompt({ runtime: Runtime, input: Input, attachmentNames }, prompt, logger);
298
+ if (attachmentNames.length > 0) {
299
+ await waitForUserTurnAttachments(Runtime, attachmentNames, 20_000, logger);
300
+ logger('Verified attachments present on sent user message');
301
+ }
296
302
  };
297
303
  try {
298
304
  await raceWithDisconnect(submitOnce(promptText, attachments));
@@ -943,6 +949,10 @@ function isWsl() {
943
949
  return true;
944
950
  return os.release().toLowerCase().includes('microsoft');
945
951
  }
952
+ function extractConversationIdFromUrl(url) {
953
+ const match = url.match(/\/c\/([a-zA-Z0-9-]+)/);
954
+ return match?.[1];
955
+ }
946
956
  async function resolveUserDataBaseDir() {
947
957
  // On WSL, Chrome launched via Windows can choke on UNC paths; prefer a Windows-backed temp folder.
948
958
  if (isWsl()) {
@@ -1,5 +1,5 @@
1
1
  export { navigateToChatGPT, ensureNotBlocked, ensureLoggedIn, ensurePromptReady } from './actions/navigation.js';
2
2
  export { ensureModelSelection } from './actions/modelSelection.js';
3
3
  export { submitPrompt, clearPromptComposer } from './actions/promptComposer.js';
4
- export { uploadAttachmentFile, waitForAttachmentCompletion } from './actions/attachments.js';
4
+ export { uploadAttachmentFile, waitForAttachmentCompletion, waitForUserTurnAttachments } from './actions/attachments.js';
5
5
  export { waitForAssistantResponse, readAssistantSnapshot, captureAssistantMarkdown, buildAssistantExtractorForTest, buildConversationDebugExpressionForTest, } from './actions/assistantResponse.js';
@@ -1,5 +1,13 @@
1
1
  import CDP from 'chrome-remote-interface';
2
- import { waitForAssistantResponse, captureAssistantMarkdown } from './pageActions.js';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { mkdtemp, mkdir, rm } from 'node:fs/promises';
5
+ import { waitForAssistantResponse, captureAssistantMarkdown, navigateToChatGPT, ensureNotBlocked, ensureLoggedIn, ensurePromptReady, } from './pageActions.js';
6
+ import { launchChrome, connectToChrome, hideChromeWindow } from './chromeLifecycle.js';
7
+ import { resolveBrowserConfig } from './config.js';
8
+ import { syncCookies } from './cookies.js';
9
+ import { CHATGPT_URL } from './constants.js';
10
+ import { delay } from './utils.js';
3
11
  function pickTarget(targets, runtime) {
4
12
  if (!Array.isArray(targets) || targets.length === 0) {
5
13
  return undefined;
@@ -18,33 +26,114 @@ function pickTarget(targets, runtime) {
18
26
  return targets.find((t) => t.type === 'page') ?? targets[0];
19
27
  }
20
28
  export async function resumeBrowserSession(runtime, config, logger, deps = {}) {
29
+ const recoverSession = deps.recoverSession ??
30
+ (async (runtimeMeta, configMeta) => resumeBrowserSessionViaNewChrome(runtimeMeta, configMeta, logger, deps));
21
31
  if (!runtime.chromePort) {
22
- throw new Error('Missing chromePort; cannot reattach.');
32
+ logger('No running Chrome detected; reopening browser to locate the session.');
33
+ return recoverSession(runtime, config);
23
34
  }
24
35
  const host = runtime.chromeHost ?? '127.0.0.1';
25
- const listTargets = deps.listTargets ??
26
- (async () => {
27
- const targets = await CDP.List({ host, port: runtime.chromePort });
28
- return targets;
29
- });
30
- const connect = deps.connect ?? ((options) => CDP(options));
31
- const targetList = (await listTargets());
32
- const target = pickTarget(targetList, runtime);
33
- const client = (await connect({
34
- host,
35
- port: runtime.chromePort,
36
- target: target?.targetId,
37
- }));
38
- const { Runtime, DOM } = client;
36
+ try {
37
+ const listTargets = deps.listTargets ??
38
+ (async () => {
39
+ const targets = await CDP.List({ host, port: runtime.chromePort });
40
+ return targets;
41
+ });
42
+ const connect = deps.connect ?? ((options) => CDP(options));
43
+ const targetList = (await listTargets());
44
+ const target = pickTarget(targetList, runtime);
45
+ const client = (await connect({
46
+ host,
47
+ port: runtime.chromePort,
48
+ target: target?.targetId,
49
+ }));
50
+ const { Runtime, DOM } = client;
51
+ if (Runtime?.enable) {
52
+ await Runtime.enable();
53
+ }
54
+ if (DOM && typeof DOM.enable === 'function') {
55
+ await DOM.enable();
56
+ }
57
+ const waitForResponse = deps.waitForAssistantResponse ?? waitForAssistantResponse;
58
+ const captureMarkdown = deps.captureAssistantMarkdown ?? captureAssistantMarkdown;
59
+ const timeoutMs = config?.timeoutMs ?? 120_000;
60
+ const answer = await waitForResponse(Runtime, timeoutMs, logger);
61
+ const markdown = (await captureMarkdown(Runtime, answer.meta, logger)) ?? answer.text;
62
+ if (client && typeof client.close === 'function') {
63
+ try {
64
+ await client.close();
65
+ }
66
+ catch {
67
+ // ignore
68
+ }
69
+ }
70
+ return { answerText: answer.text, answerMarkdown: markdown };
71
+ }
72
+ catch (error) {
73
+ const message = error instanceof Error ? error.message : String(error);
74
+ logger(`Existing Chrome reattach failed (${message}); reopening browser to locate the session.`);
75
+ return recoverSession(runtime, config);
76
+ }
77
+ }
78
+ async function resumeBrowserSessionViaNewChrome(runtime, config, logger, deps) {
79
+ const resolved = resolveBrowserConfig(config ?? {});
80
+ const manualLogin = Boolean(resolved.manualLogin);
81
+ const userDataDir = manualLogin
82
+ ? resolved.manualLoginProfileDir ?? path.join(os.homedir(), '.oracle', 'browser-profile')
83
+ : await mkdtemp(path.join(os.tmpdir(), 'oracle-reattach-'));
84
+ if (manualLogin) {
85
+ await mkdir(userDataDir, { recursive: true });
86
+ }
87
+ const chrome = await launchChrome(resolved, userDataDir, logger);
88
+ const chromeHost = chrome.host ?? '127.0.0.1';
89
+ const client = await connectToChrome(chrome.port, logger, chromeHost);
90
+ const { Network, Page, Runtime, DOM } = client;
39
91
  if (Runtime?.enable) {
40
92
  await Runtime.enable();
41
93
  }
42
94
  if (DOM && typeof DOM.enable === 'function') {
43
95
  await DOM.enable();
44
96
  }
97
+ if (!resolved.headless && resolved.hideWindow) {
98
+ await hideChromeWindow(chrome, logger);
99
+ }
100
+ let appliedCookies = 0;
101
+ if (!manualLogin && resolved.cookieSync) {
102
+ appliedCookies = await syncCookies(Network, resolved.url, resolved.chromeProfile, logger, {
103
+ allowErrors: resolved.allowCookieErrors,
104
+ filterNames: resolved.cookieNames ?? undefined,
105
+ inlineCookies: resolved.inlineCookies ?? undefined,
106
+ cookiePath: resolved.chromeCookiePath ?? undefined,
107
+ });
108
+ }
109
+ await navigateToChatGPT(Page, Runtime, CHATGPT_URL, logger);
110
+ await ensureNotBlocked(Runtime, resolved.headless, logger);
111
+ await ensureLoggedIn(Runtime, logger, { appliedCookies });
112
+ if (resolved.url !== CHATGPT_URL) {
113
+ await navigateToChatGPT(Page, Runtime, resolved.url, logger);
114
+ await ensureNotBlocked(Runtime, resolved.headless, logger);
115
+ }
116
+ await ensurePromptReady(Runtime, resolved.inputTimeoutMs, logger);
117
+ const conversationUrl = buildConversationUrl(runtime, resolved.url);
118
+ if (conversationUrl) {
119
+ logger(`Reopening conversation at ${conversationUrl}`);
120
+ await navigateToChatGPT(Page, Runtime, conversationUrl, logger);
121
+ await ensureNotBlocked(Runtime, resolved.headless, logger);
122
+ await ensurePromptReady(Runtime, resolved.inputTimeoutMs, logger);
123
+ }
124
+ else {
125
+ const opened = await openConversationFromSidebar(Runtime, {
126
+ conversationId: runtime.conversationId ?? extractConversationIdFromUrl(runtime.tabUrl ?? ''),
127
+ preferProjects: resolved.url !== CHATGPT_URL,
128
+ });
129
+ if (!opened) {
130
+ throw new Error('Unable to locate prior ChatGPT conversation in sidebar.');
131
+ }
132
+ await waitForLocationChange(Runtime, 15_000);
133
+ }
45
134
  const waitForResponse = deps.waitForAssistantResponse ?? waitForAssistantResponse;
46
135
  const captureMarkdown = deps.captureAssistantMarkdown ?? captureAssistantMarkdown;
47
- const timeoutMs = config?.timeoutMs ?? 120_000;
136
+ const timeoutMs = resolved.timeoutMs ?? 120_000;
48
137
  const answer = await waitForResponse(Runtime, timeoutMs, logger);
49
138
  const markdown = (await captureMarkdown(Runtime, answer.meta, logger)) ?? answer.text;
50
139
  if (client && typeof client.close === 'function') {
@@ -55,5 +144,91 @@ export async function resumeBrowserSession(runtime, config, logger, deps = {}) {
55
144
  // ignore
56
145
  }
57
146
  }
147
+ if (!resolved.keepBrowser && !manualLogin) {
148
+ try {
149
+ await chrome.kill();
150
+ }
151
+ catch {
152
+ // ignore
153
+ }
154
+ await rm(userDataDir, { recursive: true, force: true }).catch(() => undefined);
155
+ }
58
156
  return { answerText: answer.text, answerMarkdown: markdown };
59
157
  }
158
+ function extractConversationIdFromUrl(url) {
159
+ if (!url)
160
+ return undefined;
161
+ const match = url.match(/\/c\/([a-zA-Z0-9-]+)/);
162
+ return match?.[1];
163
+ }
164
+ function buildConversationUrl(runtime, baseUrl) {
165
+ if (runtime.tabUrl) {
166
+ return runtime.tabUrl;
167
+ }
168
+ const conversationId = runtime.conversationId;
169
+ if (!conversationId) {
170
+ return null;
171
+ }
172
+ try {
173
+ const base = new URL(baseUrl);
174
+ return `${base.origin}/c/${conversationId}`;
175
+ }
176
+ catch {
177
+ return null;
178
+ }
179
+ }
180
+ async function openConversationFromSidebar(Runtime, options) {
181
+ const response = await Runtime.evaluate({
182
+ expression: `(() => {
183
+ const conversationId = ${JSON.stringify(options.conversationId ?? null)};
184
+ const preferProjects = ${JSON.stringify(Boolean(options.preferProjects))};
185
+ const nav = document.querySelector('nav') || document.querySelector('aside') || document.body;
186
+ if (preferProjects) {
187
+ const projectLink = Array.from(nav.querySelectorAll('a,button'))
188
+ .find((el) => (el.textContent || '').trim().toLowerCase() === 'projects');
189
+ if (projectLink) {
190
+ projectLink.click();
191
+ }
192
+ }
193
+ const links = Array.from(nav.querySelectorAll('a[href]'))
194
+ .filter((el) => el instanceof HTMLAnchorElement)
195
+ .map((el) => el);
196
+ const convoLinks = links.filter((el) => el.href.includes('/c/'));
197
+ let target = null;
198
+ if (conversationId) {
199
+ target = convoLinks.find((el) => el.href.includes('/c/' + conversationId));
200
+ }
201
+ if (!target && convoLinks.length > 0) {
202
+ target = convoLinks[0];
203
+ }
204
+ if (target) {
205
+ target.scrollIntoView({ block: 'center' });
206
+ target.click();
207
+ return { ok: true, href: target.href, count: convoLinks.length };
208
+ }
209
+ return { ok: false, count: convoLinks.length };
210
+ })()`,
211
+ returnByValue: true,
212
+ });
213
+ return Boolean(response.result?.value?.ok);
214
+ }
215
+ async function waitForLocationChange(Runtime, timeoutMs) {
216
+ const start = Date.now();
217
+ let lastHref = '';
218
+ while (Date.now() - start < timeoutMs) {
219
+ const { result } = await Runtime.evaluate({ expression: 'location.href', returnByValue: true });
220
+ const href = typeof result?.value === 'string' ? result.value : '';
221
+ if (lastHref && href !== lastHref) {
222
+ return;
223
+ }
224
+ lastHref = href;
225
+ await delay(200);
226
+ }
227
+ }
228
+ // biome-ignore lint/style/useNamingConvention: test-only export used in vitest suite
229
+ export const __test__ = {
230
+ pickTarget,
231
+ extractConversationIdFromUrl,
232
+ buildConversationUrl,
233
+ openConversationFromSidebar,
234
+ };
@@ -110,3 +110,13 @@ export function normalizeChatgptUrl(raw, fallback) {
110
110
  // Preserve user-provided path/query; URL#toString will normalize trailing slashes appropriately.
111
111
  return parsed.toString();
112
112
  }
113
+ export function isTemporaryChatUrl(url) {
114
+ try {
115
+ const parsed = new URL(url);
116
+ const value = (parsed.searchParams.get('temporary-chat') ?? '').trim().toLowerCase();
117
+ return value === 'true' || value === '1' || value === 'yes';
118
+ }
119
+ catch {
120
+ return false;
121
+ }
122
+ }
@@ -1 +1 @@
1
- export { runBrowserMode, CHATGPT_URL, DEFAULT_MODEL_TARGET, parseDuration, normalizeChatgptUrl, } from './browser/index.js';
1
+ export { runBrowserMode, CHATGPT_URL, DEFAULT_MODEL_TARGET, parseDuration, normalizeChatgptUrl, isTemporaryChatUrl, } from './browser/index.js';
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import { CHATGPT_URL, DEFAULT_MODEL_TARGET, normalizeChatgptUrl, parseDuration } from '../browserMode.js';
3
+ import { CHATGPT_URL, DEFAULT_MODEL_TARGET, isTemporaryChatUrl, normalizeChatgptUrl, parseDuration } from '../browserMode.js';
4
4
  import { getOracleHomeDir } from '../oracleHome.js';
5
5
  const DEFAULT_BROWSER_TIMEOUT_MS = 1_200_000;
6
6
  const DEFAULT_BROWSER_INPUT_TIMEOUT_MS = 30_000;
@@ -54,6 +54,15 @@ export async function buildBrowserConfig(options) {
54
54
  }
55
55
  const rawUrl = options.chatgptUrl ?? options.browserUrl;
56
56
  const url = rawUrl ? normalizeChatgptUrl(rawUrl, CHATGPT_URL) : undefined;
57
+ const desiredModel = isChatGptModel
58
+ ? mapModelToBrowserLabel(options.model)
59
+ : shouldUseOverride
60
+ ? desiredModelOverride
61
+ : mapModelToBrowserLabel(options.model);
62
+ if (url && isTemporaryChatUrl(url) && /\bpro\b/i.test(desiredModel ?? '')) {
63
+ throw new Error('Temporary Chat mode does not expose Pro models in the ChatGPT model picker. ' +
64
+ 'Remove "temporary-chat=true" from --chatgpt-url (or omit --chatgpt-url), or use a non-Pro model (e.g. --model gpt-5.2).');
65
+ }
57
66
  return {
58
67
  chromeProfile: options.browserChromeProfile ?? DEFAULT_CHROME_PROFILE,
59
68
  chromePath: options.browserChromePath ?? null,
@@ -72,11 +81,7 @@ export async function buildBrowserConfig(options) {
72
81
  keepBrowser: options.browserKeepBrowser ? true : undefined,
73
82
  manualLogin: options.browserManualLogin ? true : undefined,
74
83
  hideWindow: options.browserHideWindow ? true : undefined,
75
- desiredModel: isChatGptModel
76
- ? mapModelToBrowserLabel(options.model)
77
- : shouldUseOverride
78
- ? desiredModelOverride
79
- : mapModelToBrowserLabel(options.model),
84
+ desiredModel,
80
85
  debug: options.verbose ? true : undefined,
81
86
  // Allow cookie failures by default so runs can continue without Chrome/Keychain secrets.
82
87
  allowCookieErrors: options.browserAllowCookieErrors ?? true,
@@ -2,6 +2,7 @@ import notifier from 'toasted-notifier';
2
2
  import { spawn } from 'node:child_process';
3
3
  import { formatUSD, formatNumber } from '../oracle/format.js';
4
4
  import { MODEL_CONFIGS } from '../oracle/config.js';
5
+ import { estimateUsdCost } from 'tokentally';
5
6
  import fs from 'node:fs/promises';
6
7
  import path from 'node:path';
7
8
  import { createRequire } from 'node:module';
@@ -131,8 +132,13 @@ function inferCost(payload) {
131
132
  const config = MODEL_CONFIGS[model];
132
133
  if (!config?.pricing)
133
134
  return undefined;
134
- return (usage.inputTokens * config.pricing.inputPerToken +
135
- usage.outputTokens * config.pricing.outputPerToken);
135
+ return (estimateUsdCost({
136
+ usage: { inputTokens: usage.inputTokens, outputTokens: usage.outputTokens },
137
+ pricing: {
138
+ inputUsdPerToken: config.pricing.inputPerToken,
139
+ outputUsdPerToken: config.pricing.outputPerToken,
140
+ },
141
+ })?.totalUsd ?? undefined);
136
142
  }
137
143
  function parseToggle(value) {
138
144
  if (value == null)
@@ -1,20 +1,2 @@
1
1
  // Utilities for handling OSC progress codes embedded in stored logs.
2
- const OSC_PROGRESS_PREFIX = '\u001b]9;4;';
3
- const OSC_END = '\u001b\\';
4
- /**
5
- * Optionally removes OSC 9;4 progress sequences (used by Ghostty/WezTerm to show progress bars).
6
- * Keep them when replaying to a real TTY; strip when piping to non-TTY outputs.
7
- */
8
- export function sanitizeOscProgress(text, keepOsc) {
9
- if (keepOsc) {
10
- return text;
11
- }
12
- let current = text;
13
- while (current.includes(OSC_PROGRESS_PREFIX)) {
14
- const start = current.indexOf(OSC_PROGRESS_PREFIX);
15
- const end = current.indexOf(OSC_END, start + OSC_PROGRESS_PREFIX.length);
16
- const cutEnd = end === -1 ? start + OSC_PROGRESS_PREFIX.length : end + OSC_END.length;
17
- current = `${current.slice(0, start)}${current.slice(cutEnd)}`;
18
- }
19
- return current;
20
- }
2
+ export { sanitizeOscProgress } from 'osc-progress';
@@ -76,10 +76,13 @@ export async function attachSession(sessionId, options) {
76
76
  const isVerbose = Boolean(process.env.ORACLE_VERBOSE_RENDER);
77
77
  const runtime = metadata.browser?.runtime;
78
78
  const controllerAlive = isProcessAlive(runtime?.controllerPid);
79
- const canReattach = metadata.status === 'running' &&
79
+ const hasChromeDisconnect = metadata.response?.incompleteReason === 'chrome-disconnected';
80
+ const statusAllowsReattach = metadata.status === 'running' || (metadata.status === 'error' && hasChromeDisconnect);
81
+ const hasFallbackSessionInfo = Boolean(runtime?.chromePort || runtime?.tabUrl || runtime?.conversationId);
82
+ const canReattach = statusAllowsReattach &&
80
83
  metadata.mode === 'browser' &&
81
- runtime?.chromePort &&
82
- (metadata.response?.incompleteReason === 'chrome-disconnected' || (runtime.controllerPid && !controllerAlive));
84
+ hasFallbackSessionInfo &&
85
+ (hasChromeDisconnect || (runtime?.controllerPid && !controllerAlive));
83
86
  if (canReattach) {
84
87
  const portInfo = runtime?.chromePort ? `port ${runtime.chromePort}` : 'unknown port';
85
88
  const urlInfo = runtime?.tabUrl ? `url=${runtime.tabUrl}` : 'url=unknown';
@@ -1,6 +1,7 @@
1
1
  import chalk from 'chalk';
2
2
  import kleur from 'kleur';
3
3
  import { MODEL_CONFIGS } from '../oracle.js';
4
+ import { estimateUsdCost } from 'tokentally';
4
5
  const isRich = (rich) => rich ?? Boolean(process.stdout.isTTY && chalk.level > 0);
5
6
  const dim = (text, rich) => (rich ? kleur.dim(text) : text);
6
7
  export const STATUS_PAD = 9;
@@ -48,7 +49,10 @@ export function resolveSessionCost(meta) {
48
49
  }
49
50
  const input = meta.usage.inputTokens ?? 0;
50
51
  const output = meta.usage.outputTokens ?? 0;
51
- const cost = input * pricing.inputPerToken + output * pricing.outputPerToken;
52
+ const cost = estimateUsdCost({
53
+ usage: { inputTokens: input, outputTokens: output },
54
+ pricing: { inputUsdPerToken: pricing.inputPerToken, outputUsdPerToken: pricing.outputPerToken },
55
+ })?.totalUsd ?? 0;
52
56
  return cost > 0 ? cost : null;
53
57
  }
54
58
  export function formatTimestampAligned(iso) {
@@ -13,7 +13,14 @@ export async function readFiles(filePaths, { cwd = process.cwd(), fsModule = DEF
13
13
  const useNativeFilesystem = fsModule === DEFAULT_FS || isNativeFsModule(fsModule);
14
14
  let candidatePaths = [];
15
15
  if (useNativeFilesystem) {
16
- candidatePaths = await expandWithNativeGlob(partitioned, cwd);
16
+ if (partitioned.globPatterns.length === 0 &&
17
+ partitioned.excludePatterns.length === 0 &&
18
+ partitioned.literalDirectories.length === 0) {
19
+ candidatePaths = Array.from(new Set(partitioned.literalFiles));
20
+ }
21
+ else {
22
+ candidatePaths = await expandWithNativeGlob(partitioned, cwd);
23
+ }
17
24
  }
18
25
  else {
19
26
  if (partitioned.globPatterns.length > 0 || partitioned.excludePatterns.length > 0) {
@@ -1,5 +1,6 @@
1
1
  import { MODEL_CONFIGS, PRO_MODELS } from './config.js';
2
2
  import { countTokens as countTokensGpt5Pro } from 'gpt-tokenizer/model/gpt-5-pro';
3
+ import { pricingFromUsdPerMillion } from 'tokentally';
3
4
  const OPENROUTER_DEFAULT_BASE = 'https://openrouter.ai/api/v1';
4
5
  const OPENROUTER_MODELS_ENDPOINT = 'https://openrouter.ai/api/v1/models';
5
6
  export function isKnownModel(model) {
@@ -96,10 +97,16 @@ export async function resolveModelConfig(model, options = {}) {
96
97
  provider: known?.provider ?? 'other',
97
98
  inputLimit: info.context_length ?? known?.inputLimit ?? 200_000,
98
99
  pricing: info.pricing && info.pricing.prompt != null && info.pricing.completion != null
99
- ? {
100
- inputPerToken: info.pricing.prompt / 1_000_000,
101
- outputPerToken: info.pricing.completion / 1_000_000,
102
- }
100
+ ? (() => {
101
+ const pricing = pricingFromUsdPerMillion({
102
+ inputUsdPerMillion: info.pricing.prompt,
103
+ outputUsdPerMillion: info.pricing.completion,
104
+ });
105
+ return {
106
+ inputPerToken: pricing.inputUsdPerToken,
107
+ outputPerToken: pricing.outputUsdPerToken,
108
+ };
109
+ })()
103
110
  : known?.pricing ?? null,
104
111
  supportsBackground: known?.supportsBackground ?? true,
105
112
  supportsSearch: known?.supportsSearch ?? true,