@steipete/oracle 0.21.0 → 0.21.1

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.
@@ -651,7 +651,7 @@ program
651
651
  .option("--remote-host <host:port>", "Delegate browser runs to a remote `oracle serve` instance.")
652
652
  .option("--remote-token <token>", "Access token for the remote `oracle serve` instance.")
653
653
  .action(async (sessionId, _options, cmd) => {
654
- const restartOptions = cmd.opts();
654
+ const restartOptions = cmd.optsWithGlobals();
655
655
  await restartSession(sessionId, restartOptions);
656
656
  });
657
657
  function buildRunOptions(options, overrides = {}) {
@@ -1462,9 +1462,9 @@ async function runRootCommand(options) {
1462
1462
  });
1463
1463
  let browserDeps;
1464
1464
  if (browserConfig && remoteHost) {
1465
- const { createRemoteBrowserExecutor } = await import("../src/remote/client.js");
1465
+ const { resolveBrowserExecutor } = await import("../src/browser/executor.js");
1466
1466
  browserDeps = {
1467
- executeBrowser: createRemoteBrowserExecutor({ host: remoteHost, token: remoteToken }),
1467
+ executeBrowser: await resolveBrowserExecutor({ ...resolvedOptions, model: activeModel }, { host: remoteHost, token: remoteToken }),
1468
1468
  };
1469
1469
  console.log(chalk.dim(`Routing browser automation to remote host ${remoteHost}`));
1470
1470
  }
@@ -1776,9 +1776,12 @@ async function restartSession(sessionId, options) {
1776
1776
  }
1777
1777
  let browserDeps;
1778
1778
  if (browserConfig && remoteHost) {
1779
- const { createRemoteBrowserExecutor } = await import("../src/remote/client.js");
1779
+ const { resolveBrowserExecutor } = await import("../src/browser/executor.js");
1780
1780
  browserDeps = {
1781
- executeBrowser: createRemoteBrowserExecutor({ host: remoteHost, token: remoteToken }),
1781
+ executeBrowser: await resolveBrowserExecutor(runOptions, {
1782
+ host: remoteHost,
1783
+ token: remoteToken,
1784
+ }),
1782
1785
  };
1783
1786
  console.log(chalk.dim(`Routing browser automation to remote host ${remoteHost}`));
1784
1787
  }
@@ -515,13 +515,14 @@ body:not(.home) .doc>h1:first-child{display:none}
515
515
  <p>``<code>bash oracle serve </code>``</p>
516
516
  <p>Oracle picks a free port, launches Chrome, starts an HTTP/SSE API, and prints:</p>
517
517
  <p>``<code> Listening at 0.0.0.0:9473 Access token: c4e5f9... </code>``</p>
518
- <p>Use <code>--host</code>, <code>--port</code>, or <code>--token</code> to override the defaults if needed. On first use, sign in to ChatGPT in the dedicated automation Chrome window. The service keeps that profile for later runs.</p>
518
+ <p>Use <code>--host</code>, <code>--port</code>, or <code>--token</code> to override the defaults if needed. On first use, sign in to ChatGPT or Gemini in the dedicated automation Chrome window, according to the models you use. The service keeps that profile for later runs.</p>
519
519
  <ol>
520
520
  <li><strong>Run from your laptop</strong></li>
521
521
  </ol>
522
522
  <p>``<code>bash oracle --engine browser \ --remote-host 192.168.64.2:9473 \ --remote-token c4e5f9... \ --prompt &quot;Summarize the incident doc&quot; \ --file docs/incidents/latest.md </code>``</p>
523
523
  <ul>
524
524
  <li><code>--remote-host</code> points the CLI at the VM.</li>
525
+ <li><code>--model gemini-3.5-flash</code> (or another supported Gemini browser model) selects the Gemini web executor on the host. Upgrade both endpoints for remote Gemini; see <a href="gemini.html">Gemini</a> for supported options. GPT models keep the ChatGPT browser path.</li>
525
526
  <li><code>--remote-token</code> matches the token printed by <code>oracle serve</code> (set <code>ORACLE_REMOTE_TOKEN</code> to avoid repeating it).</li>
526
527
  <li>You can also set defaults in <code>~/.oracle/config.json</code> (<code>browser.remoteHost</code>, <code>browser.remoteToken</code>) so you don’t need the flags; env vars still override those when present.</li>
