@steipete/oracle 0.7.2 → 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.
@@ -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) {
@@ -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
  };
@@ -947,6 +949,10 @@ function isWsl() {
947
949
  return true;
948
950
  return os.release().toLowerCase().includes('microsoft');
949
951
  }
952
+ function extractConversationIdFromUrl(url) {
953
+ const match = url.match(/\/c\/([a-zA-Z0-9-]+)/);
954
+ return match?.[1];
955
+ }
950
956
  async function resolveUserDataBaseDir() {
951
957
  // On WSL, Chrome launched via Windows can choke on UNC paths; prefer a Windows-backed temp folder.
952
958
  if (isWsl()) {
@@ -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
+ };
@@ -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,
@@ -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
  }
@@ -19,8 +19,10 @@ import { createFsAdapter } from './fsAdapter.js';
19
19
  import { resolveGeminiModelId } from './gemini.js';
20
20
  import { resolveClaudeModelId } from './claude.js';
21
21
  import { renderMarkdownAnsi } from '../cli/markdownRenderer.js';
22
+ import { createLiveRenderer } from 'markdansi';
22
23
  import { executeBackgroundResponse } from './background.js';
23
24
  import { formatTokenEstimate, formatTokenValue, resolvePreviewMode } from './runUtils.js';
25
+ import { estimateUsdCost } from 'tokentally';
24
26
  import { defaultOpenRouterBaseUrl, isKnownModel, isOpenRouterBaseUrl, isProModel, resolveModelConfig, normalizeOpenRouterBaseUrl, } from './modelResolver.js';
25
27
  const isStdoutTty = process.stdout.isTTY && chalk.level > 0;
26
28
  const dim = (text) => (isStdoutTty ? kleur.dim(text) : text);
@@ -309,8 +311,8 @@ export async function runOracle(options, deps = {}) {
309
311
  let response = null;
310
312
  let elapsedMs = 0;
311
313
  let sawTextDelta = false;
312
- // Buffer streamed text so we can re-render markdown once the stream ends (TTY + non-plain mode).
313
- const streamedChunks = [];
314
+ let streamedText = '';
315
+ let lastLiveFrameAtMs = 0;
314
316
  let answerHeaderPrinted = false;
315
317
  const allowAnswerHeader = options.suppressAnswerHeader !== true;
316
318
  const timeoutExceeded = () => now() - runStart >= timeoutMs;
@@ -378,31 +380,52 @@ export async function runOracle(options, deps = {}) {
378
380
  },
379
381
  });
380
382
  }
