@steipete/oracle 0.7.2 → 0.7.4

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.
@@ -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
+ };
@@ -5,17 +5,20 @@ 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;
7
7
  const DEFAULT_CHROME_PROFILE = 'Default';
8
- const BROWSER_MODEL_LABELS = {
9
- // Browser engine supports GPT-5.2 and GPT-5.2 Pro (legacy/Pro aliases normalize to those targets).
10
- 'gpt-5-pro': 'GPT-5.2 Pro',
11
- 'gpt-5.1-pro': 'GPT-5.2 Pro',
12
- 'gpt-5.1': 'GPT-5.2',
13
- 'gpt-5.2': 'GPT-5.2',
14
- // ChatGPT UI doesn't expose "instant" as a separate picker option; treat it as GPT-5.2 for browser automation.
15
- 'gpt-5.2-instant': 'GPT-5.2',
16
- 'gpt-5.2-pro': 'GPT-5.2 Pro',
17
- 'gemini-3-pro': 'Gemini 3 Pro',
18
- };
8
+ // Ordered array: most specific models first to ensure correct selection.
9
+ // The browser label is passed to the model picker which fuzzy-matches against ChatGPT's UI.
10
+ const BROWSER_MODEL_LABELS = [
11
+ // Most specific first (e.g., "gpt-5.2-thinking" before "gpt-5.2")
12
+ ['gpt-5.2-thinking', 'GPT-5.2 Thinking'],
13
+ ['gpt-5.2-instant', 'GPT-5.2 Instant'],
14
+ ['gpt-5.2-pro', 'GPT-5.2 Pro'],
15
+ ['gpt-5.1-pro', 'GPT-5.2 Pro'],
16
+ ['gpt-5-pro', 'GPT-5.2 Pro'],
17
+ // Base models last (least specific)
18
+ ['gpt-5.2', 'GPT-5.2'], // Selects "Auto" in ChatGPT UI
19
+ ['gpt-5.1', 'GPT-5.2'], // Legacy alias → Auto
20
+ ['gemini-3-pro', 'Gemini 3 Pro'],
21
+ ];
19
22
  export function normalizeChatGptModelForBrowser(model) {
20
23
  const normalized = model.toLowerCase();
21
24
  if (!normalized.startsWith('gpt-') || normalized.includes('codex')) {
@@ -25,10 +28,11 @@ export function normalizeChatGptModelForBrowser(model) {
25
28
  if (normalized === 'gpt-5-pro' || normalized === 'gpt-5.1-pro' || normalized.endsWith('-pro')) {
26
29
  return 'gpt-5.2-pro';
27
30
  }
28
- // Legacy / UI-mismatch variants: map to the closest ChatGPT picker target.
29
- if (normalized === 'gpt-5.2-instant') {
30
- return 'gpt-5.2';
31
+ // Explicit model variants: keep as-is (they have their own browser labels)
32
+ if (normalized === 'gpt-5.2-thinking' || normalized === 'gpt-5.2-instant') {
33
+ return normalized;
31
34
  }
35
+ // Legacy aliases: map to base GPT-5.2 (Auto)
32
36
  if (normalized === 'gpt-5.1') {
33
37
  return 'gpt-5.2';
34
38
  }
@@ -86,7 +90,7 @@ export async function buildBrowserConfig(options) {
86
90
  // Allow cookie failures by default so runs can continue without Chrome/Keychain secrets.
87
91
  allowCookieErrors: options.browserAllowCookieErrors ?? true,
88
92
  remoteChrome,
89
- extendedThinking: options.browserExtendedThinking ? true : undefined,
93
+ thinkingTime: options.browserThinkingTime,
90
94
  };
91
95
  }
92
96
  function selectBrowserPort(options) {
@@ -100,7 +104,13 @@ function selectBrowserPort(options) {
100
104
  }
101
105
  export function mapModelToBrowserLabel(model) {
102
106
  const normalized = normalizeChatGptModelForBrowser(model);
103
- return BROWSER_MODEL_LABELS[normalized] ?? DEFAULT_MODEL_TARGET;
107
+ // Iterate ordered array to find first match (most specific first)
108
+ for (const [key, label] of BROWSER_MODEL_LABELS) {
109
+ if (key === normalized) {
110
+ return label;
111
+ }
112
+ }
113
+ return DEFAULT_MODEL_TARGET;
104
114
  }
105
115
  export function resolveBrowserModelLabel(input, model) {
106
116
  const trimmed = input?.trim?.() ?? '';
@@ -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)
@@ -187,6 +187,13 @@ export function inferModelFromLabel(modelValue) {
187
187
  if ((normalized.includes('5.2') || normalized.includes('5_2')) && normalized.includes('pro')) {
188
188
  return 'gpt-5.2-pro';
189
189
  }
190
+ // Browser-only: pass through 5.2 thinking/instant variants for browser label mapping
191
+ if ((normalized.includes('5.2') || normalized.includes('5_2')) && normalized.includes('thinking')) {
192
+ return 'gpt-5.2-thinking';
193
+ }
194
+ if ((normalized.includes('5.2') || normalized.includes('5_2')) && normalized.includes('instant')) {
195
+ return 'gpt-5.2-instant';
196
+ }
190
197
  if (normalized.includes('5.0') || normalized.includes('5-pro')) {
191
198
  return 'gpt-5-pro';
192
199
  }
@@ -205,8 +212,11 @@ export function inferModelFromLabel(modelValue) {
205
212
  if (normalized.includes('5.1') || normalized.includes('5_1')) {
206
213
  return 'gpt-5.1';
207
214
  }
208
- if (normalized.includes('instant') || normalized.includes('thinking') || normalized.includes('fast')) {
209
- return 'gpt-5.1';
215
+ if (normalized.includes('thinking')) {
216
+ return 'gpt-5.2-thinking';
217
+ }
218
+ if (normalized.includes('instant') || normalized.includes('fast')) {
219
+ return 'gpt-5.2-instant';
210
220
  }
211
- return 'gpt-5.1';
221
+ return 'gpt-5.2';
212
222
  }
@@ -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,
@@ -2,24 +2,13 @@ import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { runOracle, OracleResponseError, OracleTransportError, extractResponseMetadata, asOracleUserError, extractTextOutput, } from '../oracle.js';
4
4
  import { sessionStore } from '../sessionStore.js';
5
- const OSC_PROGRESS_PREFIX = '\u001b]9;4;';
6
- const OSC_PROGRESS_END = '\u001b\\';
5
+ import { findOscProgressSequences, OSC_PROGRESS_PREFIX } from 'osc-progress';
7
6
  function forwardOscProgress(chunk, shouldForward) {
8
7
  if (!shouldForward || !chunk.includes(OSC_PROGRESS_PREFIX)) {
9
8
  return;
10
9
  }
11
- let searchFrom = 0;
12
- while (searchFrom < chunk.length) {
13
- const start = chunk.indexOf(OSC_PROGRESS_PREFIX, searchFrom);
14
- if (start === -1) {
15
- break;
16
- }
17
- const end = chunk.indexOf(OSC_PROGRESS_END, start + OSC_PROGRESS_PREFIX.length);
18
- if (end === -1) {
19
- break;
20
- }
21
- process.stdout.write(chunk.slice(start, end + OSC_PROGRESS_END.length));
22
- searchFrom = end + OSC_PROGRESS_END.length;
10
+ for (const seq of findOscProgressSequences(chunk)) {
11
+ process.stdout.write(seq.raw);
23
12
  }
24
13
  }
25
14
  const defaultDeps = {
@@ -1,66 +1,17 @@
1
1
  import process from 'node:process';
2
- const OSC = '\u001b]9;4;';
3
- const ST = '\u001b\\';
4
- function sanitizeLabel(label) {
5
- const withoutEscape = label.split('\u001b').join('');
6
- const withoutBellAndSt = withoutEscape.replaceAll('\u0007', '').replaceAll('\u009c', '');
7
- return withoutBellAndSt.replaceAll(']', '').trim();
8
- }
2
+ import { startOscProgress as startOscProgressShared, supportsOscProgress as supportsOscProgressShared, } from 'osc-progress';
9
3
  export function supportsOscProgress(env = process.env, isTty = process.stdout.isTTY) {
10
- if (!isTty) {
11
- return false;
12
- }
13
- if (env.ORACLE_NO_OSC_PROGRESS === '1') {
14
- return false;
15
- }
16
- if (env.ORACLE_FORCE_OSC_PROGRESS === '1') {
17
- return true;
18
- }
19
- const termProgram = (env.TERM_PROGRAM ?? '').toLowerCase();
20
- if (termProgram.includes('ghostty')) {
21
- return true;
22
- }
23
- if (termProgram.includes('wezterm')) {
24
- return true;
25
- }
26
- if (env.WT_SESSION) {
27
- return true; // Windows Terminal exposes this
28
- }
29
- return false;
4
+ return supportsOscProgressShared(env, isTty, {
5
+ disableEnvVar: 'ORACLE_NO_OSC_PROGRESS',
6
+ forceEnvVar: 'ORACLE_FORCE_OSC_PROGRESS',
7
+ });
30
8
  }
31
9
  export function startOscProgress(options = {}) {
32
- const { label = 'Waiting for API', targetMs = 10 * 60_000, write = (text) => process.stdout.write(text), indeterminate = false, } = options;
33
- if (!supportsOscProgress(options.env, options.isTty)) {
34
- return () => { };
35
- }
36
- const cleanLabel = sanitizeLabel(label);
37
- if (indeterminate) {
38
- write(`${OSC}3;;${cleanLabel}${ST}`);
39
- return () => {
40
- write(`${OSC}0;0;${cleanLabel}${ST}`);
41
- };
42
- }
43
- const target = Math.max(targetMs, 1_000);
44
- const send = (state, percent) => {
45
- const clamped = Math.max(0, Math.min(100, Math.round(percent)));
46
- write(`${OSC}${state};${clamped};${cleanLabel}${ST}`);
47
- };
48
- const startedAt = Date.now();
49
- send(1, 0); // activate progress bar
50
- const timer = setInterval(() => {
51
- const elapsed = Date.now() - startedAt;
52
- const percent = Math.min(99, (elapsed / target) * 100);
53
- send(1, percent);
54
- }, 900);
55
- timer.unref?.();
56
- let stopped = false;
57
- return () => {
58
- // multiple callers may try to stop
59
- if (stopped) {
60
- return;
61
- }
62
- stopped = true;
63
- clearInterval(timer);
64
- send(0, 0); // clear the progress bar
65
- };
10
+ return startOscProgressShared({
11
+ ...options,
12
+ // Preserve Oracle's previous default: progress emits to stdout.
13
+ write: options.write ?? ((text) => process.stdout.write(text)),
14
+ disableEnvVar: 'ORACLE_NO_OSC_PROGRESS',
15
+ forceEnvVar: 'ORACLE_FORCE_OSC_PROGRESS',
16
+ });
66
17
  }