527
528
  <li>Cookies are <strong>not</strong> transferred from your laptop. The service reuses the dedicated automation profile on the host.</li>
@@ -289,10 +289,10 @@ body:not(.home) .doc>h1:first-child{display:none}
289
289
  <li>Legacy <code>gemini-3-pro</code>, <code>gemini-2.5-pro</code>, and <code>gemini-2.5-flash</code> browser names remain accepted and map to current Gemini web models.</li>
290
290
  <li>If your logged-in Gemini account can’t access the requested model, Oracle auto-falls back to Gemini 3.1 Flash-Lite and logs the fallback in verbose mode.</li>
291
291
  <li>Pass <code>--no-gemini-fallback</code> to fail instead when the requested web model is unavailable.</li>
292
- <li>If Google&#39;s session response exceeds Node&#39;s header limit, start Oracle with <code>NODE_OPTIONS=--max-http-header-size=65536</code> (append this to existing Node options). Oracle reports this condition explicitly and retains the process&#39;s configured proxy/TLS transport.</li>
292
+ <li>Gemini web requests accept response headers up to 64 KiB automatically, including Google&#39;s large security and reporting policies; no Node options are required. These requests honor <code>HTTP_PROXY</code>, <code>HTTPS_PROXY</code>, and <code>NO_PROXY</code> (including lowercase equivalents) and Node&#39;s TLS trust configuration.</li>
293
293
  <li>This path runs fully in Node/TypeScript (no Python/venv dependency).</li>
294
294
  <li>Local MCP consultations and detached workers use the same Gemini executor as the CLI and preserve the session&#39;s saved Gemini options. The explicit model key takes precedence over ChatGPT picker labels.</li>
295
- <li>Oracle&#39;s remote browser service supports ChatGPT only. Gemini requests with <code>--remote-host</code> are rejected before submission; run Gemini locally instead.</li>
295
+ <li>Gemini browser runs also support <code>--remote-host</code>. Upgrade both endpoints, sign into Gemini on the service host, then run <code>oracle --engine browser --model gemini-3.5-flash --remote-host &lt;host:port&gt; --remote-token &lt;token&gt; --prompt &quot;Say OK.&quot;</code>. New runs and restarts dispatch the Gemini web executor on that host; Google cookies and browser state stay there. Host manual-login profiles and <code>oracle serve --browser-attach-running</code> are supported. Text, file attachments, YouTube prompts, thoughts, and <code>--no-gemini-fallback</code> are forwarded; image generation/editing still require a local Gemini run.</li>
296
296
  <li><code>--browser-model-strategy</code> only affects ChatGPT automation; Gemini web always uses the explicit Gemini model ID.</li>
297
297
  <li><code>gemini-3-deep-think</code> is browser-only for now. <code>--engine api</code> rejects it instead of silently falling back to regular Gemini Pro.</li>
298
298
  <li>Oracle intentionally does not expose generic <code>low</code> / <code>medium</code> / <code>high</code> Gemini aliases. Explicit IDs keep model choice, billing, and thinking-effort configuration distinct.</li>
@@ -313,6 +313,7 @@ body:not(.home) .doc>h1:first-child{display:none}
313
313
  <ul>
314
314
  <li><code>src/gemini-web/models.ts</code> — centralizes current private web model headers, legacy aliases, and fallback selection.</li>
315
315
  <li><code>src/gemini-web/client.ts</code> — talks to <code>gemini.google.com</code> and downloads generated images via authenticated <code>gg-dl</code> redirects.</li>
316
+ <li><code>src/gemini-web/http.ts</code> — uses a reusable, Gemini-only Undici dispatcher with a 64 KiB response-header limit. It does not change the global dispatcher or replay failed requests. Embedders with custom dispatcher/proxy/TLS settings can use <code>createGeminiWebDispatcher(options)</code> and pass <code>dispatcher</code> to <code>fetchGeminiWebResource</code>; an explicitly supplied dispatcher owns its header limit. Global dispatcher customizations do not configure this dedicated transport.</li>
316
317
  <li><code>src/gemini-web/executor.ts</code> — browser-engine executor for Gemini (loads Chrome cookies and runs the web client).</li>
317
318
  </ul>