383
+ let liveRenderer = null;
381
384
  try {
385
+ liveRenderer =
386
+ isTty && !renderPlain
387
+ ? createLiveRenderer({
388
+ write: stdoutWrite,
389
+ width: process.stdout.columns ?? 80,
390
+ renderFrame: renderMarkdownAnsi,
391
+ })
392
+ : null;
382
393
  for await (const event of stream) {
383
394
  throwIfTimedOut();
384
395
  const isTextDelta = event.type === 'chunk' || event.type === 'response.output_text.delta';
385
- if (isTextDelta) {
386
- stopOscProgress();
387
- stopHeartbeatNow();
388
- sawTextDelta = true;
389
- ensureAnswerHeader();
390
- if (!options.silent && typeof event.delta === 'string') {
391
- // Always keep the log/bookkeeping sink up to date.
392
- sinkWrite(event.delta);
393
- if (renderPlain) {
394
- // Plain mode: stream directly to stdout regardless of write sink.
395
- stdoutWrite(event.delta);
396
- }
397
- else if (isTty) {
398
- // Buffer for end-of-stream markdown rendering on TTY.
399
- streamedChunks.push(event.delta);
400
- }
401
- else {
402
- // Non-TTY streams should still surface output; fall back to raw stdout.
403
- stdoutWrite(event.delta);
404
- }
396
+ if (!isTextDelta)
397
+ continue;
398
+ stopOscProgress();
399
+ stopHeartbeatNow();
400
+ sawTextDelta = true;
401
+ ensureAnswerHeader();
402
+ if (options.silent || typeof event.delta !== 'string')
403
+ continue;
404
+ // Always keep the log/bookkeeping sink up to date.
405
+ sinkWrite(event.delta);
406
+ if (renderPlain) {
407
+ // Plain mode: stream directly to stdout regardless of write sink.
408
+ stdoutWrite(event.delta);
409
+ continue;
410
+ }
411
+ if (liveRenderer) {
412
+ streamedText += event.delta;
413
+ const currentMs = now();
414
+ const due = currentMs - lastLiveFrameAtMs >= 120;
415
+ const hasNewline = event.delta.includes('\n');
416
+ if (hasNewline || due) {
417
+ liveRenderer.render(streamedText);
418
+ lastLiveFrameAtMs = currentMs;
405
419
  }
420
+ continue;
421
+ }
422
+ // Non-TTY streams should still surface output; fall back to raw stdout.
423
+ stdoutWrite(event.delta);
424
+ }
425
+ if (liveRenderer) {
426
+ streamedText = streamedText.trim();
427
+ if (streamedText.length > 0) {
428
+ liveRenderer.render(streamedText);
406
429
  }
407
430
  }
408
431
  throwIfTimedOut();
@@ -414,6 +437,9 @@ export async function runOracle(options, deps = {}) {
414
437
  log(chalk.yellow(describeTransportError(transportError, timeoutMs)));
415
438
  throw transportError;
416
439
  }
440
+ finally {
441
+ liveRenderer?.finish();
442
+ }
417
443
  response = await stream.finalResponse();
418
444
  throwIfTimedOut();
419
445
  stopHeartbeatNow();
@@ -428,21 +454,17 @@ export async function runOracle(options, deps = {}) {
428
454
  }
429
455
  // We only add spacing when streamed text was printed.
430
456
  if (sawTextDelta && !options.silent) {
431
- const fullStreamedText = streamedChunks.join('');
432
- const shouldRenderAfterStream = isTty && !renderPlain && fullStreamedText.length > 0;
433
- if (shouldRenderAfterStream) {
434
- const rendered = renderMarkdownAnsi(fullStreamedText);
435
- stdoutWrite(rendered);
436
- if (!rendered.endsWith('\n')) {
437
- stdoutWrite('\n');
438
- }
439
- log('');
440
- }
441
- else if (renderPlain) {
457
+ const shouldRenderAfterStream = isTty && !renderPlain && streamedText.length > 0;
458
+ if (renderPlain) {
442
459
  // Plain streaming already wrote chunks; ensure clean separation.
443
460
  stdoutWrite('\n');
444
461
  }
462
+ else if (!shouldRenderAfterStream) {
463
+ // Non-TTY streams should still surface output; ensure separation.
464
+ log('');
465
+ }
445
466
  else {
467
+ // Live-rendered mode already drew the final frame; only separate from logs.
446
468
  log('');
447
469
  }
448
470
  }
@@ -503,7 +525,10 @@ export async function runOracle(options, deps = {}) {
503
525
  const totalTokens = usage.total_tokens ?? inputTokens + outputTokens + reasoningTokens;
504
526
  const pricing = modelConfig.pricing ?? undefined;
505
527
  const cost = pricing
506
- ? inputTokens * pricing.inputPerToken + outputTokens * pricing.outputPerToken
528
+ ? estimateUsdCost({
529
+ usage: { inputTokens, outputTokens, reasoningTokens, totalTokens },
530
+ pricing: { inputUsdPerToken: pricing.inputPerToken, outputUsdPerToken: pricing.outputPerToken },
531
+ })?.totalUsd
507
532
  : undefined;
508
533
  const elapsedDisplay = formatElapsed(elapsedMs);
509
534
  const statsParts = [];
@@ -541,6 +566,9 @@ export async function runOracle(options, deps = {}) {
541
566
  statsParts.push(`files=${files.length}`);
542
567
  }
543
568
  const sessionPrefix = options.sessionId ? `${options.sessionId} ` : '';
569
+ if (!options.silent) {
570
+ log('');
571
+ }
544
572
  log(chalk.blue(`Finished ${sessionPrefix}in ${elapsedDisplay} (${statsParts.join(' | ')})`));
545
573
  return {
546
574
  mode: 'live',
@@ -1,6 +1,7 @@
1
1
  import path from 'node:path';
2
2
  import fs from 'node:fs/promises';
3
3
  import { createWriteStream } from 'node:fs';
4
+ import net from 'node:net';
4
5
  import { DEFAULT_MODEL } from './oracle.js';
5
6
  import { safeModelSlug } from './oracle/modelResolver.js';
6
7
  import { getOracleHomeDir } from './oracleHome.js';
@@ -15,6 +16,7 @@ const MODEL_JSON_EXTENSION = '.json';
15
16
  const MODEL_LOG_EXTENSION = '.log';
16
17
  const MAX_STATUS_LIMIT = 1000;
17
18
  const ZOMBIE_MAX_AGE_MS = 60 * 60 * 1000; // 60 minutes
19
+ const CHROME_RUNTIME_TIMEOUT_MS = 250;
18
20
  const DEFAULT_SLUG = 'session';
19
21
  const MAX_SLUG_WORDS = 5;
20
22
  const MIN_CUSTOM_SLUG_WORDS = 3;
@@ -245,7 +247,8 @@ async function readModernSessionMetadata(sessionId) {
245
247
  return null;
246
248
  }
247
249
  const enriched = await attachModelRuns(parsed, sessionId);
248
- return await markZombie(enriched, { persist: false });
250
+ const runtimeChecked = await markDeadBrowser(enriched, { persist: false });
251
+ return await markZombie(runtimeChecked, { persist: false });
249
252
  }
250
253
  catch {
251
254
  return null;
@@ -256,7 +259,8 @@ async function readLegacySessionMetadata(sessionId) {
256
259
  const raw = await fs.readFile(legacySessionPath(sessionId), 'utf8');
257
260
  const parsed = JSON.parse(raw);
258
261
  const enriched = await attachModelRuns(parsed, sessionId);
259
- return await markZombie(enriched, { persist: false });
262
+ const runtimeChecked = await markDeadBrowser(enriched, { persist: false });
263
+ return await markZombie(runtimeChecked, { persist: false });
260
264
  }
261
265
  catch {
262
266
  return null;
@@ -294,6 +298,7 @@ export async function listSessionsMetadata() {
294
298
  for (const entry of entries) {
295
299
  let meta = await readSessionMetadata(entry);
296
300
  if (meta) {
301
+ meta = await markDeadBrowser(meta, { persist: true });
297
302
  meta = await markZombie(meta, { persist: true }); // keep stored metadata consistent with zombie detection
298
303
  metas.push(meta);
299
304
  }
@@ -451,6 +456,44 @@ async function markZombie(meta, { persist }) {
451
456
  }
452
457
  return updated;
453
458
  }
459
+ async function markDeadBrowser(meta, { persist }) {
460
+ if (meta.status !== 'running' || meta.mode !== 'browser') {
461
+ return meta;
462
+ }
463
+ const runtime = meta.browser?.runtime;
464
+ if (!runtime) {
465
+ return meta;
466
+ }
467
+ const signals = [];
468
+ if (runtime.chromePid) {
469
+ signals.push(isProcessAlive(runtime.chromePid));
470
+ }
471
+ if (runtime.chromePort) {
472
+ const host = runtime.chromeHost ?? '127.0.0.1';
473
+ signals.push(await isPortOpen(host, runtime.chromePort));
474
+ }
475
+ if (signals.length === 0 || signals.some(Boolean)) {
476
+ return meta;
477
+ }
478
+ const response = meta.response
479
+ ? {
480
+ ...meta.response,
481
+ status: 'error',
482
+ incompleteReason: meta.response.incompleteReason ?? 'chrome-disconnected',
483
+ }
484
+ : { status: 'error', incompleteReason: 'chrome-disconnected' };
485
+ const updated = {
486
+ ...meta,
487
+ status: 'error',
488
+ errorMessage: 'Browser session ended (Chrome is no longer reachable)',
489
+ completedAt: new Date().toISOString(),
490
+ response,
491
+ };
492
+ if (persist) {
493
+ await fs.writeFile(metaPath(meta.id), JSON.stringify(updated, null, 2), 'utf8');
494
+ }
495
+ return updated;
496
+ }
454
497
  function isZombie(meta) {
455
498
  if (meta.status !== 'running') {
456
499
  return false;
@@ -465,3 +508,49 @@ function isZombie(meta) {
465
508
  }
466
509
  return Date.now() - startedMs > ZOMBIE_MAX_AGE_MS;
467
510
  }
511
+ function isProcessAlive(pid) {
512
+ if (!pid)
513
+ return false;
514
+ try {
515
+ process.kill(pid, 0);
516
+ return true;
517
+ }
518
+ catch (error) {
519
+ const code = error instanceof Error ? error.code : undefined;
520
+ if (code === 'ESRCH' || code === 'EINVAL') {
521
+ return false;
522
+ }
523
+ if (code === 'EPERM') {
524
+ return true;
525
+ }
526
+ return true;
527
+ }
528
+ }
529
+ async function isPortOpen(host, port) {
530
+ if (!port || port <= 0 || port > 65535) {
531
+ return false;
532
+ }
533
+ return new Promise((resolve) => {
534
+ const socket = net.createConnection({ host, port });
535
+ let settled = false;
536
+ const cleanup = (result) => {
537
+ if (settled)
538
+ return;
539
+ settled = true;
540
+ socket.removeAllListeners();
541
+ socket.end();
542
+ socket.destroy();
543
+ socket.unref();
544
+ resolve(result);
545
+ };
546
+ const timer = setTimeout(() => cleanup(false), CHROME_RUNTIME_TIMEOUT_MS);
547
+ socket.once('connect', () => {
548
+ clearTimeout(timer);
549
+ cleanup(true);
550
+ });
551
+ socket.once('error', () => {
552
+ clearTimeout(timer);
553
+ cleanup(false);
554
+ });
555
+ });
556
+ }
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steipete/oracle",
3
- "version": "0.7.2",
3
+ "version": "0.7.3",
4
4
  "description": "CLI wrapper around OpenAI Responses API with GPT-5.2 Pro (via gpt-5.1-pro alias), GPT-5.2, GPT-5.1, and GPT-5.1 Codex high reasoning modes.",
5
5
  "type": "module",
6
6
  "main": "dist/bin/oracle-cli.js",
@@ -8,6 +8,26 @@
8
8
  "oracle": "dist/bin/oracle-cli.js",
9
9
  "oracle-mcp": "dist/bin/oracle-mcp.js"
10
10
  },
11
+ "scripts": {
12
+ "docs:list": "tsx scripts/docs-list.ts",
13
+ "build": "tsc -p tsconfig.build.json && pnpm run build:vendor",
14
+ "build:vendor": "node -e \"const fs=require('fs'); const path=require('path'); const vendorRoot=path.join('dist','vendor'); fs.rmSync(vendorRoot,{recursive:true,force:true}); const vendors=[['oracle-notifier']]; vendors.forEach(([name])=>{const src=path.join('vendor',name); const dest=path.join(vendorRoot,name); fs.mkdirSync(dest,{recursive:true}); if(fs.existsSync(src)){fs.cpSync(src,dest,{recursive:true,force:true});}});\"",
15
+ "start": "pnpm run build && node ./dist/scripts/run-cli.js",
16
+ "oracle": "pnpm start",
17
+ "check": "pnpm run typecheck",
18
+ "typecheck": "tsc --noEmit",
19
+ "lint": "pnpm run typecheck && biome lint .",
20
+ "test": "vitest run",
21
+ "test:mcp": "pnpm run build && pnpm run test:mcp:unit && pnpm run test:mcp:mcporter",
22
+ "test:mcp:unit": "vitest run tests/mcp*.test.ts tests/mcp/**/*.test.ts",
23
+ "test:mcp:mcporter": "npx -y mcporter list oracle-local --schema --config config/mcporter.json && npx -y mcporter call oracle-local.sessions limit:1 --config config/mcporter.json",
24
+ "test:browser": "pnpm run build && tsx scripts/test-browser.ts && ./scripts/browser-smoke.sh",
25
+ "test:live": "ORACLE_LIVE_TEST=1 vitest run tests/live --exclude tests/live/openai-live.test.ts",
26
+ "test:pro": "ORACLE_LIVE_TEST=1 vitest run tests/live/openai-live.test.ts",
27
+ "test:coverage": "vitest run --coverage",
28
+ "prepare": "pnpm run build",
29
+ "mcp": "pnpm run build && node ./dist/bin/oracle-mcp.js"
30
+ },
11
31
  "files": [
12
32
  "dist/**/*",
13
33
  "assets-oracle-icon.png",
@@ -57,25 +77,27 @@
57
77
  "json5": "^2.2.3",
58
78
  "keytar": "^7.9.0",
59
79
  "kleur": "^4.1.5",
60
- "markdansi": "^0.1.3",
61
- "openai": "^6.14.0",
80
+ "markdansi": "0.1.7",
81
+ "openai": "^6.15.0",
82
+ "osc-progress": "^0.1.0",
62
83
  "shiki": "^3.20.0",
63
84
  "sqlite3": "^5.1.7",
64
85
  "toasted-notifier": "^10.1.0",
86
+ "tokentally": "github:steipete/tokentally#v0.1.0",
65
87
  "zod": "^4.2.1"
66
88
  },
67
89
  "devDependencies": {
68
90
  "@anthropic-ai/tokenizer": "^0.0.4",
69
- "@biomejs/biome": "^2.3.9",
91
+ "@biomejs/biome": "^2.3.10",
70
92
  "@cdktf/node-pty-prebuilt-multiarch": "0.10.2",
71
93
  "@types/chrome-remote-interface": "^0.33.0",
72
94
  "@types/inquirer": "^9.0.9",
73
95
  "@types/node": "^25.0.3",
74
96
  "@vitest/coverage-v8": "4.0.16",
75
- "devtools-protocol": "0.0.1559729",
97
+ "devtools-protocol": "0.0.1561482",
76
98
  "es-toolkit": "^1.43.0",
77
99
  "esbuild": "^0.27.2",
78
- "puppeteer-core": "^24.33.0",
100
+ "puppeteer-core": "^24.34.0",
79
101
  "tsx": "^4.21.0",
80
102
  "typescript": "^5.9.3",
81
103
  "vitest": "^4.0.16"
@@ -83,23 +105,18 @@
83
105
  "optionalDependencies": {
84
106
  "win-dpapi": "npm:@primno/dpapi@2.0.1"
85
107
  },
86
- "scripts": {
87
- "docs:list": "tsx scripts/docs-list.ts",
88
- "build": "tsc -p tsconfig.build.json && pnpm run build:vendor",
89
- "build:vendor": "node -e \"const fs=require('fs'); const path=require('path'); const vendorRoot=path.join('dist','vendor'); fs.rmSync(vendorRoot,{recursive:true,force:true}); const vendors=[['oracle-notifier']]; vendors.forEach(([name])=>{const src=path.join('vendor',name); const dest=path.join(vendorRoot,name); fs.mkdirSync(dest,{recursive:true}); if(fs.existsSync(src)){fs.cpSync(src,dest,{recursive:true,force:true});}});\"",
90
- "start": "pnpm run build && node ./dist/scripts/run-cli.js",
91
- "oracle": "pnpm start",
92
- "check": "pnpm run typecheck",
93
- "typecheck": "tsc --noEmit",
94
- "lint": "pnpm run typecheck && biome lint .",
95
- "test": "vitest run",
96
- "test:mcp": "pnpm run build && pnpm run test:mcp:unit && pnpm run test:mcp:mcporter",
97
- "test:mcp:unit": "vitest run tests/mcp*.test.ts tests/mcp/**/*.test.ts",
98
- "test:mcp:mcporter": "npx -y mcporter list oracle-local --schema --config config/mcporter.json && npx -y mcporter call oracle-local.sessions limit:1 --config config/mcporter.json",
99
- "test:browser": "pnpm run build && tsx scripts/test-browser.ts && ./scripts/browser-smoke.sh",
100
- "test:live": "ORACLE_LIVE_TEST=1 vitest run tests/live --exclude tests/live/openai-live.test.ts",
101
- "test:pro": "ORACLE_LIVE_TEST=1 vitest run tests/live/openai-live.test.ts",
102
- "test:coverage": "vitest run --coverage",
103
- "mcp": "pnpm run build && node ./dist/bin/oracle-mcp.js"
104
- }
105
- }
108
+ "pnpm": {
109
+ "overrides": {
110
+ "devtools-protocol": "0.0.1561482",
111
+ "win-dpapi": "npm:@primno/dpapi@2.0.1"
112
+ },
113
+ "onlyBuiltDependencies": [
114
+ "@cdktf/node-pty-prebuilt-multiarch",
115
+ "esbuild",
116
+ "keytar",
117
+ "sqlite3",
118
+ "win-dpapi"
119
+ ]
120
+ },
121
+ "packageManager": "pnpm@10.23.0+sha512.21c4e5698002ade97e4efe8b8b4a89a8de3c85a37919f957e7a0f30f38fbc5bbdd05980ffe29179b2fb6e6e691242e098d945d1601772cad0fef5fb6411e2a4b"
122
+ }
@@ -0,0 +1,24 @@
1
+ # Oracle Notifier helper (macOS, arm64)
2
+
3
+ Builds a tiny signed helper app for macOS notifications with the Oracle icon.
4
+
5
+ ## Build
6
+
7
+ ```bash
8
+ cd vendor/oracle-notifier
9
+ # Optional: notarize by setting App Store Connect key credentials
10
+ export APP_STORE_CONNECT_API_KEY_P8="$(cat AuthKey_XXXXXX.p8)" # with literal newlines or \n escaped
11
+ export APP_STORE_CONNECT_KEY_ID=XXXXXX
12
+ export APP_STORE_CONNECT_ISSUER_ID=YYYYYYYY-YYYY-YYYY-YYYY-YYYYYYYYYYYY
13
+ ./build-notifier.sh
14
+ ```
15
+
16
+ - Requires Xcode command line tools (swiftc) and a macOS Developer ID certificate. Without a valid cert, the build fails (no ad-hoc fallback).
17
+ - If `APP_STORE_CONNECT_*` vars are set, the script notarizes and staples the ticket.
18
+ - Output: `OracleNotifier.app` (arm64 only), bundled with `OracleIcon.icns`.
19
+
20
+ ## Usage
21
+ The CLI prefers this helper on macOS; if it fails or is missing, it falls back to toasted-notifier/terminal-notifier.
22
+
23
+ ## Permissions
24
+ After first run, allow notifications for “Oracle Notifier” in System Settings → Notifications.
File without changes