@steipete/oracle 0.7.5 → 0.8.0

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,4 +1,6 @@
1
1
  import { InvalidArgumentError } from 'commander';
2
+ import path from 'node:path';
3
+ import fg from 'fast-glob';
2
4
  import { DEFAULT_MODEL, MODEL_CONFIGS } from '../oracle.js';
3
5
  export function collectPaths(value, previous = []) {
4
6
  if (!value) {
@@ -17,6 +19,31 @@ export function mergePathLikeOptions(file, include, filesAlias, pathAlias, paths
17
19
  const withPathAlias = collectPaths(pathAlias, withFilesAlias);
18
20
  return collectPaths(pathsAlias, withPathAlias);
19
21
  }
22
+ export function dedupePathInputs(inputs, { cwd = process.cwd() } = {}) {
23
+ const deduped = [];
24
+ const duplicates = [];
25
+ const seen = new Set();
26
+ for (const entry of inputs ?? []) {
27
+ const raw = entry?.trim();
28
+ if (!raw)
29
+ continue;
30
+ let key = raw;
31
+ if (!raw.startsWith('!') && !fg.isDynamicPattern(raw)) {
32
+ const absolute = path.isAbsolute(raw) ? raw : path.resolve(cwd, raw);
33
+ key = `path:${path.normalize(absolute)}`;
34
+ }
35
+ else {
36
+ key = `pattern:${raw}`;
37
+ }
38
+ if (seen.has(key)) {
39
+ duplicates.push(raw);
40
+ continue;
41
+ }
42
+ seen.add(key);
43
+ deduped.push(raw);
44
+ }
45
+ return { deduped, duplicates };
46
+ }
20
47
  export function collectModelList(value, previous = []) {
21
48
  if (!value) {
22
49
  return previous;
@@ -1,7 +1,7 @@
1
1
  import chalk from 'chalk';
2
2
  import kleur from 'kleur';
3
3
  import { renderMarkdownAnsi } from './markdownRenderer.js';
4
- import { formatElapsed, formatUSD } from '../oracle/format.js';
4
+ import { formatFinishLine } from '../oracle/finishLine.js';
5
5
  import { sessionStore, wait } from '../sessionStore.js';
6
6
  import { formatTokenCount, formatTokenValue } from '../oracle/runUtils.js';
7
7
  import { resumeBrowserSession } from '../browser/reattach.js';
@@ -61,6 +61,13 @@ export async function attachSession(sessionId, options) {
61
61
  process.exitCode = 1;
62
62
  return;
63
63
  }
64
+ if (metadata.mode === 'browser' && metadata.status === 'running' && !metadata.browser?.runtime) {
65
+ await wait(250);
66
+ const refreshed = await sessionStore.readSession(sessionId);
67
+ if (refreshed) {
68
+ metadata = refreshed;
69
+ }
70
+ }
64
71
  const normalizedModelFilter = options?.model?.trim().toLowerCase();
65
72
  if (normalizedModelFilter) {
66
73
  const availableModels = metadata.models?.map((model) => model.model.toLowerCase()) ??
@@ -92,7 +99,7 @@ export async function attachSession(sessionId, options) {
92
99
  if (message) {
93
100
  console.log(dim(message));
94
101
  }
95
- }), { verbose: true }));
102
+ }), { verbose: true }), { promptPreview: metadata.promptPreview });
96
103
  const outputTokens = estimateTokenCount(result.answerMarkdown);
97
104
  const logWriter = sessionStore.createLogWriter(sessionId);
98
105
  logWriter.logLine('[reattach] captured assistant response from existing Chrome tab');
@@ -516,7 +523,6 @@ export function formatCompletionSummary(metadata, options = {}) {
516
523
  const modeLabel = metadata.mode === 'browser' ? `${metadata.model ?? 'n/a'}[browser]` : metadata.model ?? 'n/a';
517
524
  const usage = metadata.usage;
518
525
  const cost = resolveSessionCost(metadata);
519
- const costPart = cost != null ? ` | ${formatUSD(cost)}` : '';
520
526
  const tokensDisplay = [
521
527
  usage.inputTokens ?? 0,
522
528
  usage.outputTokens ?? 0,
@@ -530,10 +536,23 @@ export function formatCompletionSummary(metadata, options = {}) {
530
536
  total_tokens: usage.totalTokens,
531
537
  }, index))