318
319
  <h2 id="testing"><a class="anchor" href="#testing" aria-label="Anchor link">#</a>Testing</h2>
@@ -1,6 +1,13 @@
1
1
  import { resolveBrowserProvider } from "./provider.js";
2
- export async function resolveLocalBrowserExecutor(options) {
2
+ export async function resolveBrowserExecutor(options, remote) {
3
3
  const provider = resolveBrowserProvider(options.model);
4
+ if (!provider) {
5
+ throw new Error(`Unsupported browser model: ${options.model}. Use a GPT or Gemini model.`);
6
+ }
7
+ if (remote) {
8
+ const { createRemoteBrowserExecutor } = await import("../remote/client.js");
9
+ return createRemoteBrowserExecutor({ ...remote, runOptions: options });
10
+ }
4
11
  if (provider === "gemini") {
5
12
  const { createGeminiWebExecutor } = await import("../gemini-web/index.js");
6
13
  return createGeminiWebExecutor({
@@ -13,7 +20,5 @@ export async function resolveLocalBrowserExecutor(options) {
13
20
  allowModelFallback: options.geminiAllowModelFallback,
14
21
  });
15
22
  }
16
- if (provider === "chatgpt")
17
- return (await import("../browserMode.js")).runBrowserMode;
18
- throw new Error(`Unsupported browser model: ${options.model}. Use a GPT or Gemini model.`);
23
+ return (await import("../browserMode.js")).runBrowserMode;
19
24
  }
@@ -8,4 +8,18 @@ export function resolveBrowserProvider(model) {
8
8
  return "chatgpt";
9
9
  return undefined;
10
10
  }
11
- export const REMOTE_GEMINI_UNSUPPORTED_MESSAGE = "Gemini browser runs are supported locally; remote browser services support ChatGPT only.";
11
+ export function resolveRemoteBrowserModel(model, desiredModel) {
12
+ if (model !== undefined) {
13
+ if (typeof model === "string" && resolveBrowserProvider(model))
14
+ return model;
15
+ throw new Error(`Unsupported browser model: ${String(model)}. Use a GPT or Gemini model.`);
16
+ }
17
+ if (typeof desiredModel === "string" && resolveBrowserProvider(desiredModel))
18
+ return desiredModel;
19
+ // Older ChatGPT clients send only a picker label, or omit the selection entirely.
20
+ if (desiredModel == null ||
21
+ (typeof desiredModel === "string" &&
22
+ /^(?:|latest|auto|pro|thinking|instant)(?:\s.*)?$/i.test(desiredModel.trim())))
23
+ return undefined;
24
+ throw new Error(`Unsupported browser model: ${String(desiredModel)}. Use a GPT or Gemini model.`);
25
+ }
@@ -1,7 +1,7 @@
1
1
  import chalk from "chalk";
2
2
  import { formatTokenCount } from "../oracle/runUtils.js";
3
3
  import { formatFinishLine } from "../oracle/finishLine.js";
4
- import { resolveLocalBrowserExecutor } from "./executor.js";
4
+ import { resolveBrowserExecutor } from "./executor.js";
5
5
  import { DEFAULT_BROWSER_CONFIG } from "./config.js";
6
6
  import { redactBrowserConfigForDebugLog } from "./configLogging.js";
7
7
  import { assembleBrowserPrompt, cleanupGeneratedBrowserBundles, materializeBrowserFallback, } from "./prompt.js";
@@ -67,7 +67,7 @@ export async function runBrowserSessionExecution({ runOptions, browserConfig, cw
67
67
  if (signal?.aborted) {
68
68
  throw new BrowserRunCancelledError();
69
69
  }
70
- const executeBrowser = deps.executeBrowser ?? (await resolveLocalBrowserExecutor(runOptions));
70
+ const executeBrowser = deps.executeBrowser ?? (await resolveBrowserExecutor(runOptions));
71
71
  try {
72
72
  promptArtifacts = await Promise.race([
73
73
  assemblePrompt(runOptions, { cwd }).then(async (artifacts) => {
@@ -1,7 +1,8 @@
1
1
  import path from "node:path";
2
2
  import os from "node:os";
3
3
  import { mkdir } from "node:fs/promises";
4
- import { launchChrome, connectWithNewTab, closeTab } from "../browser/chromeLifecycle.js";
4
+ import { launchChrome, connectWithNewTab, connectToRemoteChrome, closeTab, } from "../browser/chromeLifecycle.js";
5
+ import { resolveAttachRunningConnection } from "../browser/attachRunning.js";
5
6
  import { resolveBrowserConfig } from "../browser/config.js";
6
7
  import { readDevToolsPort, writeDevToolsActivePort, writeChromePid, cleanupStaleProfileState, verifyDevToolsReachable, } from "../browser/profileState.js";
7
8
  export async function openGeminiBrowserSession(input) {
@@ -12,8 +13,20 @@ export async function openGeminiBrowserSession(input) {
12
13
  keepBrowser: browserConfig?.keepBrowser ?? keepBrowserDefault,
13
14
  });
14
15
  const profileDir = resolvedConfig.manualLoginProfileDir ?? path.join(os.homedir(), ".oracle", "browser-profile");
15
- await mkdir(profileDir, { recursive: true });
16
16
  const keepBrowser = Boolean(resolvedConfig.keepBrowser);
17
+ if (resolvedConfig.attachRunning || resolvedConfig.remoteChrome) {
18
+ const logger = log ?? (() => { });
19
+ const endpoint = await resolveAttachRunningConnection(resolvedConfig, logger);
20
+ const connection = await connectToRemoteChrome(endpoint.host, endpoint.port, logger, "about:blank", endpoint.browserWSEndpoint, { approvalWaitMs: resolvedConfig.approvalWaitMs, fallbackToDefault: false });
21
+ return {
22
+ profileDir: endpoint.profileRoot ?? profileDir,
23
+ port: endpoint.port,
24
+ client: connection.client,
25
+ targetId: connection.targetId,
26
+ close: () => connection.close({ preserveTarget: keepBrowser }),
27
+ };
28
+ }
29
+ await mkdir(profileDir, { recursive: true });
17
30
  let port = await readDevToolsPort(profileDir);
18
31
  let launchedChrome = null;
19
32
  let chromeWasLaunched = false;
@@ -228,7 +228,7 @@ async function loadGeminiCookies(browserConfig, log, options) {
228
228
  if (hasInlineRequired) {
229
229
  return inlineResult;
230
230
  }
231
- const manualNoKeychain = Boolean(browserConfig?.manualLogin) || Boolean(options?.preferManualNoKeychain);
231
+ const manualNoKeychain = Boolean(browserConfig?.manualLogin || browserConfig?.attachRunning || browserConfig?.remoteChrome) || Boolean(options?.preferManualNoKeychain);
232
232
  if (manualNoKeychain) {
233
233
  log?.("[gemini-web] Using manual-login cookie extraction path (no keychain cookie read).");
234
234
  const cdpResult = await loadGeminiCookiesFromCDP(browserConfig, log);
@@ -1,6 +1,15 @@
1
+ import { EnvHttpProxyAgent } from "undici";
2
+ // Gemini's /app policy/reporting headers exceed Node's default 16 KiB limit.
3
+ const GEMINI_MAX_HEADER_SIZE = 64 * 1024;
4
+ let geminiDispatcher;
5
+ export function createGeminiWebDispatcher(options = {}) {
6
+ return new EnvHttpProxyAgent({ ...options, maxHeaderSize: GEMINI_MAX_HEADER_SIZE });
7
+ }
1
8
  export async function fetchGeminiWebResource(url, init = {}) {
2
9
  try {
3
- return await fetch(url, init);
10
+ const dispatcher = init.dispatcher ?? (geminiDispatcher ??= createGeminiWebDispatcher());
11
+ const options = { ...init, dispatcher };
12
+ return await fetch(url, options);
4
13
  }
5
14
  catch (error) {
6
15
  const cause = error instanceof Error ? error.cause : undefined;
@@ -8,7 +17,7 @@ export async function fetchGeminiWebResource(url, init = {}) {
8
17
  typeof cause === "object" &&
9
18
  "code" in cause &&
10
19
  cause.code === "UND_ERR_HEADERS_OVERFLOW") {
11
- throw new Error("Gemini response headers exceed Node's configured limit. Start Oracle with NODE_OPTIONS=--max-http-header-size=65536 (preserving any existing Node options) and retry.", { cause: error });
20
+ throw new Error("Gemini response headers exceed the HTTP transport's configured limit (Oracle's default is 64 KiB).", { cause: error });
12
21
  }
13
22
  throw error;
14
23
  }
@@ -11,16 +11,18 @@ import { checkRemoteHealth } from "./health.js";
11
11
  import { parseHostPort } from "../bridge/connection.js";
12
12
  import { BrowserRunCancelledError } from "../oracle/errors.js";
13
13
  import { resolveSiblingImagePath } from "../browser/chatgptImages.js";
14
- import { resolveBrowserProvider, REMOTE_GEMINI_UNSUPPORTED_MESSAGE } from "../browser/provider.js";
15
- export function createRemoteBrowserExecutor({ host, token }) {
14
+ import { resolveBrowserProvider, resolveRemoteBrowserModel } from "../browser/provider.js";
15
+ export function createRemoteBrowserExecutor({ host, token, runOptions }) {
16
16
  // Return a drop-in replacement for runBrowserMode so the browser session runner can stay unchanged.
17
17
  return async function remoteBrowserExecutor(options) {
18
- if (options.model !== undefined && !resolveBrowserProvider(options.model)) {
19
- throw new Error(`Unsupported browser model: ${options.model}. Remote services support ChatGPT only.`);
20
- }
21
- if (resolveBrowserProvider(options.model) === "gemini" ||
22
- resolveBrowserProvider(options.config?.desiredModel) === "gemini") {
23
- throw new Error(REMOTE_GEMINI_UNSUPPORTED_MESSAGE);
18
+ const model = resolveRemoteBrowserModel(options.model ?? runOptions?.model, options.config?.desiredModel);
19
+ const gemini = resolveBrowserProvider(model) === "gemini";
20
+ if (gemini &&
21
+ (runOptions?.editImage ||
22
+ runOptions?.generateImage ||
23
+ options.generateImagePath ||
24
+ options.outputPath)) {
25
+ throw new Error("Remote Gemini image generation and editing are not supported; run these requests locally.");
24
26
  }
25
27
  if (options.config?.researchMode === "search") {
26
28
  throw new Error("Web Search is a local browser pilot; --remote-host does not negotiate this capability yet. Use local Chrome or --browser-attach-running.");
@@ -46,8 +48,22 @@ export function createRemoteBrowserExecutor({ host, token }) {
46
48
  prompt: options.prompt,
47
49
  attachments: await serializeAttachments(options.attachments ?? []),
48
50
  fallbackSubmission: await serializeFallback(options.fallbackSubmission, { host, token }),
49
- browserConfig: options.config ?? {},
51
+ browserConfig: {
52
+ ...options.config,
53
+ // Keep old hosts fail-closed for Gemini even when the picker label is unrelated.
54
+ ...(gemini ? { desiredModel: model } : {}),
55
+ inlineCookies: null,
56
+ inlineCookiesSource: null,
57
+ },
50
58
  options: {
59
+ model,
60
+ ...(gemini
61
+ ? {
62
+ youtube: runOptions?.youtube,
63
+ geminiShowThoughts: runOptions?.geminiShowThoughts,
64
+ geminiAllowModelFallback: runOptions?.geminiAllowModelFallback,
65
+ }
66
+ : {}),
51
67
  heartbeatIntervalMs: options.heartbeatIntervalMs,
52
68
  verbose: options.verbose,
53
69
  sessionId: options.sessionId,
@@ -9,7 +9,7 @@ import { spawn, spawnSync } from "node:child_process";
9
9
  import { mkdtemp, rm, mkdir, writeFile, stat, realpath } from "node:fs/promises";
10
10
  import chalk from "chalk";
11
11
  import { materializeStagedFallbackBundle } from "../browser/prompt.js";
12
- import { runBrowserMode } from "../browserMode.js";
12
+ import { resolveBrowserExecutor } from "../browser/executor.js";
13
13
  import { resolveBrowserConfig } from "../browser/config.js";
14
14
  import { RunSlots } from "./runSlots.js";
15
15
  export { RunSlots } from "./runSlots.js";
@@ -19,7 +19,7 @@ import { getCookies } from "@steipete/sweet-cookie";
19
19
  import { CHATGPT_URL } from "../browser/constants.js";
20
20
  import { getCliVersion } from "../version.js";
21
21
  import { getOracleHomeDir } from "../oracleHome.js";
22
- import { resolveBrowserProvider, REMOTE_GEMINI_UNSUPPORTED_MESSAGE } from "../browser/provider.js";
22
+ import { resolveBrowserProvider, resolveRemoteBrowserModel } from "../browser/provider.js";
23
23
  import { cleanupStaleProfileState, readDevToolsPort, verifyDevToolsReachable, writeChromePid, writeDevToolsActivePort, } from "../browser/profileState.js";
24
24
  import { normalizeChatgptUrl } from "../browser/utils.js";
25
25
  import { computeFileSha256, resolveSessionArtifactsDir, sanitizeArtifactFilename, sanitizeArtifactMimeType, validateArtifactFile, } from "../browser/artifacts.js";
@@ -60,7 +60,6 @@ function validateAdmissionOptions(options) {
60
60
  throw new Error("--max-queued-runs must be a nonnegative integer.");
61
61
  }
62
62
  export async function createRemoteServer(options = {}, deps = {}) {
63
- const runBrowser = deps.runBrowser ?? runBrowserMode;
64
63
  const attachedBrowser = usesHostBrowserAttachment(options.browserConfig);
65
64
  const manualLoginDefault = !attachedBrowser && options.manualLoginDefault;
66
65
  const hostBrowserConfig = options.browserConfig
@@ -231,13 +230,20 @@ export async function createRemoteServer(options = {}, deps = {}) {
231
230
  await abandon();
232
231
  return;
233
232
  }
234
- if (resolveBrowserProvider(payload.browserConfig?.desiredModel) === "gemini") {
233
+ let model;
234
+ try {
235
+ model = resolveRemoteBrowserModel(payload.options.model, payload.browserConfig?.desiredModel);
236
+ if (resolveBrowserProvider(model) === "gemini" && payload.options.imageOutputRequested) {
237
+ throw new Error("Remote Gemini image generation and editing are not supported; run these requests locally.");
238
+ }
239
+ }
240
+ catch (error) {
235
241
  await abandon();
236
242
  if (!res.destroyed) {
237
243
  res.writeHead(400, { "Content-Type": "application/json" });
238
244
  res.end(JSON.stringify({
239
245
  error: "unsupported_browser_provider",
240
- message: REMOTE_GEMINI_UNSUPPORTED_MESSAGE,
246
+ message: error instanceof Error ? error.message : String(error),
241
247
  }));
242
248
  }
243
249
  return;
@@ -362,8 +368,16 @@ export async function createRemoteServer(options = {}, deps = {}) {
362
368
  logger(`[serve] Enforcing manual-login profile at ${options.manualLoginProfileDir ?? "default"} for remote run ${runId}`);
363
369
  }
364
370
  }
371
+ const runBrowser = deps.runBrowser ??
372
+ (await resolveBrowserExecutor({
373
+ model: model ?? "gpt-5.5",
374
+ youtube: typeof payload.options.youtube === "string" ? payload.options.youtube : undefined,
375
+ geminiShowThoughts: payload.options.geminiShowThoughts === true,
376
+ geminiAllowModelFallback: payload.options.geminiAllowModelFallback !== false,
377
+ }));
365
378
  const result = await runBrowser({
366
379
  prompt: payload.prompt,
380
+ model,
367
381
  attachments,
368
382
  fallbackSubmission,
369
383
  config: payload.browserConfig,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steipete/oracle",
3
- "version": "0.21.0",
3
+ "version": "0.21.1",
4
4
  "description": "CLI wrapper around OpenAI Responses API with GPT-5.6 Sol, GPT-5.6, GPT-5.5 Pro, GPT-5.5, GPT-5.4, GPT-5.2, GPT-5.1, and GPT-5.1 Codex high reasoning modes.",
5
5
  "keywords": [],
6
6
  "homepage": "https://askoracle.sh",
@@ -79,6 +79,7 @@
79
79
  "shiki": "^4.4.3",
80
80
  "toasted-notifier": "^10.1.0",
81
81
  "tokentally": "^0.1.6",
82
+ "undici": "^7.29.1",
82
83
  "zod": "^4.6.2"
83
84
  },
84
85
  "devDependencies": {