532
538
  .join('/');
539
+ const tokensPart = (() => {
540
+ const parts = tokensDisplay.split('/');
541
+ if (parts.length !== 4)
542
+ return tokensDisplay;
543
+ return `↑${parts[0]} ↓${parts[1]} ↻${parts[2]} Δ${parts[3]}`;
544
+ })();
533
545
  const filesCount = metadata.options?.file?.length ?? 0;
534
- const filesPart = filesCount > 0 ? ` | files=${filesCount}` : '';
535
- const slugPart = options.includeSlug ? ` | slug=${metadata.id}` : '';
536
- return `Finished in ${formatElapsed(metadata.elapsedMs)} (${modeLabel}${costPart} | tok(i/o/r/t)=${tokensDisplay}${filesPart}${slugPart})`;
546
+ const filesPart = filesCount > 0 ? `files=${filesCount}` : null;
547
+ const slugPart = options.includeSlug ? `slug=${metadata.id}` : null;
548
+ const { line1, line2 } = formatFinishLine({
549
+ elapsedMs: metadata.elapsedMs,
550
+ model: modeLabel,
551
+ costUsd: cost ?? null,
552
+ tokensPart,
553
+ detailParts: [filesPart, slugPart],
554
+ });
555
+ return line2 ? `${line1} | ${line2}` : line1;
537
556
  }
538
557
  async function readStoredPrompt(sessionId) {
539
558
  const request = await sessionStore.readRequest(sessionId);
@@ -15,10 +15,9 @@ import { resolveModelConfig } from '../oracle/modelResolver.js';
15
15
  import { buildPrompt, buildRequestBody } from '../oracle/request.js';
16
16
  import { estimateRequestTokens } from '../oracle/tokenEstimate.js';
17
17
  import { formatTokenEstimate, formatTokenValue } from '../oracle/runUtils.js';
18
- import { formatElapsed } from '../oracle/format.js';
18
+ import { formatFinishLine } from '../oracle/finishLine.js';
19
19
  import { sanitizeOscProgress } from './oscUtils.js';
20
20
  import { readFiles } from '../oracle/files.js';
21
- import { formatUSD } from '../oracle/format.js';
22
21
  import { cwd as getCwd } from 'node:process';
23
22
  const isTty = process.stdout.isTTY;
24
23
  const dim = (text) => (isTty ? kleur.dim(text) : text);
@@ -211,10 +210,21 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
211
210
  total_tokens: aggregateUsage.totalTokens,
212
211
  }, idx))
213
212
  .join('/');
214
- const costLabel = aggregateUsage.cost != null ? formatUSD(aggregateUsage.cost) : 'cost=N/A';
213
+ const tokensPart = (() => {
214
+ const parts = tokensDisplay.split('/');
215
+ if (parts.length !== 4)
216
+ return tokensDisplay;
217
+ return `↑${parts[0]} ↓${parts[1]} ↻${parts[2]} Δ${parts[3]}`;
218
+ })();
215
219
  const statusColor = summary.rejected.length === 0 ? kleur.green : summary.fulfilled.length > 0 ? kleur.yellow : kleur.red;
216
220
  const overallText = `${summary.fulfilled.length}/${multiModels.length} models`;
217
- log(statusColor(`Finished in ${formatElapsed(summary.elapsedMs)} (${overallText} | ${costLabel} | tok(i/o/r/t)=${tokensDisplay})`));
221
+ const { line1 } = formatFinishLine({
222
+ elapsedMs: summary.elapsedMs,
223
+ model: overallText,
224
+ costUsd: aggregateUsage.cost ?? null,
225
+ tokensPart,
226
+ });
227
+ log(statusColor(line1));
218
228
  const hasFailure = summary.rejected.length > 0;
219
229
  await sessionStore.updateSession(sessionMeta.id, {
220
230
  status: hasFailure ? 'error' : 'completed',
@@ -1,5 +1,27 @@
1
1
  import path from 'node:path';
2
+ import { getCookies } from '@steipete/sweet-cookie';
2
3
  import { runGeminiWebWithFallback, saveFirstGeminiImageFromOutput } from './client.js';
4
+ const GEMINI_COOKIE_NAMES = [
5
+ '__Secure-1PSID',
6
+ '__Secure-1PSIDTS',
7
+ '__Secure-1PSIDCC',
8
+ '__Secure-1PAPISID',
9
+ 'NID',
10
+ 'AEC',
11
+ 'SOCS',
12
+ '__Secure-BUCKET',
13
+ '__Secure-ENID',
14
+ 'SID',
15
+ 'HSID',
16
+ 'SSID',
17
+ 'APISID',
18
+ 'SAPISID',
19
+ '__Secure-3PSID',
20
+ '__Secure-3PSIDTS',
21
+ '__Secure-3PAPISID',
22
+ 'SIDCC',
23
+ ];
24
+ const GEMINI_REQUIRED_COOKIES = ['__Secure-1PSID', '__Secure-1PSIDTS'];
3
25
  function estimateTokenCount(text) {
4
26
  return Math.ceil(text.length / 4);
5
27
  }
@@ -30,59 +52,84 @@ function resolveGeminiWebModel(desiredModel, log) {
30
52
  return 'gemini-3-pro';
31
53
  }
32
54
  }
55
+ function resolveCookieDomain(cookie) {
56
+ const rawDomain = cookie.domain?.trim();
57
+ if (rawDomain) {
58
+ return rawDomain.startsWith('.') ? rawDomain.slice(1) : rawDomain;
59
+ }
60
+ const rawUrl = cookie.url?.trim();
61
+ if (rawUrl) {
62
+ try {
63
+ return new URL(rawUrl).hostname;
64
+ }
65
+ catch {
66
+ return null;
67
+ }
68
+ }
69
+ return null;
70
+ }
71
+ function pickCookieValue(cookies, name) {
72
+ const matches = cookies.filter((cookie) => cookie.name === name && typeof cookie.value === 'string');
73
+ if (matches.length === 0)
74
+ return undefined;
75
+ const preferredDomain = matches.find((cookie) => {
76
+ const domain = resolveCookieDomain(cookie);
77
+ return domain === 'google.com' && (cookie.path ?? '/') === '/';
78
+ });
79
+ const googleDomain = matches.find((cookie) => (resolveCookieDomain(cookie) ?? '').endsWith('google.com'));
80
+ return (preferredDomain ?? googleDomain ?? matches[0])?.value;
81
+ }
82
+ function buildGeminiCookieMap(cookies) {
83
+ const cookieMap = {};
84
+ for (const name of GEMINI_COOKIE_NAMES) {
85
+ const value = pickCookieValue(cookies, name);
86
+ if (value)
87
+ cookieMap[name] = value;
88
+ }
89
+ return cookieMap;
90
+ }
91
+ function hasRequiredGeminiCookies(cookieMap) {
92
+ return GEMINI_REQUIRED_COOKIES.every((name) => Boolean(cookieMap[name]));
93
+ }
94
+ async function loadGeminiCookiesFromInline(browserConfig, log) {
95
+ const inline = browserConfig?.inlineCookies;
96
+ if (!inline || inline.length === 0)
97
+ return {};
98
+ const cookieMap = buildGeminiCookieMap(inline.filter((cookie) => Boolean(cookie?.name && typeof cookie.value === 'string')));
99
+ if (Object.keys(cookieMap).length > 0) {
100
+ const source = browserConfig?.inlineCookiesSource ?? 'inline';
101
+ log?.(`[gemini-web] Loaded Gemini cookies from inline payload (${source}): ${Object.keys(cookieMap).length} cookie(s).`);
102
+ }
103
+ else {
104
+ log?.('[gemini-web] Inline cookie payload provided but no Gemini cookies matched.');
105
+ }
106
+ return cookieMap;
107
+ }
33
108
  async function loadGeminiCookiesFromChrome(browserConfig, log) {
34
109
  try {
35
- const mod = (await import('chrome-cookies-secure'));
36
- const chromeCookies = mod.default ??
37
- mod;
38
- const profile = typeof browserConfig?.chromeProfile === 'string' &&
39
- browserConfig.chromeProfile.trim().length > 0
40
- ? browserConfig.chromeProfile.trim()
110
+ // Learned: Gemini web relies on Google auth cookies in the *browser* profile, not API keys.
111
+ const profileCandidate = browserConfig?.chromeCookiePath ?? browserConfig?.chromeProfile ?? undefined;
112
+ const profile = typeof profileCandidate === 'string' && profileCandidate.trim().length > 0
113
+ ? profileCandidate.trim()
41
114
  : undefined;
42
115
  const sources = [
43
116
  'https://gemini.google.com',
44
117
  'https://accounts.google.com',
45
118
  'https://www.google.com',
46
119
  ];
47
- const wantNames = [
48
- '__Secure-1PSID',
49
- '__Secure-1PSIDTS',
50
- '__Secure-1PSIDCC',
51
- '__Secure-1PAPISID',
52
- 'NID',
53
- 'AEC',
54
- 'SOCS',
55
- '__Secure-BUCKET',
56
- '__Secure-ENID',
57
- 'SID',
58
- 'HSID',
59
- 'SSID',
60
- 'APISID',
61
- 'SAPISID',
62
- '__Secure-3PSID',
63
- '__Secure-3PSIDTS',
64
- '__Secure-3PAPISID',
65
- 'SIDCC',
66
- ];
67
- const cookieMap = {};
68
- for (const url of sources) {
69
- const cookies = (await chromeCookies.getCookiesPromised(url, 'puppeteer', profile));
70
- for (const name of wantNames) {
71
- if (cookieMap[name])
72
- continue;
73
- const matches = cookies.filter((cookie) => cookie.name === name);
74
- if (matches.length === 0)
75
- continue;
76
- const preferredDomain = matches.find((cookie) => cookie.domain === '.google.com' && (cookie.path ?? '/') === '/');
77
- const googleDomain = matches.find((cookie) => (cookie.domain ?? '').endsWith('google.com'));
78
- const value = (preferredDomain ?? googleDomain ?? matches[0])?.value;
79
- if (value)
80
- cookieMap[name] = value;
81
- }
82
- }
83
- if (!cookieMap['__Secure-1PSID'] || !cookieMap['__Secure-1PSIDTS']) {
84
- return {};
120
+ const { cookies, warnings } = await getCookies({
121
+ url: sources[0],
122
+ origins: sources,
123
+ names: [...GEMINI_COOKIE_NAMES],
124
+ browsers: ['chrome'],
125
+ mode: 'merge',
126
+ chromeProfile: profile,
127
+ timeoutMs: 5_000,
128
+ });
129
+ if (warnings.length && log?.verbose) {
130
+ log(`[gemini-web] Cookie warnings:\n- ${warnings.join('\n- ')}`);
85
131
  }
132
+ const cookieMap = buildGeminiCookieMap(cookies);
86
133
  log?.(`[gemini-web] Loaded Gemini cookies from Chrome (node): ${Object.keys(cookieMap).length} cookie(s).`);
87
134
  return cookieMap;
88
135
  }
@@ -91,13 +138,27 @@ async function loadGeminiCookiesFromChrome(browserConfig, log) {
91
138
  return {};
92
139
  }
93
140
  }
141
+ async function loadGeminiCookies(browserConfig, log) {
142
+ const inlineMap = await loadGeminiCookiesFromInline(browserConfig, log);
143
+ const hasInlineRequired = hasRequiredGeminiCookies(inlineMap);
144
+ if (hasInlineRequired && browserConfig?.cookieSync === false) {
145
+ return inlineMap;
146
+ }
147
+ if (browserConfig?.cookieSync === false && !hasInlineRequired) {
148
+ log?.('[gemini-web] Cookie sync disabled and inline cookies missing Gemini auth tokens.');
149
+ return inlineMap;
150
+ }
151
+ const chromeMap = await loadGeminiCookiesFromChrome(browserConfig, log);
152
+ const merged = { ...chromeMap, ...inlineMap };
153
+ return merged;
154
+ }
94
155
  export function createGeminiWebExecutor(geminiOptions) {
95
156
  return async (runOptions) => {
96
157
  const startTime = Date.now();
97
158
  const log = runOptions.log;
98
159
  log?.('[gemini-web] Starting Gemini web executor (TypeScript)');
99
- const cookieMap = await loadGeminiCookiesFromChrome(runOptions.config, log);
100
- if (!cookieMap['__Secure-1PSID'] || !cookieMap['__Secure-1PSIDTS']) {
160
+ const cookieMap = await loadGeminiCookies(runOptions.config, log);
161
+ if (!hasRequiredGeminiCookies(cookieMap)) {
101
162
  throw new Error('Gemini browser mode requires Chrome cookies for google.com (missing __Secure-1PSID/__Secure-1PSIDTS).');
102
163
  }
103
164
  const configTimeout = typeof runOptions.config?.timeoutMs === 'number' && Number.isFinite(runOptions.config.timeoutMs)
@@ -0,0 +1,32 @@
1
+ import { formatUSD } from './format.js';
2
+ export function formatElapsedCompact(ms) {
3
+ if (!Number.isFinite(ms) || ms < 0) {
4
+ return 'unknown';
5
+ }
6
+ if (ms < 60_000) {
7
+ return `${(ms / 1000).toFixed(1)}s`;
8
+ }
9
+ if (ms < 60 * 60_000) {
10
+ const minutes = Math.floor(ms / 60_000);
11
+ const seconds = Math.floor((ms % 60_000) / 1000);
12
+ return `${minutes}m${seconds.toString().padStart(2, '0')}s`;
13
+ }
14
+ const hours = Math.floor(ms / (60 * 60_000));
15
+ const minutes = Math.floor((ms % (60 * 60_000)) / 60_000);
16
+ return `${hours}h${minutes.toString().padStart(2, '0')}m`;
17
+ }
18
+ export function formatFinishLine({ elapsedMs, model, costUsd, tokensPart, summaryExtraParts, detailParts, }) {
19
+ const line1Parts = [
20
+ formatElapsedCompact(elapsedMs),
21
+ typeof costUsd === 'number' ? formatUSD(costUsd) : null,
22
+ model,
23
+ tokensPart,
24
+ ...(summaryExtraParts ?? []),
25
+ ];
26
+ const line1 = line1Parts.filter((part) => typeof part === 'string' && part.length > 0).join(' · ');
27
+ const line2Parts = (detailParts ?? []).filter((part) => typeof part === 'string' && part.length > 0);
28
+ if (line2Parts.length === 0) {
29
+ return { line1 };
30
+ }
31
+ return { line1, line2: line2Parts.join(' | ') };
32
+ }
@@ -8,7 +8,8 @@ import { DEFAULT_SYSTEM_PROMPT, MODEL_CONFIGS, TOKENIZER_OPTIONS } from './confi
8
8
  import { readFiles } from './files.js';
9
9
  import { buildPrompt, buildRequestBody } from './request.js';
10
10
  import { estimateRequestTokens } from './tokenEstimate.js';
11
- import { formatElapsed, formatUSD } from './format.js';
11
+ import { formatElapsed } from './format.js';
12
+ import { formatFinishLine } from './finishLine.js';
12
13
  import { getFileTokenStats, printFileTokenStats } from './tokenStats.js';
13
14
  import { OracleResponseError, OracleTransportError, PromptValidationError, describeTransportError, toTransportError, } from './errors.js';
14
15
  import { createDefaultClientFactory } from './client.js';
@@ -19,7 +20,7 @@ import { createFsAdapter } from './fsAdapter.js';
19
20
  import { resolveGeminiModelId } from './gemini.js';
20
21
  import { resolveClaudeModelId } from './claude.js';
21
22
  import { renderMarkdownAnsi } from '../cli/markdownRenderer.js';
22
- import { createLiveRenderer } from 'markdansi';
23
+ import { createMarkdownStreamer } from 'markdansi';
23
24
  import { executeBackgroundResponse } from './background.js';
24
25
  import { formatTokenEstimate, formatTokenValue, resolvePreviewMode } from './runUtils.js';
25
26
  import { estimateUsdCost } from 'tokentally';
@@ -311,8 +312,6 @@ export async function runOracle(options, deps = {}) {
311
312
  let response = null;
312
313
  let elapsedMs = 0;
313
314
  let sawTextDelta = false;
314
- let streamedText = '';
315
- let lastLiveFrameAtMs = 0;
316
315
  let answerHeaderPrinted = false;
317
316
  const allowAnswerHeader = options.suppressAnswerHeader !== true;
318
317
  const timeoutExceeded = () => now() - runStart >= timeoutMs;
@@ -380,14 +379,23 @@ export async function runOracle(options, deps = {}) {
380
379
  },
381
380
  });
382
381
  }
383
- let liveRenderer = null;
382
+ let markdownStreamer = null;
383
+ const flushMarkdownStreamer = () => {
384
+ if (!markdownStreamer)
385
+ return;
386
+ const rendered = markdownStreamer.finish();
387
+ markdownStreamer = null;
388
+ if (rendered) {
389
+ stdoutWrite(rendered);
390
+ }
391
+ };
384
392
  try {
385
- liveRenderer =
393
+ markdownStreamer =
386
394
  isTty && !renderPlain
387
- ? createLiveRenderer({
388
- write: stdoutWrite,
389
- width: process.stdout.columns ?? 80,
390
- renderFrame: renderMarkdownAnsi,
395
+ ? createMarkdownStreamer({
396
+ render: renderMarkdownAnsi,
397
+ spacing: 'single',
398
+ mode: 'hybrid',
391
399
  })
392
400
  : null;
393
401
  for await (const event of stream) {
@@ -408,38 +416,27 @@ export async function runOracle(options, deps = {}) {
408
416
  stdoutWrite(event.delta);
409
417
  continue;
410
418
  }
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;
419
+ if (markdownStreamer) {
420
+ const rendered = markdownStreamer.push(event.delta);
421
+ if (rendered) {
422
+ stdoutWrite(rendered);
419
423
  }
420
424
  continue;
421
425
  }
422
426
  // Non-TTY streams should still surface output; fall back to raw stdout.
423
427
  stdoutWrite(event.delta);
424
428
  }
425
- if (liveRenderer) {
426
- streamedText = streamedText.trim();
427
- if (streamedText.length > 0) {
428
- liveRenderer.render(streamedText);
429
- }
430
- }
429
+ flushMarkdownStreamer();
431
430
  throwIfTimedOut();
432
431
  }
433
432
  catch (streamError) {
434
433
  // stream.abort() is not available on the interface
434
+ flushMarkdownStreamer();
435
435
  stopHeartbeatNow();
436
436
  const transportError = toTransportError(streamError, requestBody.model);
437
437
  log(chalk.yellow(describeTransportError(transportError, timeoutMs)));
438
438
  throw transportError;
439
439
  }
440
- finally {
441
- liveRenderer?.finish();
442
- }
443
440
  response = await stream.finalResponse();
444
441
  throwIfTimedOut();
445
442
  stopHeartbeatNow();
@@ -454,17 +451,12 @@ export async function runOracle(options, deps = {}) {
454
451
  }
455
452
  // We only add spacing when streamed text was printed.
456
453
  if (sawTextDelta && !options.silent) {
457
- const shouldRenderAfterStream = isTty && !renderPlain && streamedText.length > 0;
458
454
  if (renderPlain) {
459
455
  // Plain streaming already wrote chunks; ensure clean separation.
460
456
  stdoutWrite('\n');
461
457
  }
462
- else if (!shouldRenderAfterStream) {
463
- // Non-TTY streams should still surface output; ensure separation.
464
- log('');
465
- }
466
458
  else {
467
- // Live-rendered mode already drew the final frame; only separate from logs.
459
+ // Separate streamed output from logs.
468
460
  log('');
469
461
  }
470
462
  }
@@ -530,46 +522,48 @@ export async function runOracle(options, deps = {}) {
530
522
  pricing: { inputUsdPerToken: pricing.inputPerToken, outputUsdPerToken: pricing.outputPerToken },
531
523
  })?.totalUsd
532
524
  : undefined;
533
- const elapsedDisplay = formatElapsed(elapsedMs);
534
- const statsParts = [];
535
525
  const effortLabel = modelConfig.reasoning?.effort;
536
526
  const modelLabel = effortLabel ? `${modelConfig.model}[${effortLabel}]` : modelConfig.model;
537
527
  const sessionIdContainsModel = typeof options.sessionId === 'string' && options.sessionId.toLowerCase().includes(modelConfig.model.toLowerCase());
538
- // Avoid duplicating the model name in the prefix (session id) and the stats bundle; keep a single source of truth.
539
- if (!sessionIdContainsModel) {
540
- statsParts.push(modelLabel);
541
- }
542
- if (cost != null) {
543
- statsParts.push(formatUSD(cost));
544
- }
545
- else {
546
- statsParts.push('cost=N/A');
547
- }
548
528
  const tokensDisplay = [inputTokens, outputTokens, reasoningTokens, totalTokens]
549
529
  .map((value, index) => formatTokenValue(value, usage, index))
550
530
  .join('/');
551
- const tokensLabel = options.verbose ? 'tokens (input/output/reasoning/total)' : 'tok(i/o/r/t)';
552
- statsParts.push(`${tokensLabel}=${tokensDisplay}`);
553
- if (options.verbose) {
554
- // Only surface request-vs-response deltas when verbose is explicitly requested to keep default stats compact.
555
- const actualInput = usage.input_tokens;
556
- if (actualInput !== undefined) {
557
- const delta = actualInput - estimatedInputTokens;
558
- const deltaText = delta === 0 ? '' : delta > 0 ? ` (+${delta.toLocaleString()})` : ` (${delta.toLocaleString()})`;
559
- statsParts.push(`est→actual=${estimatedInputTokens.toLocaleString()}→${actualInput.toLocaleString()}${deltaText}`);
560
- }
561
- }
562
- if (!searchEnabled) {
563
- statsParts.push('search=off');
564
- }
565
- if (files.length > 0) {
566
- statsParts.push(`files=${files.length}`);
567
- }
568
- const sessionPrefix = options.sessionId ? `${options.sessionId} ` : '';
531
+ const tokensPart = (() => {
532
+ const parts = tokensDisplay.split('/');
533
+ if (parts.length !== 4)
534
+ return tokensDisplay;
535
+ return `↑${parts[0]} ↓${parts[1]} ↻${parts[2]} Δ${parts[3]}`;
536
+ })();
537
+ const modelPart = sessionIdContainsModel ? null : modelLabel;
538
+ const actualInput = usage.input_tokens;
539
+ const estActualPart = (() => {
540
+ if (!options.verbose)
541
+ return null;
542
+ if (actualInput === undefined)
543
+ return null;
544
+ const delta = actualInput - estimatedInputTokens;
545
+ const deltaText = delta === 0 ? '' : delta > 0 ? ` (+${delta.toLocaleString()})` : ` (${delta.toLocaleString()})`;
546
+ return `est→actual=${estimatedInputTokens.toLocaleString()}→${actualInput.toLocaleString()}${deltaText}`;
547
+ })();
548
+ const { line1, line2 } = formatFinishLine({
549
+ elapsedMs,
550
+ model: modelPart,
551
+ costUsd: cost ?? null,
552
+ tokensPart,
553
+ summaryExtraParts: options.sessionId ? [`sid=${options.sessionId}`] : null,
554
+ detailParts: [
555
+ estActualPart,
556
+ !searchEnabled ? 'search=off' : null,
557
+ files.length > 0 ? `files=${files.length}` : null,
558
+ ],
559
+ });
569
560
  if (!options.silent) {
570
561
  log('');
571
562
  }
572
- log(chalk.blue(`Finished ${sessionPrefix}in ${elapsedDisplay} (${statsParts.join(' | ')})`));
563
+ log(chalk.blue(line1));
564
+ if (line2) {
565
+ log(dim(line2));
566
+ }
573
567
  return {
574
568
  mode: 'live',
575
569
  response,
@@ -7,7 +7,7 @@ import { spawn, spawnSync } from 'node:child_process';
7
7
  import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises';
8
8
  import chalk from 'chalk';
9
9
  import { runBrowserMode } from '../browserMode.js';
10
- import { loadChromeCookies } from '../browser/chromeCookies.js';
10
+ import { getCookies } from '@steipete/sweet-cookie';
11
11
  import { CHATGPT_URL } from '../browser/constants.js';
12
12
  import { cleanupStaleProfileState, readDevToolsPort, verifyDevToolsReachable, writeChromePid, writeDevToolsActivePort, } from '../browser/profileState.js';
13
13
  import { normalizeChatgptUrl } from '../browser/utils.js';
@@ -331,13 +331,17 @@ function formatReachableAddresses(bindAddress, port) {
331
331
  async function loadLocalChatgptCookies(logger, targetUrl) {
332
332
  try {
333
333
  logger('Loading ChatGPT cookies from this host\'s Chrome profile...');
334
- const cookies = await Promise.resolve(loadChromeCookies({
335
- targetUrl,
336
- profile: 'Default',
337
- })).catch((error) => {
338
- logger(`Unable to load local ChatGPT cookies on this host: ${error instanceof Error ? error.message : String(error)}`);
339
- return [];
334
+ const { cookies: rawCookies, warnings } = await getCookies({
335
+ url: targetUrl,
336
+ browsers: ['chrome'],
337
+ mode: 'merge',
338
+ chromeProfile: 'Default',
339
+ timeoutMs: 5_000,
340
340
  });
341
+ if (warnings.length) {
342
+ logger(`Cookie warnings:\n- ${warnings.join('\n- ')}`);
343
+ }
344
+ const cookies = rawCookies.map(toCdpCookie).filter((c) => Boolean(c));
341
345
  if (!cookies || cookies.length === 0) {
342
346
  logger('No local ChatGPT cookies found on this host. Please log in once; opening ChatGPT...');
343
347
  const opened = triggerLocalLoginPrompt(logger, targetUrl);
@@ -348,14 +352,7 @@ async function loadLocalChatgptCookies(logger, targetUrl) {
348
352
  }
349
353
  catch (error) {
350
354
  const message = error instanceof Error ? error.message : String(error);
351
- const missingDbMatch = message.match(/Unable to locate Chrome cookie DB at (.+)/);
352
- if (missingDbMatch) {
353
- const lookedPath = missingDbMatch[1];
354
- logger(`Chrome cookies not found at ${lookedPath}. Set --browser-cookie-path to your Chrome profile or log in manually.`);
355
- }
356
- else {
357
- logger(`Unable to load local ChatGPT cookies on this host: ${message}`);
358
- }
355
+ logger(`Unable to load local ChatGPT cookies on this host: ${message}`);
359
356
  if (process.platform === 'linux' && isWsl()) {
360
357
  logger('WSL hint: Chrome lives under /mnt/c/Users/<you>/AppData/Local/Google/Chrome/User Data/Default; pass --browser-cookie-path to that directory if auto-detect fails.');
361
358
  }
@@ -363,6 +360,24 @@ async function loadLocalChatgptCookies(logger, targetUrl) {
363
360
  return { cookies: null, opened };
364
361
  }
365
362
  }
363
+ function toCdpCookie(cookie) {
364
+ if (!cookie?.name)
365
+ return null;
366
+ const out = {
367
+ name: cookie.name,
368
+ value: cookie.value,
369
+ domain: cookie.domain,
370
+ path: cookie.path ?? '/',
371
+ secure: cookie.secure ?? true,
372
+ httpOnly: cookie.httpOnly ?? false,
373
+ };
374
+ if (typeof cookie.expires === 'number')
375
+ out.expires = cookie.expires;
376
+ if (cookie.sameSite === 'Lax' || cookie.sameSite === 'Strict' || cookie.sameSite === 'None') {
377
+ out.sameSite = cookie.sameSite;
378
+ }
379
+ return out;
380
+ }
366
381
  function triggerLocalLoginPrompt(logger, url) {
367
382
  const verbose = process.argv.includes('--verbose') || process.env.ORACLE_SERVE_VERBOSE === '1';
368
383
  const openers = [];
@@ -445,6 +445,22 @@ async function markZombie(meta, { persist }) {
445
445
  if (!isZombie(meta)) {
446
446
  return meta;
447
447
  }
448
+ if (meta.mode === 'browser') {
449
+ const runtime = meta.browser?.runtime;
450
+ if (runtime) {
451
+ const signals = [];
452
+ if (runtime.chromePid) {
453
+ signals.push(isProcessAlive(runtime.chromePid));
454
+ }
455
+ if (runtime.chromePort) {
456
+ const host = runtime.chromeHost ?? '127.0.0.1';
457
+ signals.push(await isPortOpen(host, runtime.chromePort));
458
+ }
459
+ if (signals.some(Boolean)) {
460
+ return meta;
461
+ }
462
+ }
463
+ }
448
464
  const updated = {
449
465
  ...meta,
450
466
  status: 'error',