# llms-full (private-aware)
> Built from GitHub files and website pages. Large files may be truncated.

--- docs/anthropic.md ---
# Anthropic (Claude) Integration Plan

Status: **shipped** (November 20, 2025)  
Scope: API support for Claude 4.5 Sonnet and Claude 4.1 Opus in Oracle CLI.

## Models & Pricing (public list prices)
- **claude-sonnet-4-5** (CLI alias: `claude-4.5-sonnet`) — 200k context, ~$3 / 1M input tokens, ~$15 / 1M output tokens.
- **claude-opus-4-1** (CLI alias: `claude-4.1-opus`) — 200k context, ~$15 / 1M input tokens, ~$75 / 1M output tokens.
- Prompt-caching premium (not modeled in CLI costs): cached input portion >200k is billed higher (Sonnet ~$6 / 1M; Opus ~$18.75 / 1M).

## Requirements
- Environment: `ANTHROPIC_API_KEY` (required), `ANTHROPIC_BASE_URL` (optional; defaults to `https://api.anthropic.com`).
- Engine: **API only**. Browser mode is blocked for Claude.
- Tokenizer: `@anthropic-ai/tokenizer` (wrapped to accept Oracle’s array inputs). Estimates are approximate; rely on API `usage` for actual billing.

## Planned CLI Behavior
- Add models to `--model/--models`: `claude-4.5-sonnet`, `claude-4.1-opus`. Aliases: “sonnet”, “opus” map to those IDs.
- Background runs: **disabled** for Claude (`supportsBackground=false`). Even if `--background` is set, the run streams normally and logs a note.
- Search / tools: `web_search_preview` is ignored for Claude; `--search` is effectively off with a warning.
- Base URL: `--base-url` / `apiBaseUrl` applies per provider; falls back to `ANTHROPIC_BASE_URL` for Claude, `OPENAI_BASE_URL` for GPT.
- Cost display: uses the prices above; prompt-caching billing is not modeled (estimates are upper/lower bounds only).

## Usage Examples
- Single model (Sonnet):  
  ```bash
  oracle --engine api --model claude-4.5-sonnet --prompt "Summarize the design doc" --file docs/design.md
  ```
- High-reasoning (Opus) with files report:  
  ```bash
  oracle -m claude-4.1-opus --files-report --prompt "Analyze risk register" --file docs/risk.md
  ```
- Multi-model compare (GPT + Claude):  
  ```bash
  oracle --models gpt-5.1-pro,claude-4.5-sonnet --prompt "Propose mitigation steps" --file docs/plan.md
  ```
  Background stays off for Claude; GPT may still use background.

## Implementation Notes (for maintainers)
- Types/config: Claude entries use `apiModel` mapping to Anthropic IDs (`claude-sonnet-4-5`, `claude-opus-4-1`); Opus stays in `ProModelName`; pricing + 200k inputLimit; Anthropic tokenizer wrapper; `supportsBackground=false`. Opus gets `reasoning: high`.
- Client factory: branch on `claude*` to the Anthropic adapter (messages.stream/create); pass provider-specific `baseUrl`.
- Env selection: `ANTHROPIC_API_KEY` and `ANTHROPIC_BASE_URL`; log masked key per provider.
- Token estimates: wrapper flattens Oracle message arrays into text before calling `countTokens`.
- Multi-model: shared `runOptions.background` is gated by per-model `supportsBackground`; Claude never enters the background polling path.
- Docs to update alongside code: this file, `README.md` model list, `docs/configuration.md`, `docs/multimodel.md`.

## Limitations / Caveats
- Token estimates for Claude are approximate; API may still reject >200k inputs even if estimate passes.
- Prompt-caching cost deltas are not reflected in CLI estimates.
- No tool use/search for Claude in v1.

## Troubleshooting
- Missing key: “Missing ANTHROPIC_API_KEY…” — set the env var or pass `--api-key`.
- Background ignored: expected; Claude does not support the Responses-style job API.
- Search ignored: expected; Claude adapter currently drops `web_search_preview`.

## Next Steps (post-v1)
- Optional: add tool/use support with a provider-agnostic tool layer.
- Add dated model-id mapping if Anthropic starts versioned IDs (similar to Gemini resolver) — currently aliases map to the undated IDs above.
- Improve cost estimation if API exposes cached-token counters.


--- docs/browser-mode.md ---
# Browser Mode

Oracle’s `--engine browser` supports two different execution paths:

- **ChatGPT automation** (GPT-* models): drives the ChatGPT web UI with Chrome automation.
- **Gemini web mode** (Gemini models): talks directly to `gemini.google.com` using your signed-in Chrome cookies (no ChatGPT automation).

If you’re running Gemini, also see `docs/gemini.md`.

`oracle --engine browser` routes the assembled prompt bundle through the ChatGPT web UI instead of the Responses API. (Legacy `--browser` still maps to `--engine browser`, but it will be removed.) If you omit `--engine`, Oracle first honors any `engine` value in `~/.oracle/config.json`, then auto-picks API when `OPENAI_API_KEY` is available and falls back to browser otherwise. The CLI writes the same session metadata/logs as API runs, and by default pastes the payload into ChatGPT via a temporary Chrome profile (manual-login mode can reuse a persistent automation profile).

`--preview` now works with `--engine browser`: it renders the composed prompt, lists which files would be uploaded vs inlined, and shows the bundle location when bundling is enabled, without launching Chrome.

## Quick example: browser mode with custom cookies

```bash
# Minimal inline-cookies flow: keep ChatGPT logged in without Keychain
jq '.' ~/.oracle/cookies.json  # file must contain CookieParam[]
oracle --engine browser \
  --browser-inline-cookies-file ~/.oracle/cookies.json \
  --model "GPT-5.2 Pro" \
  -p "Run the UI smoke" \
  --file "src/**/*.ts" --file "!src/**/*.test.ts"
```

`~/.oracle/cookies.json` should be a JSON array shaped like:

```json
[
  { "name": "__Secure-next-auth.session-token", "value": "<token>", "domain": "chatgpt.com", "path": "/", "secure": true, "httpOnly": true },
  { "name": "_account", "value": "personal", "domain": "chatgpt.com", "path": "/", "secure": true }
]
```

You can pass the same payload inline (`--browser-inline-cookies '<json or base64>'`) or via env (`ORACLE_BROWSER_COOKIES_JSON`, `ORACLE_BROWSER_COOKIES_FILE`). Cloudflare cookies (`cf_clearance`, `__cf_bm`, etc.) are only needed when you hit a challenge.

## Current Pipeline

1. **Prompt assembly** – we reuse the normal prompt builder (`buildPrompt`) and the markdown renderer. Browser mode pastes the system + user text (no special markers) into the ChatGPT composer and, by default, pastes resolved file contents inline until the total pasted content reaches ~60k characters (then switches to uploads).
2. **Automation stack** – code lives in `src/browserMode.ts` and is a lightly refactored version of the `oraclecheap` utility:
   - Launches Chrome via `chrome-launcher` and connects with `chrome-remote-interface`.
   - (Optional) copies cookies from the requested browser profile via Oracle’s built-in cookie reader (Keychain/DPAPI aware) so you stay signed in.
   - Navigates to `chatgpt.com`, switches the model to the requested **GPT-5.2** variant (Auto/Thinking/Instant/Pro), pastes the prompt, waits for completion, and copies the markdown via the built-in “copy turn” button.
   - Immediately probes `/backend-api/me` in the ChatGPT tab to verify the session is authenticated; if the endpoint returns 401/403 we abort early with a login-specific error instead of timing out waiting for the composer.
   - When `--file` inputs would push the pasted composer content over ~60k characters, we switch to uploading attachments (optionally bundled) and wait for ChatGPT to re-enable the send button before submitting the combined system+user prompt.
   - Cleans up the temporary profile unless `--browser-keep-browser` is passed.
3. **Session integration** – browser sessions use the normal log writer, add `mode: "browser"` plus `browser.config/runtime` metadata, and log the Chrome PID/port so `oracle session <id>` (or `oracle status <id>`) shows a marker for the background Chrome process.
4. **Usage accounting** – we estimate input tokens with the same tokenizer used for API runs and estimate output tokens via `estimateTokenCount`. `oracle status` therefore shows comparable cost/timing info even though the call ran through the browser.

### CLI Options

- `--engine browser`: enables browser mode (legacy `--browser` remains as an alias for now). Without `--engine`, Oracle chooses API when `OPENAI_API_KEY` exists, otherwise browser.
- `--browser-chrome-profile`, `--browser-chrome-path`: cookie source + binary override (defaults to the standard `"Default"` Chrome profile so existing ChatGPT logins carry over).
- `--browser-cookie-path`: explicit path to the Chrome/Chromium/Edge `Cookies` SQLite DB. Handy when you launch a fork via `--browser-chrome-path` and want to copy its session cookies; see [docs/chromium-forks.md](chromium-forks.md) for examples.
- `--chatgpt-url`: override the ChatGPT base URL. Works with the root homepage (`https://chatgpt.com/`) **or** a specific workspace/folder link such as `https://chatgpt.com/g/.../project`. `--browser-url` stays as a hidden alias.
- `--browser-timeout`, `--browser-input-timeout`: `1200s (20m)`/`30s` defaults. Durations accept `ms`, `s`, `m`, or `h` and can be chained (`1h2m10s`).
- `--browser-model-strategy <select|current|ignore>`: control ChatGPT model selection. `select` (default) switches to the requested model; `current` keeps the active model and logs its label; `ignore` skips the picker entirely. (Ignored for Gemini web runs.)
- `--browser-thinking-time <light|standard|extended|heavy>`: set the ChatGPT thinking-time intensity (Thinking/Pro models only). You can also set a default in `~/.oracle/config.json` via `browser.thinkingTime`.
- `--browser-port <port>` (alias: `--browser-debug-port`; env: `ORACLE_BROWSER_PORT`/`ORACLE_BROWSER_DEBUG_PORT`): pin the DevTools port (handy on WSL/Windows firewalls). When omitted, a random open port is chosen.
- `--browser-no-cookie-sync`, `--browser-manual-login` (persistent automation profile + user-driven login), `--browser-headless`, `--browser-hide-window`, `--browser-keep-browser`, and the global `-v/--verbose` flag for detailed automation logs.
- `--browser-url`: override ChatGPT base URL if needed.
- `--browser-attachments <auto|never|always>`: control how `--file` inputs are delivered in browser mode. Default `auto` pastes file contents inline up to ~60k characters and switches to uploads above that.
- `--browser-inline-files`: alias for `--browser-attachments never` (forces inline paste; never uploads attachments).
- `--browser-bundle-files`: bundle all resolved attachments into a single temp file before uploading (only used when uploads are enabled/selected).
- sqlite bindings: automatic rebuilds now require `ORACLE_ALLOW_SQLITE_REBUILD=1`. Without it, the CLI logs instructions instead of running `pnpm rebuild` on your behalf.
- `--model`: the same flag used for API runs is accepted, but the ChatGPT automation path only supports **GPT-5.2** variants (Auto/Thinking/Instant/Pro). Use `gpt-5.2`, `gpt-5.2-thinking`, `gpt-5.2-instant`, or `gpt-5.2-pro`. Other GPT families still require API mode.
- Cookie sync is mandatory—if we can’t copy cookies from Chrome, the run exits early. Use the hidden `--browser-allow-cookie-errors` flag only when you’re intentionally running logged out (it skips the early exit but still warns).
- Experimental cookie controls (hidden flags/env):
  - `--browser-cookie-names <comma-list>` or `ORACLE_BROWSER_COOKIE_NAMES`: allowlist which cookies to sync. Useful for “only NextAuth/Cloudflare, drop the rest.”
  - `--browser-cookie-wait <ms|s|m>`: if cookie sync fails or returns no cookies, wait once and retry (helps when macOS Keychain prompts are slow).
  - `--browser-inline-cookies <jsonOrBase64>` or `ORACLE_BROWSER_COOKIES_JSON`: skip Chrome/keychain and set cookies directly. Payload is a JSON array of DevTools `CookieParam` objects (or the same, base64-encoded). At minimum you need `name`, `value`, and either `url` or `domain`; we infer `path=/`, `secure=true`, `httpOnly=false`.
  - `--browser-inline-cookies-file <path>` or `ORACLE_BROWSER_COOKIES_FILE`: load the same payload from disk (JSON or base64 JSON). If no args/env are provided, Oracle also auto-loads `~/.oracle/cookies.json` or `~/.oracle/cookies.base64` when present.
  - Practical minimal set that keeps ChatGPT logged in and avoids the workspace picker: `__Secure-next-auth.session-token` (include `.0`/`.1` variants) and `_account` (active workspace/account). Cloudflare proofs (`cf_clearance`, `__cf_bm`/`_cfuvid`/`CF_Authorization`/`__cflb`) are only needed when a challenge is active. In practice our allowlist pulls just two cookies (session token + `_account`) and works; add the Cloudflare names if you hit a challenge.
  - Inline payload shape example (we ignore extra fields like `expirationDate`, `sameSite`, `hostOnly`):  
    ```json
    [
      { "name": "__Secure-next-auth.session-token", "value": "<token>", "domain": "chatgpt.com", "path": "/", "secure": true, "httpOnly": true, "expires": 1771295753 },
      { "name": "_account", "value": "personal", "domain": "chatgpt.com", "path": "/", "secure": true, "httpOnly": false, "expires": 1770702447 }
    ]
    ```

All options are persisted with the session so reruns (`oracle exec <id>`) reuse the same automation settings.

### Manual login mode (persistent profile, no cookie copy)

Use `--browser-manual-login` when cookie decrypt is blocked (e.g., Windows app-bound cookies) or you prefer to sign in explicitly. You can also make it the default via `browser.manualLogin` in `~/.oracle/config.json`.

```bash
oracle --engine browser \
  --browser-manual-login \
  --browser-keep-browser \
  --model "GPT-5.2 Pro" \
  -p "Say hi"
```

- Oracle launches Chrome headful with a persistent automation profile at `~/.oracle/browser-profile` (override with `ORACLE_BROWSER_PROFILE_DIR` or `browser.manualLoginProfileDir` in `~/.oracle/config.json`).
- Log into chatgpt.com in that window the first time; Oracle polls until the session is active, then proceeds.
- Reuse the same profile on subsequent runs (no re-login unless the session expires).
- Add `--browser-keep-browser` (or config `browser.keepBrowser=true`) when doing the initial login/setup or debugging so the Chrome window stays open after the run. When omitted, Oracle closes Chrome but preserves the profile on disk.
- Cookie copy is skipped by default in this mode. To automate manual-login runs, set `browser.manualLoginCookieSync=true` in `~/.oracle/config.json` to seed the persistent profile from your existing Chrome cookies; inline cookies apply when cookie sync is enabled.
- If Chrome is already running with that profile and DevTools remote debugging enabled (see `DevToolsActivePort` in the profile dir), you can reuse it instead of relaunching by pointing Oracle at it with `--remote-chrome <host:port>`.

## Remote Chrome Sessions (headless/server workflows)

Oracle can reuse an already-running Chrome/Edge instance on another machine by tunneling over the Chrome DevTools Protocol. This is handy when:

- Your CLI runs on a headless server (Linux/macOS CI, remote mac minis, etc.) but you want the browser UI to live on a desktop where you can see uploads or respond to Captcha challenges.
- You want to keep a single signed-in profile open (e.g., Windows VM with company SSO) while sending prompts from other hosts.

### 1. Start Chrome with remote debugging enabled

On the machine that should host the browser window:

```bash
google-chrome \
  --remote-debugging-port=9222 \
  --remote-debugging-address=0.0.0.0 \
  --user-data-dir=/path/to/profile \
  --profile-directory='Default'
```

Notes:

- Any Chromium flavor works (Chrome, Edge, Vivaldi, etc.)—just ensure CDP is exposed on a reachable host:port. Linux distributions often call the binary `google-chrome-stable`. On macOS you can run `/Applications/Google Chrome.app/Contents/MacOS/Google Chrome`.
- `--remote-debugging-address=0.0.0.0` is required if the CLI connects from another machine. Lock it down behind a VPN or SSH tunnel if the network is untrusted.
- Keep this browser window open and signed into ChatGPT; Oracle will reuse that session and **will not** copy cookies over the wire.

### 2. Point Oracle at the remote browser

From the machine running `oracle`:

```bash
oracle --engine browser \
  --remote-chrome 192.168.1.10:9222 \
  --prompt "Summarize the latest incident doc" \
  --file docs/incidents/latest.md
```

Key behavior:

- Use IPv6 by wrapping the host in brackets, e.g. `--remote-chrome "[2001:db8::1]:9222"`.
- Local-only flags like `--browser-headless`, `--browser-hide-window`, `--browser-keep-browser`, and `--browser-chrome-path` are ignored because Oracle no longer launches Chrome. You still get verbose logging, model switching, attachment uploads, and markdown capture.
- Cookie sync is skipped automatically (the remote browser already has cookies). If you need inline cookies, use them on the machine that’s actually running Chrome.
- Oracle opens a dedicated CDP target (new tab) for each run and closes it afterward so your existing tabs stay untouched.
- Attachments are transferred via CDP: Oracle reads each file locally, base64-encodes it, and uses `DataTransfer` inside the remote browser to populate the upload field. Files larger than 20 MB are rejected to keep CDP messages reasonable.
- When the remote WebSocket disconnects, Oracle errors with “Remote Chrome connection lost…” so you can re-run after restarting the browser.

### 3. Troubleshooting

- Run `scripts/test-remote-chrome.ts <host> [port]` to sanity-check connectivity (`npx tsx scripts/test-remote-chrome.ts my-host 9222`).
- If you target IPv6 without brackets (e.g., `2001:db8::1:9222`), the CLI rejects it—wrap the address like `[2001:db8::1]:9222`.
- Ensure firewalls allow inbound TCP to the debugging port and that you’re not behind a captive proxy stripping WebSocket upgrades.
- Because we do not control the remote lifecycle, Chrome stays running after the session. Shut it down manually when you’re done or remove `--remote-debugging-port` to stop exposing CDP.

### Remote Service Mode (`oracle serve`)

Prefer to keep Chrome entirely on the remote Mac (no DevTools tunneling, no manual cookie shuffling)? Use the built-in service:

1. **Start the host**
   ```bash
   oracle serve
   ```
   Oracle picks a free port, launches Chrome, starts an HTTP/SSE API, and prints:
   ```
   Listening at 0.0.0.0:9473
   Access token: c4e5f9...
   ```
   Use `--host`, `--port`, or `--token` to override the defaults if needed.
   If the host Chrome profile is not signed into ChatGPT, the service opens chatgpt.com for login and exits—sign in, then restart `oracle serve`.

2. **Run from your laptop**
   ```bash
   oracle --engine browser \
     --remote-host 192.168.64.2:9473 \
     --remote-token c4e5f9... \
   --prompt "Summarize the incident doc" \
    --file docs/incidents/latest.md
   ```

   - `--remote-host` points the CLI at the VM.
   - `--remote-token` matches the token printed by `oracle serve` (set `ORACLE_REMOTE_TOKEN` to avoid repeating it).
   - You can also set defaults in `~/.oracle/config.json` (`remote.host`, `remote.token`) so you don’t need the flags; env vars still override those when present.
   - Cookies are **not** transferred from your laptop. The service requires the host Chrome profile to be signed in; if not, it opens chatgpt.com and exits so you can log in, then restart `oracle serve`.

3. **What happens**
   - The CLI assembles the composed prompt + file bundle locally, sends them to the VM, and streams log lines/answer text back through the same HTTP connection.
   - The remote host runs Chrome locally, pulls ChatGPT cookies from its own Chrome profile, and reuses them across runs while the service is up. If cookies are missing, the service exits after opening chatgpt.com so you can sign in before restarting.
   - Background/detached sessions (`--no-wait`) are disabled in remote mode so the CLI can keep streaming output.
   - `oracle serve` logs the DevTools port of the manual-login Chrome (e.g., `Manual-login Chrome DevTools port: 54371`). Runs automatically attach to that logged-in Chrome; you can use the printed port/JSON URL for debugging if needed.

4. **Stop the host**
   - `Ctrl+C` on the VM shuts down the HTTP server and Chrome. Restart `oracle serve` whenever you need a new session; omit `--token` to let it rotate automatically.

This mode is ideal when you have a macOS VM (or spare Mac mini) logged into ChatGPT and you just want to run the CLI from another machine without ever copying profiles or keeping Chrome visible locally.

## Limitations / Follow-Up Plan

- **Attachment lifecycle** – in `auto` mode we prefer inlining files into the composer (fewer moving parts). When we do upload, each `--file` path is uploaded separately (or bundled) so ChatGPT can ingest filenames/content. The automation waits for uploads to finish (send button enabled, upload chips visible) before submitting. When inline paste is rejected by ChatGPT (too large), Oracle retries automatically with uploads.
- **Model picker drift** – we rely on heuristics to pick GPT-5.2 variants. If OpenAI changes the DOM we need to refresh the selectors quickly. Consider snapshot tests or a small “self check” command.
- **Non-mac platforms** – window hiding uses AppleScript today; Linux/Windows just ignore the flag. We should detect platforms explicitly and document the behavior.
- **Streaming UX** – browser runs cannot stream tokens, so we log a warning before launching Chrome. Investigate whether we can stream clipboard deltas via mutation observers for a closer UX.

## Testing Notes

- ChatGPT automation smoke: `pnpm test:browser`
- Gemini web (cookie) smoke: `ORACLE_LIVE_TEST=1 pnpm vitest run tests/live/gemini-web-live.test.ts` (requires a signed-in Chrome profile at `gemini.google.com`)
- `pnpm test --filter browser` does not exist yet; manual runs with `--engine browser -v` are the current validation path.
- Most of the heavy lifting lives in `src/browserMode.ts`. If you change selectors or the mutation observer logic, run a local `oracle --engine browser --browser-keep-browser` session so you can inspect DevTools before cleanup.


## Links discovered
- [docs/chromium-forks.md](https://github.com/steipete/oracle/blob/main/docs/chromium-forks.md)

--- docs/chromium-forks.md ---
# Chromium-based browsers (Chromium, Edge, Brave variants)

Oracle’s browser engine assumes Google Chrome by default: it launches Chrome via `chrome-launcher` and copies cookies from Chrome’s profile/keychain so you stay signed in to ChatGPT. Chromium, Microsoft Edge, and other forks ship the same DevTools protocol, but they keep the executable and cookie store in different locations. Use the knobs below to point Oracle at those assets explicitly.

## 1. Point Oracle at the right executable

Either pass the CLI flag or set it once in `~/.oracle/config.json`:

- CLI: `oracle --engine browser --browser-chrome-path "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge" …`
- Config:
  ```json5
  {
    browser: {
      chromePath: "/Applications/Chromium.app/Contents/MacOS/Chromium"
    }
  }
  ```

`--browser-chrome-path` (also exposed in `oracle --debug-help`) controls which binary `chrome-launcher` starts. You can still keep `chromeProfile: "Default"` if you want to copy cookies from Chrome proper while launching Edge/Chromium.

## 2. Tell cookie sync where your session lives

Set the new `--browser-cookie-path` flag (or `browser.chromeCookiePath` in config) to the absolute path of the fork’s `Cookies` SQLite database. When present, Oracle feeds this path straight into the internal cookie reader, skipping Chrome-only heuristics and profile-name guesses.

```bash
oracle --engine browser \
  --browser-chrome-path "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge" \
  --browser-cookie-path "$HOME/Library/Application Support/Microsoft Edge/Profile 1/Cookies" \
  --prompt "Summarize the release notes"
```

Config example (JSON5):

```json5
{
  browser: {
    chromePath: "/usr/bin/chromium",
    chromeCookiePath: "/home/you/.config/chromium/Default/Cookies",
    chromeProfile: null
  }
}
```

If you omit `chromeCookiePath`, Oracle falls back to `chromeProfile` (name or explicit path). Providing both keeps things unambiguous.

## Common cookie DB paths

| Browser | macOS | Linux | Windows |
| --- | --- | --- | --- |
| Chrome (default) | `~/Library/Application Support/Google/Chrome/Default/Cookies` | `~/.config/google-chrome/Default/Cookies` | `%LOCALAPPDATA%/Google/Chrome/User Data/Default/Network/Cookies` |
| Chromium | `~/Library/Application Support/Chromium/Default/Cookies` | `~/.config/chromium/Default/Cookies` | `%LOCALAPPDATA%/Chromium/User Data/Default/Network/Cookies` |
| Microsoft Edge | `~/Library/Application Support/Microsoft Edge/Default/Cookies` (profiles are `Profile 1`, `Profile 2`, …) | `~/.config/microsoft-edge/Default/Cookies` | `%LOCALAPPDATA%/Microsoft/Edge/User Data/Default/Network/Cookies` |

Brave and other forks work the same way—inspect `%APPDATA%`/`~/Library/Application Support`/`~/.config` for their `Cookies` file and pass its full path to `--browser-cookie-path`.

### macOS / Windows encryption caveat

Oracle now detects the right Keychain/DPAPI label based on the cookie path (`Chrome Safe Storage`, `Chromium Safe Storage`, `Microsoft Edge Safe Storage`, etc.) and pulls the key automatically. If macOS asks for Keychain access, approve it. When the system doesn’t expose that secret (e.g., the browser hasn’t stored any cookies yet), fall back to `--browser-inline-cookies[(-file)]` until you can sign in once via the target browser.

## Troubleshooting checklist

- `oracle --debug-help` lists both `--browser-chrome-path` and `--browser-cookie-path`.
- Run with `-v` to verify which cookie source Oracle is using (Chrome profile, inline payload, or explicit path).
- If cookie sync fails with “Chrome Safe Storage” prompts while using another fork, fall back to inline cookies until the fork’s password store is supported.
- `CHROME_PATH` still works as a last-resort override for the executable; config + CLI flags are preferred because they’re persisted per workspace.


--- docs/configuration.md ---
# Local configuration (JSON5)

Oracle reads an optional per-user config from `~/.oracle/config.json`. The file uses JSON5 parsing, so trailing commas and comments are allowed.

## Example (`~/.oracle/config.json`)

```json5
{
  // Default engine when neither CLI flag nor env decide
  engine: "api",           // or "browser"
  model: "gpt-5.1-pro",    // API alias → gpt-5.2-pro
  search: "on",            // "on" | "off"

  notify: {
    enabled: true,          // default notifications (still auto-mutes in CI/SSH unless forced on)
    sound: false,           // play a sound on completion
    muteIn: ["CI", "SSH"], // auto-disable when these env vars are set
  },

  browser: {
    chromeProfile: "Default",
    chromePath: null,
    chromeCookiePath: null,
    chatgptUrl: "https://chatgpt.com/", // root is fine; folder URLs also work
    url: null, // alias for chatgptUrl (kept for back-compat)
    debugPort: null,          // fixed DevTools port (env: ORACLE_BROWSER_PORT / ORACLE_BROWSER_DEBUG_PORT)
    timeoutMs: 1200000,
    inputTimeoutMs: 30000,
    cookieSyncWaitMs: 0,      // wait (ms) before retrying cookie sync when Chrome cookies are empty/locked
    modelStrategy: "select", // select | current | ignore (ChatGPT only; ignored for Gemini web)
    thinkingTime: "extended", // light | standard | extended | heavy (ChatGPT Thinking/Pro models)
    manualLogin: false,        // set true to reuse a persistent automation profile and sign in once (Windows defaults to true when unset)
    manualLoginProfileDir: null, // override profile dir (or set ORACLE_BROWSER_PROFILE_DIR)
    headless: false,
    hideWindow: false,
    keepBrowser: false,
    manualLoginCookieSync: false, // allow cookie sync even in manual-login mode
  },

  // Default target for `oracle serve` remote browser runs
  remote: {
    host: "192.168.64.2:9473",
    token: "c4e5f9...", // printed by `oracle serve`
  },

  // Azure OpenAI defaults (only used when endpoint is set)
  azure: {
    endpoint: "https://your-resource-name.openai.azure.com/",
    deployment: "gpt-5-1-pro",
    apiVersion: "2024-02-15-preview"
  },

  heartbeatSeconds: 30,     // default heartbeat interval
  filesReport: false,       // default per-file token report
  background: true,         // default background mode for API runs
  sessionRetentionHours: 72, // prune cached sessions older than 72h before each run (0 disables)
  promptSuffix: "// signed-off by me", // appended to every prompt
  apiBaseUrl: "https://api.openai.com/v1" // override for LiteLLM / custom gateways
}
```

## Precedence

CLI flags → `config.json` → environment → built-in defaults.

- `engine`, `model`, `search`, `filesReport`, `heartbeatSeconds`, and `apiBaseUrl` in `config.json` override the auto-detected values unless explicitly set on the CLI.
- If `azure.endpoint` (or `--azure-endpoint`) is set, Oracle reads `AZURE_OPENAI_API_KEY` first and falls back to `OPENAI_API_KEY` for GPT models.
- Remote browser defaults follow the same order: `--remote-host/--remote-token` win, then `remote.host` / `remote.token` (or `remoteHost` / `remoteToken`) in the config, then `ORACLE_REMOTE_HOST` / `ORACLE_REMOTE_TOKEN` if still unset.
- `OPENAI_API_KEY` only influences engine selection when neither the CLI nor `config.json` specify an engine (API when present, otherwise browser).
- `ORACLE_NOTIFY*` env vars still layer on top of the config’s `notify` block.
- `sessionRetentionHours` controls the default value for `--retain-hours`. When unset, `ORACLE_RETAIN_HOURS` (if present) becomes the fallback, and the CLI flag still wins over both.
- `browser.chatgptUrl` accepts either the root ChatGPT URL (`https://chatgpt.com/`) or a folder/workspace URL (e.g., `https://chatgpt.com/g/.../project`); `browser.url` remains as a legacy alias.
- Browser automation defaults can be set under `browser.*`, including `browser.manualLogin`, `browser.manualLoginProfileDir`, and `browser.thinkingTime` (CLI override: `--browser-thinking-time`). On Windows, `browser.manualLogin` defaults to `true` when omitted.

If the config is missing or invalid, Oracle falls back to defaults and prints a warning for parse errors.

Chromium-based browsers usually need both `chromePath` (binary) and `chromeCookiePath` (cookie DB) set so automation can launch the right executable and reuse your login. See [docs/chromium-forks.md](chromium-forks.md) for detailed paths per browser/OS.

## Session retention

Each invocation can optionally prune cached sessions before starting new work:

- `--retain-hours <n>` deletes sessions older than `<n>` hours right before the run begins. Use `0` (or omit the flag) to skip pruning.
- In `config.json`, set `sessionRetentionHours` to apply pruning automatically for every CLI/TUI/MCP invocation.
- Set `ORACLE_RETAIN_HOURS` in the environment to override the config on shared machines without editing the JSON file.

Under the hood, pruning removes entire session directories (metadata + logs). The command-line cleanup command (`oracle session --clear`) still exists when you need to wipe everything manually.

## API timeouts

- `--timeout <seconds|auto>` controls the overall API deadline for a run.
- Defaults: `auto` = 60 m for `gpt-5.1-pro`; non-pro API models use `120s` if you don’t set a value.
- Heartbeat messages print the live remaining time so you can see when the client-side deadline will fire.


## Links discovered
- [docs/chromium-forks.md](https://github.com/steipete/oracle/blob/main/docs/chromium-forks.md)

--- scripts/docs-list.ts ---
#!/usr/bin/env tsx

import { readdirSync, readFileSync } from 'node:fs';
import { dirname, join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
import { compact } from 'es-toolkit';

const docsListFile = fileURLToPath(import.meta.url);
const docsListDir = dirname(docsListFile);
const DOCS_DIR = join(docsListDir, '..', 'docs');

const EXCLUDED_DIRS = new Set(['archive', 'research']);

function walkMarkdownFiles(dir: string, base: string = dir): string[] {
  const entries = readdirSync(dir, { withFileTypes: true });
  const files: string[] = [];
  for (const entry of entries) {
    if (entry.name.startsWith('.')) {
      continue;
    }
    const fullPath = join(dir, entry.name);
    if (entry.isDirectory()) {
      if (EXCLUDED_DIRS.has(entry.name)) {
        continue;
      }
      files.push(...walkMarkdownFiles(fullPath, base));
    } else if (entry.isFile() && entry.name.endsWith('.md')) {
      files.push(relative(base, fullPath));
    }
  }
  return files.sort((a, b) => a.localeCompare(b));
}

function extractMetadata(fullPath: string): {
  summary: string | null;
  readWhen: string[];
  error?: string;
} {
  const content = readFileSync(fullPath, 'utf8');

  if (!content.startsWith('---')) {
    return { summary: null, readWhen: [], error: 'missing front matter' };
  }

  const endIndex = content.indexOf('\n---', 3);
  if (endIndex === -1) {
    return { summary: null, readWhen: [], error: 'unterminated front matter' };
  }

  const frontMatter = content.slice(3, endIndex).trim();
  const lines = frontMatter.split('\n');

  let summaryLine: string | null = null;
  const readWhen: string[] = [];
  let collectingField: 'read_when' | null = null;

  for (const rawLine of lines) {
    const line = rawLine.trim();

    if (line.startsWith('summary:')) {
      summaryLine = line;
      collectingField = null;
      continue;
    }

    if (line.startsWith('read_when:')) {
      collectingField = 'read_when';
      const inline = line.slice('read_when:'.length).trim();
      if (inline.startsWith('[') && inline.endsWith(']')) {
        try {
          const parsed = JSON.parse(inline.replace(/'/g, '"')) as unknown;
          if (Array.isArray(parsed)) {
            readWhen.push(...compact(parsed.map((item) => String(item).trim())));
          }
        } catch {
          // ignore malformed inline arrays
        }
      }
      continue;
    }

    if (collectingField === 'read_when') {
      if (line.startsWith('- ')) {
        const hint = line.slice(2).trim();
        if (hint) {
          readWhen.push(hint);
        }
      } else if (line === '') {
      } else {
        collectingField = null;
      }
    }
  }

  if (!summaryLine) {
    return { summary: null, readWhen, error: 'summary key missing' };
  }

  const summaryValue = summaryLine.slice('summary:'.length).trim();
  const normalized = summaryValue
    .replace(/^['"]|['"]$/g, '')
    .replace(/\s+/g, ' ')
    .trim();

  if (!normalized) {
    return { summary: null, readWhen, error: 'summary is empty' };
  }

  return { summary: normalized, readWhen };
}

console.log('Listing all markdown files in docs folder:');

const markdownFiles = walkMarkdownFiles(DOCS_DIR);

for (const relativePath of markdownFiles) {
  const fullPath = join(DOCS_DIR, relativePath);
  const { summary, readWhen, error } = extractMetadata(fullPath);
  if (summary) {
    console.log(`${relativePath} - ${summary}`);
    if (readWhen.length > 0) {
      console.log(`  Read when: ${readWhen.join('; ')}`);
    }
  } else {
    const reason = error ? ` - [${error}]` : '';
    console.log(`${relativePath}${reason}`);
  }
}

console.log(
  '\nReminder: keep docs up to date as behavior changes. When your task matches any "Read when" hint above (React hooks, cache directives, database work, tests, etc.), read that doc before coding, and suggest new coverage when it is missing.'
);


--- docs/gemini.md ---
# Gemini Integration

Oracle supports Gemini in two distinct ways:

1. **Gemini API mode** (`--engine api`) via `GEMINI_API_KEY`
2. **Gemini web (cookie) mode** (`--engine browser`) via your signed-in Chrome cookies at `gemini.google.com` (no API key required)

## Usage (API)

1. **Get an API Key:** Obtain a key from [Google AI Studio](https://aistudio.google.com/).
2. **Set Environment Variable:** Export the key as `GEMINI_API_KEY`.
   ```bash
   export GEMINI_API_KEY="your-google-api-key"
   ```
3. **Run Oracle:** Use the `--model` (or `-m`) flag to select Gemini.
   ```bash
   oracle --engine api --model gemini --prompt "Explain quantum entanglement"
   ```
   You can also use the explicit model ID:
   ```bash
   oracle --engine api --model gemini-3-pro --prompt "..."
   ```

## Usage (Gemini web / cookies)

Gemini web mode is a cookie-based client for `gemini.google.com`. It does **not** use `GEMINI_API_KEY` and does **not** drive ChatGPT.

Prereqs:
- Chrome installed.
- Signed into `gemini.google.com` in the Chrome profile Oracle uses (default: `Default` profile).

Examples:
```bash
# Text run
oracle --engine browser --model gemini-3-pro --prompt "Say OK."

# Generate an image (writes an output file)
oracle --engine browser --model gemini-3-pro \
  --prompt "a cute robot holding a banana" \
  --generate-image out.jpg --aspect 1:1

# Edit an image (input via --edit-image, output via --output)
oracle --engine browser --model gemini-3-pro \
  --prompt "add sunglasses" \
  --edit-image in.png --output out.jpg
```

Notes:
- If your logged-in Gemini account can’t access “Pro”, Oracle will auto-fallback to a supported model for web runs (and logs the fallback in verbose mode).
- This path runs fully in Node/TypeScript (no Python/venv dependency).
- `--browser-model-strategy` only affects ChatGPT automation; Gemini web always uses the explicit Gemini model ID.

## Implementation details

### Gemini API adapter

- `src/oracle/gemini.ts` — adapter using `@google/genai` that returns a `ClientLike`.
  - Model IDs: `gemini-3-pro` maps to the provider ID (currently `gemini-3-pro-preview`).
  - Request mapping: `OracleRequestBody` → Gemini request; `web_search_preview` maps to Gemini search tooling.
  - Response mapping: Gemini responses → `OracleResponse`.
  - Streaming: wraps Gemini’s async iterator as `ResponseStreamLike`.
- `src/oracle/run.ts` — selects `GEMINI_API_KEY` vs `OPENAI_API_KEY` based on model prefix.
- `src/oracle/config.ts` / `src/oracle/types.ts` — model config + `ModelName`.

### Gemini web client (cookie-based)

- `src/gemini-web/client.ts` — talks to `gemini.google.com` and downloads generated images via authenticated `gg-dl` redirects.
- `src/gemini-web/executor.ts` — browser-engine executor for Gemini (loads Chrome cookies and runs the web client).

## Testing

- Unit/regression: `pnpm vitest run tests/gemini.test.ts tests/gemini-web`
- Live (API): `ORACLE_LIVE_TEST=1 pnpm vitest run tests/live/gemini-live.test.ts`
- Live (Gemini web/cookies): `ORACLE_LIVE_TEST=1 pnpm vitest run tests/live/gemini-web-live.test.ts`


## Links discovered
- [Google AI Studio](https://aistudio.google.com/)

--- docs/grok.md ---
# Grok 4.1 (xAI) Support

Status: **experimental** (November 21, 2025)  
Owner: Oracle CLI

- Model key: `grok-4.1` (mapped to API id `grok-4-1-fast-reasoning`). Alias: `grok`.
- Endpoint: defaults to `https://api.x.ai/v1` or `XAI_BASE_URL`. Uses the OpenAI **Responses API** surface.
- Auth: `XAI_API_KEY`.
- Background runs: **not supported** by the Grok API (requests with `background: true` are rejected). Oracle forces foreground streaming even if `--background` is set.
- Search tools: Grok expects `web_search`; OpenAI’s `web_search_preview` is not accepted.
- Pricing (preview): $0.20 / 1M input tokens, $0.50 / 1M output tokens; 2M token context.

Notes:
- If you supply `--base-url`, it overrides the default xAI endpoint.
- Browser engine is not supported for Grok; Oracle coerces `--engine browser` to `api`.


--- docs/linux.md ---
# Linux Notes

- Browser engine now works on Linux (Chrome/Chromium/Edge) without the old `DISPLAY` guard. Oracle will launch whatever `chrome-launcher` finds or what you pass via `CHROME_PATH`.
- Cookie sync supports snap-installed Chromium automatically. Common cookie DB for the Default profile:
  - `~/snap/chromium/common/chromium/Default/Cookies`
- If you use a non-default profile or a custom install, point Oracle at the correct paths:
  - `--browser-chrome-path /path/to/chrome`
  - `--browser-cookie-path /path/to/profile/Default/Cookies`
- Browser runs are headful (Cloudflare blocks headless). Keep a compositor/virtual display running if you don’t have a desktop session.
- If cookie sync still can’t find your DB, rerun with `--browser-allow-cookie-errors --browser-no-cookie-sync` and sign in manually, or dump the session cookies with `--browser-inline-cookies-file`.


--- docs/manual-tests.md ---
# Manual Test Suite (Browser Mode + Live API)

These checks validate the real Chrome automation path and the optional live
Responses API smoke suite. Run the browser steps whenever you touch Chrome
automation (lifecycle, cookie sync, prompt injection, Markdown capture, etc.),
and run the live API suite before shipping major transport changes.

## Prerequisites

- macOS with Chrome installed (default profile signed in to ChatGPT Pro).
- Node 22+ and `pnpm install` already completed.
- Headful display access (no `--browser-headless`).
- When debugging, add `--browser-keep-browser` so Chrome stays open after Oracle exits, then connect with `pnpm exec tsx scripts/browser-tools.ts ...` (screenshot, eval, DOM picker, etc.).
- Ensure no Chrome instances are force-terminated mid-run; let Oracle clean up once you’re done capturing state.
- Clipboard checks (`browser-tools.ts eval "navigator.clipboard.readText()"`) trigger a permission dialog in Chrome—approve it for debugging, but remember that we can’t rely on readText in unattended runs.

## Test Cases

### Quick browser port smoke

- `pnpm test:browser` — launches headful Chrome and checks the DevTools endpoint is reachable. Set `ORACLE_BROWSER_PORT` (or `ORACLE_BROWSER_DEBUG_PORT`) to reuse a fixed port when you’ve already opened a firewall rule.

### Gemini browser mode (Gemini web / cookies)

Run this whenever you touch the Gemini web client or the `--generate-image` / `--edit-image` plumbing.

Prereqs:
- Chrome profile is signed into `gemini.google.com`.

1. Generate an image:
   `pnpm run oracle -- --engine browser --model gemini-3-pro --prompt "a cute robot holding a banana" --generate-image /tmp/gemini-gen.jpg --aspect 1:1 --wait --verbose`
   - Confirm the output file exists and is a real image (`file /tmp/gemini-gen.jpg`).
2. Edit an image:
   `pnpm run oracle -- --engine browser --model gemini-3-pro --prompt "add sunglasses" --edit-image /tmp/gemini-gen.jpg --output /tmp/gemini-edit.jpg --wait --verbose`
   - Confirm `/tmp/gemini-edit.jpg` exists.

### Multi-Model CLI fan-out

Run this whenever you touch the session store, CLI session views, or TUI wiring for multi-model runs.

1. Kick off an API multi-run:  
   `pnpm run oracle -- --models "gpt-5.1-pro,gemini-3-pro" --prompt "Compare the moon & sun."`
   - Expect stdout to print sequential sections, one per model (`[gpt-5.1-pro] …` followed by `[gemini-3-pro] …`). No interleaved tokens.
2. Capture the session ID from the summary line. Run `oracle session --status --model gpt-5.1-pro`.  
   - Table should collapse to sessions that include GPT-5.1 Pro and show status icons (✓/⌛/✖) per model.
3. Inspect detailed logs: `oracle session <id>`
   - The metadata header now includes a `Models:` block with one line per model plus token counts.
   - When prompted, pick `View gemini-3-pro log` and confirm only that model’s stream renders. Refresh should keep completed models intact even if others still run.
4. Model filter path: `oracle session <id> --model gemini-3-pro`  
   - Attach mode should error if that model is missing (double-check by filtering for a bogus model), otherwise it should render the prompt + single-model log only.

### Write-output export (API)

Run this when touching session serialization, file IO helpers, or CLI flag plumbing.

1. `ORACLE_LIVE_TEST=1 OPENAI_API_KEY=<real key> pnpm vitest run tests/live/write-output-live.test.ts --runInBand`
   - Expect the test to create a temp `write-output-live.md` file containing `write-output e2e`.
2. Manual spot-check: `oracle --prompt "answer file smoke" --write-output /tmp/out.md --wait`
   - Confirm `/tmp/out.md` exists with the answer text and a trailing newline.
3. Multi-model spot-check: `oracle --models "gpt-5.1-pro,gemini-3-pro" --prompt "two files" --write-output /tmp/out.md --wait`
   - Confirm `/tmp/out.gpt-5.1-pro.md` and `/tmp/out.gemini-3-pro.md` exist with distinct content.

### Lightweight Browser CLI (manual exploration)

Before running any agent-driven debugging, you can rely on the TypeScript CLI in `scripts/browser-tools.ts`:

```bash
# Show help / available commands
pnpm tsx scripts/browser-tools.ts --help

# Launch Chrome with your normal profile so you stay logged in
pnpm tsx scripts/browser-tools.ts start --profile

# Drive the active tab
pnpm tsx scripts/browser-tools.ts nav https://example.com
pnpm tsx scripts/browser-tools.ts eval 'document.title'
pnpm tsx scripts/browser-tools.ts screenshot
pnpm tsx scripts/browser-tools.ts pick "Select checkout button"
pnpm tsx scripts/browser-tools.ts cookies
pnpm tsx scripts/browser-tools.ts inspect   # show DevTools-enabled Chrome PIDs/ports/tabs
pnpm tsx scripts/browser-tools.ts kill --all --force   # tear down straggler DevTools sessions
```

This mirrors Mario Zechner’s “What if you don’t need MCP?” technique and is handy when you just need a few quick interactions without spinning up additional tooling.

1. **Prompt Submission & Model Switching**
   - With Chrome signed in and cookie sync enabled, run  
     ```bash
     pnpm run oracle -- --engine browser --model "GPT-5.2" \
       --prompt "Line 1\nLine 2\nLine 3"
     ```
   - Observe logs for:
     - `Prompt textarea ready (xxx chars queued)` (twice: initial + after model switch).
     - `Model picker: ... 5.2 ...`.
     - `Clicked send button` (or Enter fallback).
   - In the attached Chrome window, verify the multi-line prompt appears exactly as sent.

2. **Markdown Capture**
   - Prompt:
     ```bash
     pnpm run oracle -- --engine browser --model "GPT-5.2" \
       --prompt "Produce a short bullet list with code fencing."
     ```
   - Expected CLI output:
     - `Answer:` section containing bullet list with Markdown preserved (e.g., `- item`, fenced code).
     - Session log (`oracle session <id>`) should show the assistant markdown (confirm via `grep -n '```' ~/.oracle/sessions/<id>/output.log`).

3. **Stop Button Handling**
  - Start a long prompt (`"Write a detailed essay about browsers"`) and once ChatGPT responds, manually click “Stop generating” inside Chrome.
  - Oracle should detect the assistant message (partial) and still store the markdown.

4. **Override Flag**
  - Run with `--browser-allow-cookie-errors` while intentionally breaking bindings.
  - Confirm log shows `Cookie sync failed (continuing with override)` and the run proceeds headless/logged-out.
- Remember: the browser composer now pastes only the user prompt (plus any inline file blocks). If you see the default “You are Oracle…” text or other system-prefixed content in the ChatGPT composer, something regressed in `assembleBrowserPrompt` and you should stop and file a bug.
- Heartbeats: Browser runs do **not** emit `--heartbeat` logs today. Heartbeat settings apply to streaming API runs only; ignore heartbeat toggles when validating browser mode.

## Post-Run Validation

- `oracle session <id>` should replay the transcript with markdown.
- `~/.oracle/sessions/<id>/meta.json` must include `browser.config` metadata (model label, cookie settings) and `browser.runtime` (PID/port).

Document results (pass/fail, session IDs) in PR descriptions so reviewers can audit real-world behavior.

## Recent Smoke Runs

- 2025-11-18 — API gpt-5.1 (`api-smoke-give-two-words`): returned “blue sky” in 2.5s.
- 2025-11-18 — API gpt-5.1-pro (`api-smoke-pro-three-words`): completed in 3m08s with “Fast API verification”.
- 2025-11-18 — Browser gpt-5.1 Instant (`browser-smoke-instant-two-words`): completed in ~10s; replied with a clarification prompt.
- 2025-11-18 — Browser gpt-5.1-pro (`browser-smoke-pro-three-words`): completed in ~1m33s; response noted “Search tool used.”.
- 2025-11-18 (rerun) — API gpt-5.1 (`api-smoke-give-two-words`): reconfirmed OK; same answer + cost bracket.
- 2025-11-18 (rerun) — Browser gpt-5.1-pro (`browser-smoke-pro-three-words`): reconfirmed OK; included heartbeat progress and search tool note.
- 2025-11-20 — Browser gpt-5.1 via `oracle serve` (remote host on same Mac): fetched https://example.com; title “Example Domain”; first sentence “This domain is for use in documentation examples without needing permission.” (ran via tmux sessions `oracle-serve` and `oracle-client`).

## Browser Regression Checklist (manual)

Run these four smoke tests whenever we touch browser automation:

1. **GPT-5.2 simple prompt**  
   `pnpm run oracle -- --engine browser --model "GPT-5.2" --prompt "Give me two short markdown bullet points about tables"`  
   Expect two markdown bullets, no files/search referenced. Note the session ID (e.g., `give-me-two-short-markdown`).

2. **GPT-5.2 simple prompt**  
   `pnpm run oracle -- --engine browser --model gpt-5.2 --prompt "List two reasons Markdown is handy"`  
   Confirm the answer arrives (and only once) even if it takes ~2–3 minutes.

3. **GPT-5.2 + attachment**  
   Prepare `/tmp/browser-md.txt` with a short note, then run  
   `pnpm run oracle -- --engine browser --model "GPT-5.2" --prompt "Summarize the key idea from the attached note" --file /tmp/browser-md.txt`  
   Ensure upload logs show “Attachment queued” and the answer references the file contents explicitly.

4. **GPT-5.2 + attachment (verbose)**  
   Prepare `/tmp/browser-report.txt` with faux metrics, then run  
   `pnpm run oracle -- --engine browser --model gpt-5.2 --prompt "Use the attachment to report current CPU and memory figures" --file /tmp/browser-report.txt --verbose`  
   Verify verbose logs show attachment upload and the final answer matches the file data.

Record session IDs and outcomes in the PR description (pass/fail, notable delays). This ensures reviewers can audit real runs.

### Remote Chrome smoke test (CDP)

Run this whenever you touch CDP connection logic (remote chrome lifecycle, attachment transfer) or before executing remote sessions in CI.

1. Launch a throwaway Chrome instance with remote debugging enabled (adjust the path per OS):
   ```bash
   REMOTE_PROFILE=/tmp/oracle-remote-test-profile
   rm -rf "$REMOTE_PROFILE"
   "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
     --headless=new \
     --disable-gpu \
     --remote-debugging-port=9333 \
     --remote-allow-origins=* \
     --user-data-dir="$REMOTE_PROFILE" \
     >/tmp/oracle-remote-chrome.log 2>&1 &
   export REMOTE_CHROME_PID=$!
   sleep 3
   ```
2. Run the helper to verify CDP connectivity:
   ```bash
   pnpm tsx scripts/test-remote-chrome.ts localhost 9333
   ```
   Expect ✓ logs for connection, protocol info, navigation to https://chatgpt.com/, and the final “POC successful!” line.
3. Tear down the temporary browser:
   ```bash
   kill "$REMOTE_CHROME_PID"
   rm -rf "$REMOTE_PROFILE"
   ```
   Use `pkill -f oracle-remote-test-profile` if Chrome refuses to exit cleanly.

Capture the pass/fail result (include the helper’s log snippet) in your PR description alongside other manual browser tests.

## Chrome DevTools / MCP Debugging

Use this when you need to inspect the live ChatGPT composer (DOM state, markdown text, screenshots, etc.). For smaller ad‑hoc pokes, you can often rely on `pnpm tsx scripts/browser-tools.ts …` instead.

1. **Launch within tmux**
   ```bash
   tmux new -d -s oracle-browser \\
     "pnpm run oracle -- --engine browser --browser-keep-browser \\
       --model 'GPT-5.2 Pro' --prompt 'Debug via DevTools.'"
   ```
   Keeping the run in tmux prevents your shell from blocking and ensures Chrome stays open afterward.

2. **Grab the DevTools port**
   - `tmux capture-pane -pt oracle-browser` to read the logs (`Launched Chrome … on port 56663`).
   - Verify the endpoint:
     ```bash
     curl http://127.0.0.1:<PORT>/json/version
     ```
     Note the `webSocketDebuggerUrl` for reference.

3. **Attach Chrome DevTools MCP**
   - One-off: `CHROME_DEVTOOLS_URL=http://127.0.0.1:<PORT> npx -y chrome-devtools-mcp@latest`
   - `mcporter` config snippet:
     ```json
     {
       "chrome-devtools": {
         "command": "npx",
         "args": [
           "-y",
           "chrome-devtools-mcp@latest",
           "--browserUrl",
           "http://127.0.0.1:<PORT>"
         ]
       }
     }
     ```
   - Once the server prints `chrome-devtools-mcp exposes…`, you can list/call tools via `mcporter`.

4. **Interact & capture**
   - Use MCP tools (`click`, `evaluate_js`, `screenshot`, etc.) to debug the composer contents.
   - Record any manual actions you take (e.g., “fired evaluate_js to dump #prompt-textarea.innerText”).

5. **Cleanup**
   - `tmux kill-session -t oracle-browser`
   - `pkill -f oracle-browser-<slug>` if Chrome is still running.

> **Tip:** Running `npx chrome-devtools-mcp@latest --help` lists additional switches (custom Chrome binary, headless, viewport, etc.).

## Responses API Live Smoke Tests

These Vitest cases hit the real OpenAI API to exercise both transports:

1. Export a real key and explicitly opt in (default runs stay fast):
   ```bash
   export OPENAI_API_KEY=sk-...
   export ORACLE_LIVE_TEST=1
   pnpm vitest run tests/live/openai-live.test.ts
   ```
2. The first test sends a `gpt-5.1-pro` prompt (API: `gpt-5.2-pro`) and expects the CLI to stay
   in background mode until OpenAI finishes (up to 30 minutes). The second test
   targets the standard GPT-5 (`gpt-5.1`) path with foreground streaming.
3. Watch the console for `Reconnected to OpenAI background response...` if
   you're debugging transport flakiness; the test will fail if the response
   status isn't `completed` or if the text doesn't contain the hard-coded
   smoke strings.

Skip these unless you're intentionally validating the production API; they are
fully gated behind `ORACLE_LIVE_TEST=1` to avoid accidental CI runs.


--- docs/mcp.md ---
# MCP Server

`oracle-mcp` is a minimal MCP stdio server that mirrors the Oracle CLI. It shares session storage with the CLI (`~/.oracle/sessions` or `ORACLE_HOME_DIR`) so you can mix and match: run with the CLI, inspect or re-run via MCP, or vice versa.

## Tools

### `consult`
- Inputs: `prompt` (required), `files?: string[]` (globs), `model?: string` (defaults to CLI), `engine?: "api" | "browser"` (CLI auto-defaults), `slug?: string`.
- Behavior: starts a session, runs it with the chosen engine, returns final output + metadata. Background/foreground follows the CLI (e.g., GPT‑5 Pro detaches by default).
- Logging: emits MCP logs (`info` per line, `debug` for streamed chunks with byte sizes). If browser prerequisites are missing, returns an error payload instead of running.

### `sessions`
- Inputs: `{id?, hours?, limit?, includeAll?, detail?}` mirroring `oracle status` / `oracle session`.
- Behavior: without `id`, returns a bounded list of recent sessions. With `id`/slug, returns a summary row; set `detail: true` to fetch full metadata, log, and stored request body.

## Resources
- `oracle-session://{id}/{metadata|log|request}` — read-only resources that surface stored session artifacts via MCP resource reads.

## Background / detach behavior
- Same as the CLI: heavy models (e.g., GPT‑5 Pro) detach by default; reattach via `oracle session <id>` / `oracle status`. MCP does not expose extra background flags.

## Launching & usage
- Installed from npm:
  - One-off: `npx @steipete/oracle oracle-mcp`
  - Global: `oracle-mcp`
- From the repo (contributors):
  - `pnpm build`
  - `pnpm mcp` (or `oracle-mcp` in the repo root)
- mcporter example (stdio):
  ```json
  {
    "name": "oracle",
    "type": "stdio",
    "command": "npx",
    "args": ["@steipete/oracle", "oracle-mcp"]
  }
  ```
- Project-scoped Claude (.mcp.json) example:
  ```json
  {
    "mcpServers": {
      "oracle": { "type": "stdio", "command": "npx", "args": ["@steipete/oracle", "oracle-mcp"] }
    }
  }
  ```
- Tools and resources operate on the same session store as `oracle status|session`.
- Defaults (model/engine/etc.) come from your Oracle CLI config; see `docs/configuration.md` or `~/.oracle/config.json`.


--- types/win-dpapi.d.ts ---
declare module 'win-dpapi' {
  export type Scope = 'CurrentUser' | 'LocalMachine';

  export interface DpapiModule {
    unprotectData(encrypted: Buffer, optionalEntropy: Buffer | null, scope: Scope): Buffer;
  }

  const dpapi: DpapiModule;
  export = dpapi;
}


--- CHANGELOG.md ---
# Changelog

## 0.8.5 — Unreleased

## 0.8.4 — 2026-01-04

### Changed
- Deps: update zod to `4.3.5`.
- Deps: add `qs` as a direct dependency (avoids Dependabot pnpm transitive-update failures).

### Fixed
- Browser: fix attachment uploads in the current ChatGPT composer (avoid duplicate uploads; avoid image-only inputs for non-image files). Original PR #60 by Alex Naidis (@TheCrazyLex) — thank you!

## 0.8.3 — 2025-12-31

### Added
- Config: allow `browser.forceEnglishLocale` to opt into `--lang/--accept-lang` for browser runs.
- Browser: add `--browser-cookie-wait` / `browser.cookieSyncWaitMs` to wait once and retry cookie sync. Original PR #55 by bheemreddy-samsara — thank you!

### Fixed
- Browser: avoid stray attachment removal clicks while still detecting stale chips, and allow completed uploads even if send stays disabled. Original PR #56 by Alex Naidis (@TheCrazyLex) — thank you!
- Browser: dismiss blocking modals when a custom ChatGPT project URL is missing, and harden attachment uploads (force input/change events; retry via DataTransfer; treat “file selected” as insufficient unless the composer shows attachment UI).
- Browser: prefer a trusted (CDP) click on the composer “+” button so attachment uploads work even when ChatGPT ignores synthetic clicks.

## 0.8.2 — 2025-12-30

### Changed
- Release: disable npm progress output in Codex runs via `scripts/release.sh`.

### Docs
- Release checklist now requires GitHub release notes to match the full changelog section.

### Tests
- Live: tolerate truncated prompt echo in browser model selection checks.
- Live: skip mixed OpenRouter assertions when a provider returns empty output.
- Live: wait for browser runtime hint before reattaching in the reattach smoke.

## 0.8.1 — 2025-12-30

### Added
- Config: allow `browser.thinkingTime`, `browser.manualLogin`, and `browser.manualLoginProfileDir` defaults in `~/.oracle/config.json`.

### Fixed
- Browser: thinking-time chip selection now recognizes "Pro" labeled composer pills. Original PR #54 by Alex Naidis (@TheCrazyLex) — thank you!
- Browser: when a custom ChatGPT project URL is missing, retry on the base URL with a longer prompt timeout.
- Browser: increase attachment wait budget and proceed with sending the prompt if completion times out (skip attachment gating/verification).
- CLI: disable OSC progress output when running under Codex (`CODEX_MANAGED_BY_NPM=1`) to avoid spinner noise.

### Tests
- Stabilize OSC progress detection tests when `CODEX_MANAGED_BY_NPM=1` is set.
- Add fast live browser runs for missing-project fallback + attachment uploads (`test:live:fast`).

## 0.8.0 — 2025-12-28

### Highlights
- Browser reliability push: stronger reattach, response capture, and attachment uploads (fewer prompt-echoes, truncations, and duplicate uploads).
- Cookie stack revamp via Sweet Cookie (no native addons) with better inline-cookie handling; Gemini web now works on Windows and honors `--browser-cookie-path`.
- New `--browser-model-strategy` flag to control ChatGPT model selection (`select`/`current`/`ignore`) in browser mode. Original PR #49 by @djangonavarro220 — thank you!

### Improvements
- Browser reattach now preserves `/c/` conversation URLs and project URL prefixes, validates conversation ids, and recovers from mid-run disconnects or capture failures.
- Response capture is more stable: wider selectors, assistant-only copy-turn capture, prompt-echo avoidance, and stop-button/clipboard stability checks.
- Attachment uploads are idempotent and count-aware (composer + chips + file inputs), with explicit completion waits and stale-input cleanup.
- Login flow adds richer diagnostics, auto-accepts the “Welcome back” picker, and always logs the active ChatGPT URL.
- Cookie handling prefers live Chrome over legacy `~/.oracle/cookies.json`; Gemini web can use inline cookies when sync is disabled.

### Fixes
- CLI: stream Markdown via Markdansi’s block renderer and guard the live renderer for non‑TTY edge cases.
- Tests: stabilize browser live tests (serialization + project URL fallback) and add response-observer assertions; browser smoke runs are faster.

## 0.7.6 — 2025-12-25

### Changed
- CLI: compact finish line summary across API, browser, and session views.
- CLI: token counts now render as `↑in ↓out ↻reasoning Δtotal`.

### Fixed
- CLI/Browser: ignore duplicate `--file` inputs (log once) and improve attachment presence detection so re-runs don’t spam “already attached” upload errors.
- Browser: harden session reattach (better conversation targeting, longer prompt-commit wait, avoid closing shared DevTools targets).
- Live tests: add coverage + retries for browser reattach/model selection; tolerate transient OpenRouter free-tier failures.

## 0.7.5 — 2025-12-23

### Fixed
- Packaging: switch tokentally to npm release so Homebrew installs don't trigger git prepare builds.

## 0.7.4 — 2025-12-23

### Changed
- Browser: add `--browser-thinking-time <light|standard|extended|heavy>` to select thinking-time intensity in ChatGPT.

### Fixed
- Browser: throttle attachment upload pokes and pace multi-file uploads to avoid duplicate “already attached” warnings.
- Browser: correct GPT-5.2 variant selection (Auto/Thinking/Instant/Pro) with stricter matching and improved testid scoring; thinking-time selection now supports multiple levels. Original PR #45 by Manish Malhotra (@manmal) — thank you!
- Browser: only reload stalled conversations after an assistant-response failure (and only once), instead of always refreshing after submit.

## 0.7.3 — 2025-12-23

### Changed
- API: streaming answers in a rich TTY now use Markdansi’s live renderer (`createLiveRenderer`) so we can stream *and* render Markdown in-place.

### Fixed
- Browser: prevent `chrome-launcher` from auto-killing Chrome on SIGINT so reattach sessions survive Ctrl+C.
- Sessions: running browser sessions now mark as errored when the Chrome PID/port are no longer reachable.
- Browser: reattach now recovers even if Chrome was closed by reopening, locating the conversation in the sidebar, and resuming the response.

## 0.7.2 — 2025-12-17

### Fixed
- Browser: stop auto-clicking the “Answer now” gate; wait for the full Pro-thinking response instead of skipping it.
- Browser: reject `?temporary-chat=true` URLs when targeting Pro models (Pro picker entries are not available in Temporary Chat); error message now calls this out explicitly.
- Browser: attachment uploads re-trigger the file-input change event until ChatGPT renders the attachment card (avoids hydration races); verify attachments are present on the sent user message before waiting for the assistant.
- Live tests: make the `gpt-5.2-instant` OpenAI smoke test resilient to transient API stalls/errors.

## 0.7.1 — 2025-12-17

### Changed
- API: default model is now `gpt-5.2-pro` (and “Pro” label inference prefers GPT‑5.2 Pro).
- Tests: updated fixtures/defaults to use `gpt-5.2-pro` instead of `gpt-5.1-pro`.
- API: clarify `gpt-5.1-pro` as a stable alias that targets `gpt-5.2-pro`.
- Browser: browser engine GPT selection now supports ChatGPT 5.2 (`gpt-5.2`) and ChatGPT 5.2 Pro (`gpt-5.2-pro`); legacy labels like `gpt-5.1` normalize to 5.2, and “Pro” always resolves to 5.2 Pro (ignores Legacy GPT‑5.1 Pro submenu) with a top-bar label confirmation.

### Fixed
- Browser: prompt commit verification handles markdown code fences better; prompt-echo recovery is more robust (including remote browser mode); multi-file uploads are less flaky (dynamic timeouts + better filename matching). Original PR #41 by Muly Oved (@mulyoved) — thank you!
- Browser: adapt to ChatGPT DOM changes (`data-turn=assistant|user`) and “Answer now” gating in Pro thinking so we don’t capture placeholders/truncate answers.
- Gemini web: add abortable timeouts + retries for cookie-based runs so live tests are less likely to hang on transient Gemini web responses.

## 0.7.0 — 2025-12-14

### Added
- Browser: Gemini browser mode via direct Gemini web client (uses Chrome cookies; no API key required; runs fully in Node/TypeScript — no Python/venv). Includes `--youtube`, `--generate-image`, `--edit-image`, `--output`, `--aspect`, and `--gemini-show-thoughts`. Original PR #39 by Nico Bailon (@nicobailon) — thank you!
- Browser: media files passed via `--file` (images/video/audio/PDF) are treated as upload attachments instead of being inlined into the prompt (enables Gemini file analysis).
- Browser: Gemini image ops follow `gg-dl` redirects while preserving cookies, so `--generate-image`/`--edit-image` actually create output files.
- Browser: Gemini web runs support “Pro” auto-fallback when unavailable and include compatibility init for Gemini web token changes.
- Live tests: add opt-in Gemini web smoke coverage for image generation/editing (cookie-based browser mode).

### Changed
- Browser guard now allows Gemini models (browser engine supports GPT + Gemini; other models require `--engine api`).

## 0.6.1 — 2025-12-13

### Changed
- Browser: default model target now prefers ChatGPT 5.2. Original PR #40 by Muly Oved (@mulyoved) — thank you!
- Browser: remove the “browser fallback” API retry suggestion to avoid accidental billable reruns. Idea from PR #38 by Nico Bailon (@nicobailon) — thank you!

### Fixed
- Browser: manual-login runs now reuse an already-running Chrome more reliably (persist DevTools port in the profile; probe with retries; clean up stale port state). Original PR #40 by Muly Oved (@mulyoved) — thank you!
- Browser: response capture is less likely to truncate by mistaking earlier turns as complete; completion detection is scoped to the last assistant turn and requires brief stability before capture. Original PR #40 by Muly Oved (@mulyoved) — thank you!
- Browser: stale profile cleanup avoids deleting lock files when an active Chrome process is using the profile.

## 0.6.0 — 2025-12-12

### Added
- GPT-5.2 model support (`gpt-5.2` Thinking, `gpt-5.2-instant`, `gpt-5.2-pro`) plus browser thinking-time automation. Original PR #37 by Nico Bailon (@nicobailon) — thank you!

### Changed
- API: `gpt-5.1-pro` now targets `gpt-5.2-pro` instead of older Pro fallbacks.
- Browser: “Thinking time → Extended” selection now reuses centralized menu selectors, normalizes text matching, and ships a best-effort helper for future “auto” mode. Original PR #36 by Victor Vannara (@voctory) — thank you!
- Browser: new `--browser-attachments <auto|never|always>` (default `auto`) pastes file contents inline up to ~60k characters, then switches to uploads; if ChatGPT rejects an inline paste as too large, Oracle retries automatically with uploads.
  - Note: the ~60k threshold is based on pasted **characters** in the ChatGPT composer (not token estimates); on rejection we log the retry and switch to uploads automatically.

## 0.5.6 — 2025-12-09 (re-release of 0.5.5)

### Changed
- Browser uploads: after `setFileInputFiles` we now log the chips + file-input contents and only mark success when the real file input contains the uploaded filename; the generic “Files” pill is no longer treated as proof of attachment.
- Inline prompt commit: verification now matches on a normalized prefix and logs the last user turn + counts when commit fails, reducing false negatives for inline/file-paste runs.

### Fixed
- Inline fallback (pasting file contents) now reliably submits and captures the user turn; headful smoke confirms the marker text is echoed back.

## 0.5.4 — 2025-12-08

### Changed
- Docs: README now explicitly warns against `pnpx @steipete/oracle` (pnpx cache breaks sqlite bindings); use `npx -y @steipete/oracle` instead. Thanks Xuanwo for flagging this.
- Browser uploads: stick to the single reliable file-input path (no drag/drop fallbacks), wait for the composer to render the new “N files” pill/remove-card UI before sending, and prefer non-image inputs. Thanks Peter for the repros and screenshots that caught the regressions.

### Fixed
- API fallback: gpt-5.1-pro API runs now automatically downgrade to gpt-5.0-pro with a one-line notice (5.1 Pro is not yet available via API).
- Browser uploads: detect ChatGPT’s composer attachment chip (not echoed in the last user turn) to avoid false “Attachment did not appear” failures. Thanks Mariano Belinky (@mbelinky) for the fix.
- Browser interruption: if the user/agent sends SIGINT/SIGTERM/SIGQUIT while the assistant response is still pending, Oracle leaves Chrome running, writes runtime hints, and logs how to reattach with `oracle session <slug>` instead of killing the browser mid-run.
- Browser uploads (ChatGPT UI 2025-12): wait for DOM ready, avoid duplicate uploads, and block Send until the attachment chip/file name (or “N files” pill) is visible so files aren’t sent empty or multiple times.
- Browser i18n: stop-button detection now uses data-testid instead of English `aria-label`; send/input/+ selectors favor data-testid/structural cues to work across localized UIs.

## 0.5.3 — 2025-12-06

### Changed
- `oracle` with no arguments now prints the help/usage banner; launch the interactive UI explicitly via `oracle tui` (keeps `ORACLE_FORCE_TUI` for automation/tests). README updated to match.
- TUI exits gracefully when the terminal drops raw mode (e.g., `setRawMode EIO` after pager issues) instead of looping the paging error; prints a hint to run `stty sane`.
- Ctrl+C in the TUI menu now exits cleanly without printing the paging error loop.
- Exit banner is printed once when leaving the TUI (prevents duplicate “Closing the book” messages after SIGINT or exit actions).

## 0.5.2 — 2025-12-06

### Changed
- Updated Inquirer to 13.x and aligned TUI prompts with `select` to stay compatible with the latest API.
- Browser click automation now uses a shared pointer/mouse event sequence for send/model/copy/stop buttons, improving reliability with React/ProseMirror UIs. Original fix by community contributor Mike Demarais in PR #30—thank you!

### Fixed
- Browser config defaults from `~/.oracle/config.json` now apply when CLI flags are untouched (chromePath/profile/cookiePath), fixing “No Chrome installations found” when a custom browser path is configured.
- Browser engine now verifies each attachment shows up in the composer before sending (including remote/serve uploads), fixing cases where file selection succeeded but ChatGPT never received the files (e.g., WKWebView blank runs).

## 0.5.1 — 2025-12-03

### Added
- Browser runs now auto-click the ChatGPT “Answer now” gate after sending, so workspace prompts continue without manual intervention.

### Changed
- `oracle status` uses the same session table formatting as the TUI (status/model/mode/timestamp/chars/cost/slug) for consistent layout.
- Browser mode inserts a 500 ms settle before submitting prompts and after clicking gates to avoid subscription/widget races.
- OpenRouter paths route through the chat/completions API (Responses API avoided); live smokes use `z-ai/glm-4.6`, and the mixed run covers Grok fast path without skips.
- Docs/guardrails: AGENTS explains sqlite/keytar rebuilds for Node 25 browser runs; changelog notes the browser cookie-sync guard.

### Fixed
- Browser mode fails fast when cookie sync copies zero cookies (e.g., keytar not built); the error names the Chrome profile and rebuild command instead of silently hanging.

## 0.5.0 — 2025-11-25

### Added
- Browser sessions now persist Chrome reattach hints (port/host/target/url) and log them inline; `oracle session <id>` can reconnect to a live tab, harvest the assistant turn, and mark the run completed even if the original controller died. Includes a reconnection helper and regression tests for runtime hint capture and reattach.
- OpenRouter support: `OPENROUTER_API_KEY` auto-routes API runs (when provider keys are missing or the base URL points at OpenRouter), accepts arbitrary model ids (`minimax/minimax-m2`, `z-ai/glm-4.6`, etc.), mixes with built-in models in `--models`, passes attribution headers (`OPENROUTER_REFERER`/`OPENROUTER_TITLE`), and stores per-model logs with safe slugs.
- `pnpm test:browser` runs a Chrome DevTools connectivity check plus headless browser smokes across GPT-5.1 / GPT-5.1-Pro / 5.1 Instant.

### Changed
- All API errors now surface as transport reason `api-error` with the raw message and are shown in status/render/TUI; verbose mode still prints transport details. Multi-model callback order test stabilized.
- Default system prompt no longer asks models to announce when the search tool was used.
- API now surfaces a clear error when `gpt-5.1-pro` isn’t available yet (suggests using `gpt-5-pro`); remove once OpenAI enables the model.
- Dependency refresh: openai 6.9.1, clipboardy 5, Vitest 4.0.13 (+ coverage), Biome 2.3.7, puppeteer-core 24.31.0, devtools-protocol 0.0.1548823; pinned zod-to-json-schema to 3.24.1 to stay compatible with zod 3.x.

### Fixed
- CLI/TUI now print the intro banner only once; forced TUI launches (`ORACLE_FORCE_TUI` or no args in a TTY) no longer show duplicate 🧿 header lines.
- TUI session list cleans up separators, removing the `__disabled__ (Disabled)` placeholder and `(Disabled)` tag on the header row.
- `oracle session --render` no longer drops answers when the model filter is empty or per-model logs are missing (common for browser runs); stored session output is rendered again.
- Browser uploads no longer time out in ChatGPT project workspaces: file input/send-button selectors are broader, upload completion falls back to attached files when buttons are missing, and we added tests to guard the new selectors.
- Live tests now call out that `gpt-5.1` must be reached via api.openai.com; OpenRouter’s Responses API endpoint doesn’t expose `openai/gpt-5.1`, so runs will fail there with `model_not_found` until they add it.
- Browser reattach flow survives controller loss: the controller PID is persisted with the Chrome port/URL so `oracle session <id>` can reconnect, harvest the assistant turn, and mark the run completed even if the original process died.
- Live multi-model smokes force first-party API bases and soft-skip HTML/transport errors (e.g., proxy 404 pages) so missing provider access doesn’t fail the suite.
- Gemini live coverage confirmed with `gemini-2.5-flash-lite` after refreshing `GEMINI_API_KEY`; multi-model live now passes end-to-end when first-party keys are present.
- Token usage formatter again emits two-decimal abbreviations for thousands (e.g., 4.25k) to match CLI output and tests.

### Added
- `--browser-manual-login` skips cookie copy, reuses a persistent automation profile (`~/.oracle/browser-profile` by default), and waits for manual ChatGPT login—handy on Windows where app-bound cookies can’t be decrypted; works as an opt-in on macOS/Linux too.
- Manual-login browser sessions can reuse an already-running automation Chrome when remote debugging is enabled; point Oracle at it via `--remote-chrome <host:port>` to avoid relaunching/locks.
- `--browser-port` (alias `--browser-debug-port`, env `ORACLE_BROWSER_PORT`) pins the DevTools port so WSL/Windows users can open a single firewall rule; includes a lightweight `pnpm test:browser` DevTools reachability check.

### Changed
- Windows cookie reader now accepts any `v**` AES-GCM prefix (v10/v11/v20) to stay forward compatible.
- On Windows, cookie sync is disabled by default and manual-login is forced; use inline cookies or `--browser-manual-login` (default) instead of profile-based cookie copy.

## 0.4.5 — 2025-11-22

### Fixed
- MCP/API responses now report 404/405 from `/v1/responses` as “unsupported-endpoint” with guidance to fix base URLs/gateways or use browser engine; avoids silent failures when proxies lack the Responses API.

## 0.4.4 — 2025-11-22

### Fixed
- MCP/API runs now surface 404/405 Responses API failures as “unsupported-endpoint” with actionable guidance (check OPENAI_BASE_URL/Azure setup or use the browser engine) instead of a generic transport error.
- Publish metadata now declares Node >=20 (engines/devEngines) and drops the implicit bun runtime so `npx @steipete/oracle` no longer fails with EBADDEVENGINES on newer Node versions.

## 0.4.3 — 2025-11-22

### Added
- xAI Grok 4.1 API support (`--model grok-4.1` / alias `grok`): defaults to `https://api.x.ai/v1`, uses `XAI_API_KEY`, maps search to `web_search`, and includes docs + live smoke.
- Per-model search tool selection so Grok can use `web_search` while OpenAI models keep `web_search_preview`.
- Multi-model coverage now includes Grok in orchestrator tests.
- Grok “thinking”/non-fast variant is not available via API yet; Oracle aliases `grok` to the fast reasoning model to match what xAI ships today.
- PTY-driven CLI/TUI harness landed for e2e coverage (browser guard, TUI exit path); PTY suites are opt-in via `ORACLE_ENABLE_PTY_TESTS=1` and stub tokenizers to stay lightweight.

### Fixed
- MCP (global installs): keep the stdio transport alive until the client closes it so `oracle-mcp` doesn’t exit right after `connect()`; npm -g / host-spawned MCP clients now handshake successfully (tarball regression in 0.4.2).

## 0.4.2 — 2025-11-21

### Fixed
- MCP: `npx @steipete/oracle oracle-mcp` now routes directly to the MCP server (even when npx defaults to the CLI binary) and keeps stdout JSON-only for Cursor/other MCP hosts.
- Added the missing `@anthropic-ai/tokenizer` runtime dependency so `npx @steipete/oracle oracle-mcp` starts cleanly.

## 0.4.1 — 2025-11-21

### Fixed
- Removed duplicate MCP release note entry; no code changes (meta cleanup only).

## 0.4.0 — 2025-11-21

### Added
- Remote Chrome + remote browser service: `oracle serve` launches Chrome with host/token defaults for cross-machine runs, requires the host profile to be signed in, and supports reusing an existing Chrome via `--remote-chrome <host:port>` (IPv6 with `[host]:port`), including remote attachment uploads and clearer validation errors.
- Linux browser support: Chrome/Chromium/Edge runs now work on Linux (including snap-installed Chromium) with cookie sync picking up the snap profile paths. See [docs/linux.md](docs/linux.md) for paths and display guidance.
- Browser engine can target Chromium/Edge by pairing `--browser-chrome-path` with the new `--browser-cookie-path` (also configurable via `browser.chromePath` / `browser.chromeCookiePath`). See [docs/chromium-forks.md](docs/chromium-forks.md) for OS-specific paths and setup steps.
- Markdown bundles render better in the CLI and ChatGPT: each attached file now appears as `### File: <path>` followed by a fenced code block (language inferred), across API bundles, browser bundles (including inline mode), and render/dry-run output; ANSI highlighting still applies on rich TTYs.
- `--render-plain` forces plain markdown output (no ANSI/highlighting) even in a rich TTY; takes precedence when combined with `--render` / `--render-markdown`.
- `--write-output <path>` saves just the final assistant message to disk (adds `.<model>` per file for multi-model runs), with safe path guards and non-fatal write failures.
- Browser engine: `--chatgpt-url` (alias `--browser-url`) and `browser.chatgptUrl` config let you target specific ChatGPT workspace/folder URLs while keeping API `--base-url` separate.
- Multi-model API runner orchestrates multiple API models in one command and aggregates usage/cost; browser engine stays single-model.
- GPT-5.1 Pro API support (new default) and `gpt-5-pro` alias for earlier Pro rollout; GPT-5.1 Codex (API-only) now works end-to-end with high reasoning and auto-forces the API engine. GPT-5.1 Codex Max isn’t available via API yet; the CLI rejects that model until OpenAI ships it.
- Duplicate prompt guard remains active: Oracle blocks a second run when the exact prompt is already running.

### Changed
- Cookie sync covers Chrome, Chromium, Edge, Brave, and Vivaldi profiles; targets chatgpt.com, chat.openai.com, and atlas.openai.com. Windows browser automation is still partial—prefer API or clipboard fallback there.
- Reject prompts shorter than 10 characters with a friendly hint for pro-tier models (`gpt-5.1-pro`) only (prevents accidental costly runs while leaving cheaper models unblocked). Override via ORACLE_MIN_PROMPT_CHARS for automated environments.
- Browser engine default timeout bumped from 15m (900s) to 20m (1200s) so long GPT-5.x Pro responses don’t get cut off; CLI docs/help text now reflect the new ceiling.
- Duration flags such as `--browser-timeout`/`--browser-input-timeout` now accept chained units (`1h2m10s`, `3m10s`, etc.) plus `h`, `m`, `s`, or `ms` suffixes, matching the formats we already log.
- GPT-5.1 Pro and GPT-5 Pro API runs now default to a 60-minute timeout (was 20m) and the “zombie” detector waits the same hour before marking sessions as `error`; CLI messaging/docs updated accordingly so a single “auto” limit covers both behaviors.
- Browser-to-API coercion now happens automatically for GPT-5.1 Codex and Gemini (with a console hint) instead of failing when `--engine browser` is set.
- Browser engine now fails fast (with guidance) when explicitly requested alongside non-GPT models such as Grok, Claude, or Gemini; pick `--engine api` for those.
- Multi-model output is easier to scan: aggregate header/summary, deduped per-model headings, and on-demand OSC progress when replaying combined logs.
- `--write-output` adds stricter path safety, rejecting unsafe destinations while keeping writes non-fatal to avoid breaking runs.
- Session slugs now trim individual words to 10 characters to keep auto-generated IDs readable when prompts include very long tokens.
- CLI: `--mode` is now a silent alias for `--engine` for backward compatibility with older docs/scripts; prefer `--engine`.
- CLI guardrail: if a session with the same prompt is already running, new runs abort with guidance to reattach unless `--force` is provided (prevents unintended duplicate API/browser runs).

### Fixed
- Browser assistant capture is more resilient: markdown cleanup no longer drops real answers and prompt-echo recovery keeps the assistant text intact.
- Browser cookie sync on Windows now copies the profile DB into a named temp directory with the expected `Cookies` filename so `chrome-cookies-secure` can read it reliably during browser fallbacks.
- Streaming runs in `--render-plain` mode now send chunks directly to stdout and keep the log sink newline-aligned, preventing missing or double-printed output in TTY and background runs.
- CLI output is consistent again: final answers always print to stdout (even when a log sink is active) and inline runs once more echo the assistant text to stdout.
- MCP: stdout is now muted during MCP runs, preventing non-JSON logs from breaking hosts like Cursor.

## 0.3.0 — 2025-11-19

### Added
- Native Azure OpenAI support! Set `AZURE_OPENAI_ENDPOINT` (plus `AZURE_OPENAI_API_KEY` and optionally `AZURE_OPENAI_DEPLOYMENT`/`AZURE_OPENAI_API_VERSION`) or use the new CLI flags (`--azure-endpoint`, `--azure-deployment`, etc.) to switch automatically to the Azure client.
- **Gemini 3 Pro Support**: Use Google's latest model via `oracle --model gemini`. Requires `GEMINI_API_KEY`.
- Configurable API timeout: `--timeout <seconds|auto>` (auto = 20m for most models, 60m for pro models such as gpt-5.1-pro as of 0.4.0). Enforced for streaming and background runs.
- OpenAI-compatible base URL override: `--base-url` (or `apiBaseUrl` in config / `OPENAI_BASE_URL`) lets you target LiteLLM proxies, Azure gateways, and other compatible hosts.
- Help text tip: best results come from 6–30 sentences plus key source files; very short prompts tend to be generic.
- Browser inline cookies: `--browser-inline-cookies[(-file)]` (or env) accepts JSON/base64 payloads, auto-loads `~/.oracle/cookies.{json,base64}`, adds a cookie allowlist (`--browser-cookie-names`), and dry-run now reports whether cookies come from Chrome or inline sources.
- Inline runs now print a single completion line (removed duplicate “Finished” summary), keeping output concise.
- Gemini runs stay on API (no browser detours), and the CLI logs the resolved model id alongside masked keys when it differs.
- `--dry-run [summary|json|full]` is now the single preview flag; `--preview` remains as a hidden alias for compatibility.

### Changed
 - Browser engine is now macOS-only; Windows and Linux runs fail fast with guidance to re-run via `--engine api`. Cross-platform browser support is in progress.
 - Browser fallback tips focus on `--browser-bundle-files`, making it clear users can drag the single bundled file into ChatGPT when automation fails.
 - Sessions TUI separates recent vs older runs, adds an Older/Newer action, keeps headers aligned with rows, and avoids separator crashes while preserving an always-selectable “ask oracle” entry.
- CLI output is tidier and more resilient: graceful Ctrl+C, shorter headers/footers, clearer verbose token labels, and reduced trailing spacing.
- File discovery is more reliable on Windows thanks to normalized paths, native-fs glob handling, and `.gitignore` respect across platforms.

## 0.2.0 — 2025-11-18

### Added
- `oracle-mcp` stdio server (bin) with `consult` and `sessions` tools plus read-only session resources at `oracle-session://{id}/{metadata|log|request}`.
- MCP logging notifications for consult streaming (info/debug with byte sizes); browser engine guardrails now check Chrome availability before a browser run starts.
- Hidden root-level aliases `--message` (prompt) and `--include` (files) to mirror common agent calling conventions.
- `--preview` now works with `--engine browser`, emitting the composed browser payload (token estimate, attachment list, optional JSON/full dumps) without launching Chrome or requiring an API key.
- New `--browser-bundle-files` flag to opt into bundling all attachments into a single upload; bundling is still auto-applied when more than 10 files are provided.
- Desktop session notifications (default on unless CI/SSH) with `--[no-]notify` and optional `--notify-sound`; completed runs announce session name, API cost, and character count via OS-native toasts.
- Per-user JSON5 config at `~/.oracle/config.json` to set default engine/model, notification prefs (including sound/mute rules), browser defaults, heartbeat, file-reporting, background mode, and prompt suffixes. CLI/env still override config.
- Session lists now show headers plus a cost column for quick scanning.

### Changed
- Browser model picker is now more robust: longer menu-open window, richer tokens/testids for GPT-5.1 and GPT-5 Pro, fallback snapshot logging, and best-effort selection to reduce “model not found” errors.
- MCP consult honors notification settings so the macOS Swift notifier fires for MCP-triggered runs.
- `sessions` tool now returns a summary row for `id` lookups by default; pass `detail: true` to fetch full metadata/log/request to avoid large accidental payloads.
- Directory/glob expansions now honor `.gitignore` files and skip dotfiles by default; explicitly matching patterns (e.g., `--file "src/**/.keep"`) still opt in.
- Default ignores when crawling project roots now drop common build/cache folders (`node_modules`, `dist`, `coverage`, `.git`, `.turbo`, `.next`, `build`, `tmp`) unless the path is passed explicitly. Oracle logs each skipped path for transparency.
- Browser engine now logs a one-line warning before cookie sync, noting macOS may prompt for a Keychain password and how to bypass via `--browser-no-cookie-sync` or `--browser-allow-cookie-errors`.
- gpt-5.1-pro API runs default to non-blocking; add `--wait` to block. `gpt-5.1` and browser runs still block by default. CLI now polls once for `in_progress` responses before failing.
- macOS notifier helper now ships signed/notarized with the Oracle icon and auto-repairs execute bits for the fallback terminal-notifier.
- Session summaries and cost displays are clearer, with zombie-session detection to avoid stale runs.
- Token estimation now uses the full request body (instructions + input text + tools/reasoning/background/store) and compares estimated vs actual tokens in the finished stats to reduce 400/413 surprises.
- Help banner and first tip now require “prompt + --file” (dirs/globs fine) and remind you Oracle can’t see your project without attachments.
- Help tips/examples now call out project/platform/version requirements and show how to label cross-repo attachments so the model has the right context.

#### MCP configuration (quick reference)
- Local stdio (mcporter): add to `config/mcporter.json`
  ```json
  {
    "name": "oracle",
    "type": "stdio",
    "command": "npx",
    "args": ["-y", "@steipete/oracle", "oracle-mcp"]
  }
  ```
- Claude Code (global/user scope):  
  `claude mcp add --scope user --transport stdio oracle -- oracle-mcp`
- Project-scoped Claude: drop `.mcp.json` next to the repo root with
  ```json
  {
    "mcpServers": {
      "oracle": { "type": "stdio", "command": "npx", "args": ["-y", "@steipete/oracle", "oracle-mcp"] }
    }
  }
  ```
- The MCP `consult` tool honors `~/.oracle/config.json` defaults (engine/model/search/prompt suffix/heartbeat/background/filesReport) unless the caller overrides them.

## 0.1.1 — 2025-11-20

### Added
- Hidden `--files`, `--path`, and `--paths` aliases for `--file`, so all path inputs (including `--include`) merge cleanly; commas still split within a single flag.
- CLI path-merging helper now has unit coverage for alias ordering and comma splitting.
- New `--copy-markdown` flag (alias `--copy`) assembles the markdown bundle and copies it to the clipboard, printing a one-line summary; combine with `--render-markdown` to both print and copy. Clipboard handling now uses `clipboardy` for macOS/Windows/Linux/Wayland/Termux/WSL with graceful failure messaging.

## 0.1.0 — 2025-11-17

Highlights
- Markdown rendering for completed sessions (`oracle session|status <id> --render` / `--render-markdown`) with ANSI formatting in rich TTYs; falls back to raw when logs are huge or stdout isn’t a TTY.
- New `--path` flag on `oracle session <id>` prints the stored session directory plus metadata/request/log files, erroring if anything is missing. Uses soft color in rich terminals for quick scanning.

Details
### Added
- `oracle session <id> --path` now prints the on-disk session directory plus metadata/request/log files, exiting with an error when any expected file is missing instead of attaching.
- When run in a rich TTY, `--path` labels and paths are colorized for easier scanning.

### Improved
- `oracle session|status <id> --render` (alias `--render-markdown`) pretty-prints completed session markdown to ANSI in rich TTYs, falls back to raw when non-TTY or oversized logs.
## 0.0.10 — 2025-11-17

### Added
- Rich terminals that support OSC 9;4 (Ghostty 1.2+, WezTerm, Windows Terminal) now show an inline progress bar while Oracle waits for the OpenAI response; disable with `ORACLE_NO_OSC_PROGRESS=1`, force with `ORACLE_FORCE_OSC_PROGRESS=1`.

## 0.0.9 — 2025-11-16

### Added
- `oracle session|status <id> --render` (alias `--render-markdown`) pretty-prints completed session markdown to ANSI in rich TTYs, falls back to raw when non-TTY or oversized logs.
- Hidden root-level `--session <id>` alias attaches directly to a stored session (for agents/automation).
- README now recommends preferring API engine for reliability and longer uninterrupted runs when an API key is available.
- Session rendering now uses Markdansi (micromark/mdast-based), removing markdown-it-terminal and eliminating HTML leakage/crashes during replays.
- Added a local Markdansi type shim for now; switch to official types once the npm package ships them.
- Markdansi renderer now enables color/hyperlinks when TTY by default and auto-renders sessions unless the user explicitly disables it.

## 0.0.8 — 2025-11-16

### Changed
- Help tips call out that Oracle is one-shot and does not remember prior runs, so every query should include full context.
- `oracle session <id>` now logs a brief notice when extra root-only flags are present (e.g., `--render-markdown`) to make it clear those options are ignored during reattach.

## 0.0.7 — 2025-11-16

### Changed
- Browser-mode thinking monitor now emits a text-only progress bar instead of the "Pro thinking" string.
- `oracle session <id>` trims preamble/log noise and prints from the first `Answer:` line once a session is finished.
- Help tips now stress sending whole directories and richer project briefings for better answers.

## 0.0.6 — 2025-11-15

### Changed
- Colorized live run header (model/tokens/files) when a rich TTY is available.
- Added a blank line before the `Answer:` prefix for readability.
- Masked API key logging now shows first/last 4 characters (e.g., `OPENAI_API_KEY=sk-p****qfAA`).
- Suppressed duplicate session header on reattach and removed repeated background response IDs in heartbeats.

### Browser mode
- When more than 10 files are provided, automatically bundles all files into a single `attachments-bundle.txt` to stay under ChatGPT’s upload cap and logs a verbose warning when bundling occurs.

## 0.0.5 — 2025-11-15

### Added
- Logs the masked OpenAI key in use (`Using OPENAI_API_KEY=xxxx****yyyy`) so runs are traceable without leaking secrets.
- Logs a helpful tip when you run without attachments, reminding you to pass context via `--file`.

## 0.0.3 — 2025-11-15

## 0.0.2 — 2025-11-15

### Added
- Positional prompt shorthand: `oracle "prompt here"` (and `npx -y @steipete/oracle "..."`) now maps the positional argument to `--prompt` automatically.

### Fixed
- `oracle status/session` missing-prompt guard now coexists with the positional prompt path and still shows the cleanup tip when no sessions exist.

## 0.0.1 — 2025-11-15

### Fixed
- Corrected npm binary mapping so `oracle` is installed as an executable. Published with `--tag beta`.

## 0.0.0 — 2025-11-15

### Added
- Dual-engine support (API and browser) with automatic selection: defaults to API when `OPENAI_API_KEY` is set, otherwise falls back to browser mode.
- Session-friendly prompt guard that allows `status`/`session` commands to run without a prompt while still enforcing prompts for normal runs, previews, and dry runs.
- Browser mode uploads each `--file` individually and logs Chrome PID/port for detachable runs.
- Background GPT-5 Pro runs with heartbeat logging and reconnect support for long responses.
- File token accounting (`--files-report`) and dry-run summaries for both engines.
- Comprehensive CLI and browser automation test suites, including engine selection and prompt requirement coverage.

### Changed
- Help text, README, and browser-mode docs now describe the auto engine fallback and the deprecated `--browser` alias.
- CLI engine resolution is centralized to keep legacy flags, model inference, and environment defaults consistent.

### Fixed
- `oracle status` and `oracle session` no longer demand `--prompt` when used directly.


## Links discovered
- [docs/linux.md](https://github.com/steipete/oracle/blob/main/docs/linux.md)
- [docs/chromium-forks.md](https://github.com/steipete/oracle/blob/main/docs/chromium-forks.md)

--- AGENTS.md ---
# AGENTS.MD

Oracle-specific notes:
- ChatGPT project URLs: steipete@gmail.com -> https://chatgpt.com/g/g-p-691edc9fec088191b553a35093da1ea8-oracle/project; studpete@gmail.com -> https://chatgpt.com/g/g-p-69505ed97e3081918a275477a647a682/project. Prefer studpete URL if steipete project not found.
- Pro browser runs: allow up to 10 minutes; never click "Answer now"; keep at least 1–2 Pro live tests (reattach must stay Pro); move other tests to faster models where safe.
- Live smoke tests: OpenAI live tests are opt-in. Run `ORACLE_LIVE_TEST=1 pnpm vitest run tests/live/openai-live.test.ts` with a real `OPENAI_API_KEY` when you need the background path; gpt-5-pro can take ~10 minutes.
- Wait defaults: gpt-5-pro API runs detach by default; use `--wait` to stay attached. gpt-5.1 and browser runs block by default; every run prints `oracle session <id>` for reattach.
- Session storage: Oracle stores session data under `~/.oracle`; delete it if you need a clean slate.
- CLI output: the first line of any top-level CLI start banner should use the oracle emoji, e.g. `🧿 oracle (<version>) ...`; keep it only for the initial command headline. Exception: the TUI exit message also keeps the emoji.
- Model access note (2025-11-23): gpt-5.1-pro and grok-4.1 are not yet available on Peter’s keys; live tests that require them will fail until access is granted.
- Oracle CLI on Node 25: if `pnpm dlx @steipete/oracle --help` fails with a missing `node_sqlite3.node`, rebuild sqlite3 in the pnpm dlx cache using system Python: `PYTHON=/usr/bin/python3 /Users/steipete/Projects/oracle/runner npx node-gyp rebuild` from the sqlite3 package dir printed in the error, then rerun the command.
- Before a release, skim manual smokes in `docs/manual-tests.md` and rerun any that cover your change surface (especially browser/serve paths).
- If browser smokes echo the prompt (Instant), rerun with `--browser-keep-browser --verbose` in tmux, then inspect DOM with `pnpm tsx scripts/browser-tools.ts eval ...` to confirm assistant turns exist; we fixed a case by refreshing assistant snapshots post-send.
- Browser “Pro thinking” gate: never click/auto-click ChatGPT’s “Answer now” button. Treat it as a placeholder and wait 10m–1h for the real assistant response (auto-clicking skips long thinking and changes behavior).
- Browser smokes should preserve Markdown (lists, fences); if output looks flattened or echoed, inspect the captured assistant turn via `browser-tools.ts eval` before shipping.
- Working on Windows? Read and update `docs/windows-work.md` before you start.
- Sparkle signing key lives at `/Users/steipete/Library/CloudStorage/Dropbox/Backup/Sparkle`; set `SPARKLE_PRIVATE_KEY_FILE` to that path when notarizing the notifier.
- Browser cookie sync + Node 25: if browser runs fail with “Failed to load keytar… Cannot find module '../build/Release/keytar.node'” and no cookies are applied, rebuild keytar in the pnpm dlx cache: run `PYTHON=/usr/bin/python3 /Users/steipete/Projects/oracle/runner npx node-gyp rebuild` inside the keytar directory printed in the error, then rerun the oracle command.
- npm publish OTP: prepare/tag/release first, then run `npm publish ...` and stop at `Enter OTP:`; ask user for the OTP and continue (ok to handle OTP in chat).

Browser-mode debug notes (ChatGPT URL override)
- When a ChatGPT folder/workspace URL is set, Cloudflare can block automation even after cookie sync. Use `--browser-keep-browser` to leave Chrome open, solve the interstitial manually, then rerun.
- If a run stalls/looks finished but CLI didn’t stream output, check the latest session (`oracle status`) and open it (`oracle session <id> --render`) to confirm completion.
- Active Chrome port/pid live in session metadata (`~/.oracle/sessions/<id>/meta.json`). Connect with `npx tsx scripts/browser-tools.ts eval --port <port> "({ href: window.location.href, ready: document.readyState })"` to inspect the page.
- To debug with agent-tools, launch Chrome via an Oracle browser run (cookies copied) and keep it open (`--browser-keep-browser`). Then use `~/Projects/agent-scripts/bin/browser-tools ... --port <port>` with the port from `~/.oracle/sessions/<id>/meta.json`. Avoid starting a fresh browser-tools Chrome when you need the synced cookies.
- Double-hop nav is implemented (root then target URL), but Cloudflare may still need manual clearance or inline cookies.
- After finishing a feature, ask whether it matters to end users; if yes, update the changelog. Read the top ~100 lines first and group related edits into one entry instead of scattering multiple bullets.
- Beta publishing: when asked to ship a beta to npm, bump the version with a beta suffix (e.g., `0.4.4-beta.1`) before publishing; npm will not let you overwrite an existing beta tag without a new version.


--- README.md ---
# oracle 🧿 — Whispering your tokens to the silicon sage

<p align="center">
  <img src="./README-header.png" alt="Oracle CLI header banner" width="1100">
</p>

<p align="center">
  <a href="https://www.npmjs.com/package/@steipete/oracle"><img src="https://img.shields.io/npm/v/@steipete/oracle?style=for-the-badge&logo=npm&logoColor=white" alt="npm version"></a>
  <a href="https://github.com/steipete/oracle/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/steipete/oracle/ci.yml?branch=main&style=for-the-badge&label=tests" alt="CI Status"></a>
  <a href="https://github.com/steipete/oracle"><img src="https://img.shields.io/badge/platforms-macOS%20%7C%20Linux%20%7C%20Windows-blue?style=for-the-badge" alt="Platforms"></a>
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-green?style=for-the-badge" alt="MIT License"></a>
</p>

Oracle bundles your prompt and files so another AI can answer with real context. It speaks GPT-5.1 Pro (default alias to GPT-5.2 Pro on the API), GPT-5.1 Codex (API-only), GPT-5.1, GPT-5.2, Gemini 3 Pro, Claude Sonnet 4.5, Claude Opus 4.1, and more—and it can ask one or multiple models in a single run. Browser automation is available; use `--browser-model-strategy current` to keep the active ChatGPT model (or `ignore` to skip the picker). API remains the most reliable path, and `--copy` is an easy manual fallback.

## Quick start

Install globally: `npm install -g @steipete/oracle`
Homebrew: `brew install steipete/tap/oracle`

Requires Node 22+. Or use `npx -y @steipete/oracle …` (or pnpx).

```bash
# Copy the bundle and paste into ChatGPT
npx -y @steipete/oracle --render --copy -p "Review the TS data layer for schema drift" --file "src/**/*.ts,*/*.test.ts"

# Minimal API run (expects OPENAI_API_KEY in your env)
npx -y @steipete/oracle -p "Write a concise architecture note for the storage adapters" --file src/storage/README.md

# Multi-model API run
npx -y @steipete/oracle -p "Cross-check the data layer assumptions" --models gpt-5.1-pro,gemini-3-pro --file "src/**/*.ts"

# Preview without spending tokens
npx -y @steipete/oracle --dry-run summary -p "Check release notes" --file docs/release-notes.md

# Browser run (no API key, will open ChatGPT)
npx -y @steipete/oracle --engine browser -p "Walk through the UI smoke test" --file "src/**/*.ts"

# Gemini browser mode (no API key; uses Chrome cookies from gemini.google.com)
npx -y @steipete/oracle --engine browser --model gemini-3-pro --prompt "a cute robot holding a banana" --generate-image out.jpg --aspect 1:1

# Sessions (list and replay)
npx -y @steipete/oracle status --hours 72
npx -y @steipete/oracle session <id> --render

# TUI (interactive, only for humans)
npx -y @steipete/oracle tui
```

Engine auto-picks API when `OPENAI_API_KEY` is set, otherwise browser; browser is stable on macOS and works on Linux and Windows. On Linux pass `--browser-chrome-path/--browser-cookie-path` if detection fails; on Windows prefer `--browser-manual-login` or inline cookies if decryption is blocked.

## Integration

**CLI**
- API mode expects API keys in your environment: `OPENAI_API_KEY` (GPT-5.x), `GEMINI_API_KEY` (Gemini 3 Pro), `ANTHROPIC_API_KEY` (Claude Sonnet 4.5 / Opus 4.1).
- Gemini browser mode uses Chrome cookies instead of an API key—just be logged into `gemini.google.com` in Chrome (no Python/venv required).
- If your Gemini account can’t access “Pro”, Oracle auto-falls back to a supported model for web runs (and logs the fallback in verbose mode).
- Prefer API mode or `--copy` + manual paste; browser automation is experimental.
- Browser support: stable on macOS; works on Linux (add `--browser-chrome-path/--browser-cookie-path` when needed) and Windows (manual-login or inline cookies recommended when app-bound cookies block decryption).
- Remote browser service: `oracle serve` on a signed-in host; clients use `--remote-host/--remote-token`.
- AGENTS.md/CLAUDE.md:
  ```
  - Oracle bundles a prompt plus the right files so another AI (GPT 5 Pro + more) can answer. Use when stuck/bugs/reviewing.
  - Run `npx -y @steipete/oracle --help` once per session before first use.
  ```
- Tip: set `browser.chatgptUrl` in config (or `--chatgpt-url`) to a dedicated ChatGPT project folder so browser runs don’t clutter your main history.

**Codex skill**
- Copy the bundled skill from this repo to your Codex skills folder:
  - `mkdir -p ~/.codex/skills`
  - `cp -R skills/oracle ~/.codex/skills/oracle`
- Then reference it in your `AGENTS.md`/`CLAUDE.md` so Codex loads it.

**MCP**
- Run the stdio server via `oracle-mcp`.
- Configure clients via [steipete/mcporter](https://github.com/steipete/mcporter) or `.mcp.json`; see [docs/mcp.md](docs/mcp.md) for connection examples.
```bash
npx -y @steipete/oracle oracle-mcp
```
- Cursor setup (MCP): drop a `.cursor/mcp.json` like below, then pick “oracle” in Cursor’s MCP sources. See https://cursor.com/docs/context/mcp for UI steps.
[![Install MCP Server](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/en-US/install-mcp?name=oracle&config=eyJjb21tYW5kIjoibnB4IC15IEBzdGVpcGV0ZS9vcmFjbGUgb3JhY2xlLW1jcCJ9)

```json
{
  "oracle": {
    "command": "oracle-mcp",
    "args": []
  }
}
```

## Highlights

- Bundle once, reuse anywhere (API or experimental browser).
- Multi-model API runs with aggregated cost/usage, including OpenRouter IDs alongside first-party models.
- Render/copy bundles for manual paste into ChatGPT when automation is blocked.
- GPT‑5 Pro API runs detach by default; reattach via `oracle session <id>` / `oracle status` or block with `--wait`.
- Azure endpoints supported via `--azure-endpoint/--azure-deployment/--azure-api-version` or `AZURE_OPENAI_*` envs.
- File safety: globs/excludes, size guards, `--files-report`.
- Sessions you can replay (`oracle status`, `oracle session <id> --render`).
- Session logs and bundles live in `~/.oracle/sessions` (override with `ORACLE_HOME_DIR`).

## Flags you’ll actually use

| Flag | Purpose |
| --- | --- |
| `-p, --prompt <text>` | Required prompt. |
| `-f, --file <paths...>` | Attach files/dirs (globs + `!` excludes). |
| `-e, --engine <api\|browser>` | Choose API or browser (browser is experimental). |
| `-m, --model <name>` | Built-ins (`gpt-5.1-pro` default, `gpt-5-pro`, `gpt-5.1`, `gpt-5.1-codex`, `gpt-5.2`, `gpt-5.2-instant`, `gpt-5.2-pro`, `gemini-3-pro`, `claude-4.5-sonnet`, `claude-4.1-opus`) plus any OpenRouter id (e.g., `minimax/minimax-m2`, `openai/gpt-4o-mini`). |
| `--models <list>` | Comma-separated API models (mix built-ins and OpenRouter ids) for multi-model runs. |
| `--base-url <url>` | Point API runs at LiteLLM/Azure/OpenRouter/etc. |
| `--chatgpt-url <url>` | Target a ChatGPT workspace/folder (browser). |
| `--browser-model-strategy <select\|current\|ignore>` | Control ChatGPT model selection in browser mode (current keeps the active model; ignore skips the picker). |
| `--browser-manual-login` | Skip cookie copy; reuse a persistent automation profile and wait for manual ChatGPT login. |
| `--browser-thinking-time <light\|standard\|extended\|heavy>` | Set ChatGPT thinking-time intensity (browser; Thinking/Pro models only). |
| `--browser-port <port>` | Pin the Chrome DevTools port (WSL/Windows firewall helper). |
| `--browser-inline-cookies[(-file)] <payload|path>` | Supply cookies without Chrome/Keychain (browser). |
| `--browser-timeout`, `--browser-input-timeout` | Control overall/browser input timeouts (supports h/m/s/ms). |
| `--render`, `--copy` | Print and/or copy the assembled markdown bundle. |
| `--wait` | Block for background API runs (e.g., GPT‑5.1 Pro) instead of detaching. |
| `--write-output <path>` | Save only the final answer (multi-model adds `.<model>`). |
| `--files-report` | Print per-file token usage. |
| `--dry-run [summary\|json\|full]` | Preview without sending. |
| `--remote-host`, `--remote-token` | Use a remote `oracle serve` host (browser). |
| `--remote-chrome <host:port>` | Attach to an existing remote Chrome session (browser). |
| `--youtube <url>` | YouTube video URL to analyze (Gemini browser mode). |
| `--generate-image <file>` | Generate image and save to file (Gemini browser mode). |
| `--edit-image <file>` | Edit existing image with `--output` (Gemini browser mode). |
| `--azure-endpoint`, `--azure-deployment`, `--azure-api-version` | Target Azure OpenAI endpoints (picks Azure client automatically). |

## Configuration

Put defaults in `~/.oracle/config.json` (JSON5). Example:
```json5
{
  model: "gpt-5.1-pro",
  engine: "api",
  filesReport: true,
  browser: {
    chatgptUrl: "https://chatgpt.com/g/g-p-691edc9fec088191b553a35093da1ea8-oracle/project"
  }
}
```
Use `browser.chatgptUrl` (or the legacy alias `browser.url`) to target a specific ChatGPT workspace/folder for browser automation.
See [docs/configuration.md](docs/configuration.md) for precedence and full schema.

Advanced flags

| Area | Flags |
| --- | --- |
| Browser | `--browser-manual-login`, `--browser-thinking-time`, `--browser-timeout`, `--browser-input-timeout`, `--browser-cookie-wait`, `--browser-inline-cookies[(-file)]`, `--browser-attachments`, `--browser-inline-files`, `--browser-bundle-files`, `--browser-keep-browser`, `--browser-headless`, `--browser-hide-window`, `--browser-no-cookie-sync`, `--browser-allow-cookie-errors`, `--browser-chrome-path`, `--browser-cookie-path`, `--chatgpt-url` |
| Azure/OpenAI | `--azure-endpoint`, `--azure-deployment`, `--azure-api-version`, `--base-url` |

Remote browser example
```bash
# Host (signed-in Chrome): launch serve
oracle serve --host 0.0.0.0:9473 --token secret123

# Client: target that host
oracle --engine browser --remote-host 192.168.1.10:9473 --remote-token secret123 -p "Run the UI smoke" --file "src/**/*.ts"

# If cookies can’t sync, pass them inline (JSON/base64)
oracle --engine browser --browser-inline-cookies-file ~/.oracle/cookies.json -p "Run the UI smoke" --file "src/**/*.ts"
```

Session management
```bash
# Prune stored sessions (default path ~/.oracle/sessions; override ORACLE_HOME_DIR)
oracle status --clear --hours 168
```

## More docs
- Browser mode & forks: [docs/browser-mode.md](docs/browser-mode.md) (includes `oracle serve` remote service), [docs/chromium-forks.md](docs/chromium-forks.md), [docs/linux.md](docs/linux.md)
- MCP: [docs/mcp.md](docs/mcp.md)
- OpenAI/Azure/OpenRouter endpoints: [docs/openai-endpoints.md](docs/openai-endpoints.md), [docs/openrouter.md](docs/openrouter.md)
- Manual smokes: [docs/manual-tests.md](docs/manual-tests.md)
- Testing: [docs/testing.md](docs/testing.md)

If you’re looking for an even more powerful context-management tool, check out https://repoprompt.com  
Name inspired by: https://ampcode.com/news/oracle

## More free stuff from steipete
- ✂️ [Trimmy](https://trimmy.app) — “Paste once, run once.” Flatten multi-line shell snippets so they paste and run.
- 🟦🟩 [CodexBar](https://codexbar.app) — Keep Codex token windows visible in your macOS menu bar.
- 🧳 [MCPorter](https://mcporter.dev) — TypeScript toolkit + CLI for Model Context Protocol servers.


## Links discovered
- [steipete/mcporter](https://github.com/steipete/mcporter)
- [docs/mcp.md](https://github.com/steipete/oracle/blob/main/docs/mcp.md)
- [![Install MCP Server](https://cursor.com/deeplink/mcp-install-dark.svg)
- [docs/configuration.md](https://github.com/steipete/oracle/blob/main/docs/configuration.md)
- [docs/browser-mode.md](https://github.com/steipete/oracle/blob/main/docs/browser-mode.md)
- [docs/chromium-forks.md](https://github.com/steipete/oracle/blob/main/docs/chromium-forks.md)
- [docs/linux.md](https://github.com/steipete/oracle/blob/main/docs/linux.md)
- [docs/openai-endpoints.md](https://github.com/steipete/oracle/blob/main/docs/openai-endpoints.md)
- [docs/openrouter.md](https://github.com/steipete/oracle/blob/main/docs/openrouter.md)
- [docs/manual-tests.md](https://github.com/steipete/oracle/blob/main/docs/manual-tests.md)
- [docs/testing.md](https://github.com/steipete/oracle/blob/main/docs/testing.md)
- [Trimmy](https://trimmy.app)
- [CodexBar](https://codexbar.app)
- [MCPorter](https://mcporter.dev)
- [<img src="https://img.shields.io/npm/v/@steipete/oracle?style=for-the-badge&logo=npm&logoColor=white" alt="npm version">](https://www.npmjs.com/package/@steipete/oracle)
- [<img src="https://img.shields.io/github/actions/workflow/status/steipete/oracle/ci.yml?branch=main&style=for-the-badge&label=tests" alt="CI Status">](https://github.com/steipete/oracle/actions/workflows/ci.yml)
- [<img src="https://img.shields.io/badge/platforms-macOS%20%7C%20Linux%20%7C%20Windows-blue?style=for-the-badge" alt="Platforms">](https://github.com/steipete/oracle)
- [<img src="https://img.shields.io/badge/license-MIT-green?style=for-the-badge" alt="MIT License">](https://github.com/steipete/oracle/blob/main/LICENSE.md)

--- skills/oracle/SKILL.md ---
---
name: oracle
description: Use the @steipete/oracle CLI to bundle a prompt plus the right files and get a second-model review (API or browser) for debugging, refactors, design checks, or cross-validation.
---

# Oracle (CLI) — best use

Oracle bundles your prompt + selected files into one “one-shot” request so another model can answer with real repo context (API or browser automation). Treat outputs as advisory: verify against the codebase + tests.

## Main use case (browser, GPT‑5.2 Pro)

Default workflow here: `--engine browser` with GPT‑5.2 Pro in ChatGPT. This is the “human in the loop” path: it can take ~10 minutes to ~1 hour; expect a stored session you can reattach to.

Recommended defaults:
- Engine: browser (`--engine browser`)
- Model: GPT‑5.2 Pro (either `--model gpt-5.2-pro` or a ChatGPT picker label like `--model "5.2 Pro"`)
- Attachments: directories/globs + excludes; avoid secrets.

## Golden path (fast + reliable)

1. Pick a tight file set (fewest files that still contain the truth).
2. Preview what you’re about to send (`--dry-run` + `--files-report` when needed).
3. Run in browser mode for the usual GPT‑5.2 Pro ChatGPT workflow; use API only when you explicitly want it.
4. If the run detaches/timeouts: reattach to the stored session (don’t re-run).

## Commands (preferred)

- Show help (once/session):
  - `npx -y @steipete/oracle --help`

- Preview (no tokens):
  - `npx -y @steipete/oracle --dry-run summary -p "<task>" --file "src/**" --file "!**/*.test.*"`
  - `npx -y @steipete/oracle --dry-run full -p "<task>" --file "src/**"`

- Token/cost sanity:
  - `npx -y @steipete/oracle --dry-run summary --files-report -p "<task>" --file "src/**"`

- Browser run (main path; long-running is normal):
  - `npx -y @steipete/oracle --engine browser --model gpt-5.2-pro -p "<task>" --file "src/**"`

- Manual paste fallback (assemble bundle, copy to clipboard):
  - `npx -y @steipete/oracle --render --copy -p "<task>" --file "src/**"`
  - Note: `--copy` is a hidden alias for `--copy-markdown`.

## Attaching files (`--file`)

`--file` accepts files, directories, and globs. You can pass it multiple times; entries can be comma-separated.

- Include:
  - `--file "src/**"` (directory glob)
  - `--file src/index.ts` (literal file)
  - `--file docs --file README.md` (literal directory + file)

- Exclude (prefix with `!`):
  - `--file "src/**" --file "!src/**/*.test.ts" --file "!**/*.snap"`

- Defaults (important behavior from the implementation):
  - Default-ignored dirs: `node_modules`, `dist`, `coverage`, `.git`, `.turbo`, `.next`, `build`, `tmp` (skipped unless you explicitly pass them as literal dirs/files).
  - Honors `.gitignore` when expanding globs.
  - Does not follow symlinks (glob expansion uses `followSymbolicLinks: false`).
  - Dotfiles are filtered unless you explicitly opt in with a pattern that includes a dot-segment (e.g. `--file ".github/**"`).
  - Hard cap: files > 1 MB are rejected (split files or narrow the match).

## Budget + observability

- Target: keep total input under ~196k tokens.
- Use `--files-report` (and/or `--dry-run json`) to spot the token hogs before spending.
- If you need hidden/advanced knobs: `npx -y @steipete/oracle --help --verbose`.

## Engines (API vs browser)

- Auto-pick: uses `api` when `OPENAI_API_KEY` is set, otherwise `browser`.
- Browser engine supports GPT + Gemini only; use `--engine api` for Claude/Grok/Codex or multi-model runs.
- **API runs require explicit user consent** before starting because they incur usage costs.
- Browser attachments:
  - `--browser-attachments auto|never|always` (auto pastes inline up to ~60k chars then uploads).
- Remote browser host (signed-in machine runs automation):
  - Host: `oracle serve --host 0.0.0.0 --port 9473 --token <secret>`
  - Client: `oracle --engine browser --remote-host <host:port> --remote-token <secret> -p "<task>" --file "src/**"`

## Sessions + slugs (don’t lose work)

- Stored under `~/.oracle/sessions` (override with `ORACLE_HOME_DIR`).
- Runs may detach or take a long time (browser + GPT‑5.2 Pro often does). If the CLI times out: don’t re-run; reattach.
  - List: `oracle status --hours 72`
  - Attach: `oracle session <id> --render`
- Use `--slug "<3-5 words>"` to keep session IDs readable.
- Duplicate prompt guard exists; use `--force` only when you truly want a fresh run.

## Prompt template (high signal)

Oracle starts with **zero** project knowledge. Assume the model cannot infer your stack, build tooling, conventions, or “obvious” paths. Include:
- Project briefing (stack + build/test commands + platform constraints).
- “Where things live” (key directories, entrypoints, config files, dependency boundaries).
- Exact question + what you tried + the error text (verbatim).
- Constraints (“don’t change X”, “must keep public API”, “perf budget”, etc).
- Desired output (“return patch plan + tests”, “list risky assumptions”, “give 3 options with tradeoffs”).

### “Exhaustive prompt” pattern (for later restoration)

When you know this will be a long investigation, write a prompt that can stand alone later:
- Top: 6–30 sentence project briefing + current goal.
- Middle: concrete repro steps + exact errors + what you already tried.
- Bottom: attach *all* context files needed so a fresh model can fully understand (entrypoints, configs, key modules, docs).

If you need to reproduce the same context later, re-run with the same prompt + `--file …` set (Oracle runs are one-shot; the model doesn’t remember prior runs).

## Safety

- Don’t attach secrets by default (`.env`, key files, auth tokens). Redact aggressively; share only what’s required.
- Prefer “just enough context”: fewer files + better prompt beats whole-repo dumps.


--- vendor/oracle-notifier/README.md ---
# Oracle Notifier helper (macOS, arm64)

Builds a tiny signed helper app for macOS notifications with the Oracle icon.

## Build

```bash
cd vendor/oracle-notifier
# Optional: notarize by setting App Store Connect key credentials
export APP_STORE_CONNECT_API_KEY_P8="$(cat AuthKey_XXXXXX.p8)" # with literal newlines or \n escaped
export APP_STORE_CONNECT_KEY_ID=XXXXXX
export APP_STORE_CONNECT_ISSUER_ID=YYYYYYYY-YYYY-YYYY-YYYY-YYYYYYYYYYYY
./build-notifier.sh
```

- Requires Xcode command line tools (swiftc) and a macOS Developer ID certificate. Without a valid cert, the build fails (no ad-hoc fallback).
- If `APP_STORE_CONNECT_*` vars are set, the script notarizes and staples the ticket.
- Output: `OracleNotifier.app` (arm64 only), bundled with `OracleIcon.icns`.

## Usage
The CLI prefers this helper on macOS; if it fails or is missing, it falls back to toasted-notifier/terminal-notifier.

## Permissions
After first run, allow notifications for “Oracle Notifier” in System Settings → Notifications.


--- vitest.config.ts ---
import { defineConfig } from 'vitest/config';
import { fileURLToPath } from 'node:url';
import path from 'node:path';

export default defineConfig({
  test: {
    setupFiles: ['tests/setup-env.ts', 'tests/cli/runOracle/setup.ts'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'lcov'],
      all: true,
      // Measure the real TypeScript sources (the repo doesn’t ship .js in src).
      include: ['src/**/*.ts'],
      // Exclude interactive/IPC entrypoints that aren’t practical to unit test.
      exclude: [
        'src/cli/tui/**',
        'src/remote/**',
        'src/mcp/**',
        'src/browser/actions/**',
        'src/browser/index.ts',
        'src/browser/pageActions.ts',
        'src/browser/chromeLifecycle.ts',
        'src/browserMode.ts',
        'src/oracle.ts',
        'src/oracle/modelRunner.ts',
        'src/oracle/stringifier.ts',
        'src/oracle/types.ts',
        'src/types/**',
      ],
    },
  },
  resolve: {
    alias: {
      '@src': fileURLToPath(new URL('./src', import.meta.url)),
      '@tests': fileURLToPath(new URL('./tests', import.meta.url)),
    },
  },
});


--- bin/oracle-cli.ts ---
#!/usr/bin/env node
import 'dotenv/config';
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { once } from 'node:events';
import { Command, Option } from 'commander';
import type { OptionValues } from 'commander';
// Allow `npx @steipete/oracle oracle-mcp` to resolve the MCP server even though npx runs the default binary.
if (process.argv[2] === 'oracle-mcp') {
  const { startMcpServer } = await import('../src/mcp/server.js');
  await startMcpServer();
  process.exit(0);
}
import { resolveEngine, type EngineMode, defaultWaitPreference } from '../src/cli/engine.js';
import { shouldRequirePrompt } from '../src/cli/promptRequirement.js';
import chalk from 'chalk';
import type { SessionMetadata, SessionMode, BrowserSessionConfig } from '../src/sessionStore.js';
import { sessionStore, pruneOldSessions } from '../src/sessionStore.js';
import { DEFAULT_MODEL, MODEL_CONFIGS, runOracle, readFiles, estimateRequestTokens, buildRequestBody } from '../src/oracle.js';
import { isKnownModel } from '../src/oracle/modelResolver.js';
import type { ModelName, PreviewMode, RunOracleOptions } from '../src/oracle.js';
import { CHATGPT_URL, normalizeChatgptUrl } from '../src/browserMode.js';
import { createRemoteBrowserExecutor } from '../src/remote/client.js';
import { createGeminiWebExecutor } from '../src/gemini-web/index.js';
import { applyHelpStyling } from '../src/cli/help.js';
import {
  collectPaths,
  collectModelList,
  parseFloatOption,
  parseIntOption,
  parseSearchOption,
  usesDefaultStatusFilters,
  resolvePreviewMode,
  normalizeModelOption,
  normalizeBaseUrl,
  resolveApiModel,
  inferModelFromLabel,
  parseHeartbeatOption,
  parseTimeoutOption,
  mergePathLikeOptions,
  dedupePathInputs,
} from '../src/cli/options.js';
import { copyToClipboard } from '../src/cli/clipboard.js';
import { buildMarkdownBundle } from '../src/cli/markdownBundle.js';
import { shouldDetachSession } from '../src/cli/detach.js';
import { applyHiddenAliases } from '../src/cli/hiddenAliases.js';
import { buildBrowserConfig, resolveBrowserModelLabel } from '../src/cli/browserConfig.js';
import { performSessionRun } from '../src/cli/sessionRunner.js';
import type { BrowserSessionRunnerDeps } from '../src/browser/sessionRunner.js';
import { isMediaFile } from '../src/browser/prompt.js';
import { attachSession, showStatus, formatCompletionSummary } from '../src/cli/sessionDisplay.js';
import type { ShowStatusOptions } from '../src/cli/sessionDisplay.js';
import { formatCompactNumber } from '../src/cli/format.js';
import { formatIntroLine } from '../src/cli/tagline.js';
import { warnIfOversizeBundle } from '../src/cli/bundleWarnings.js';
import { formatRenderedMarkdown } from '../src/cli/renderOutput.js';
import { resolveRenderFlag, resolveRenderPlain } from '../src/cli/renderFlags.js';
import { resolveGeminiModelId } from '../src/oracle/gemini.js';
import { handleSessionCommand, type StatusOptions, formatSessionCleanupMessage } from '../src/cli/sessionCommand.js';
import { isErrorLogged } from '../src/cli/errorUtils.js';
import { handleSessionAlias, handleStatusFlag } from '../src/cli/rootAlias.js';
import { resolveOutputPath } from '../src/cli/writeOutputPath.js';
import { getCliVersion } from '../src/version.js';
import { runDryRunSummary, runBrowserPreview } from '../src/cli/dryRun.js';
import { launchTui } from '../src/cli/tui/index.js';
import {
  resolveNotificationSettings,
  deriveNotificationSettingsFromMetadata,
  type NotificationSettings,
} from '../src/cli/notifier.js';
import { loadUserConfig, type UserConfig } from '../src/config.js';
import { applyBrowserDefaultsFromConfig } from '../src/cli/browserDefaults.js';
import { shouldBlockDuplicatePrompt } from '../src/cli/duplicatePromptGuard.js';

interface CliOptions extends OptionValues {
  prompt?: string;
  message?: string;
  file?: string[];
  include?: string[];
  files?: string[];
  path?: string[];
  paths?: string[];
  render?: boolean;
  model: string;
  models?: string[];
  force?: boolean;
  slug?: string;
  filesReport?: boolean;
  maxInput?: number;
  maxOutput?: number;
  system?: string;
  silent?: boolean;
  search?: boolean;
  preview?: boolean | string;
  previewMode?: PreviewMode;
  apiKey?: string;
  session?: string;
  execSession?: string;
  notify?: boolean;
  notifySound?: boolean;
  renderMarkdown?: boolean;
  sessionId?: string;
  engine?: EngineMode;
  browser?: boolean;
  timeout?: number | 'auto';
  browserChromeProfile?: string;
  browserChromePath?: string;
  browserCookiePath?: string;
  chatgptUrl?: string;
  browserUrl?: string;
  browserTimeout?: string;
  browserInputTimeout?: string;
  browserCookieWait?: string;
  browserNoCookieSync?: boolean;
  browserInlineCookiesFile?: string;
  browserCookieNames?: string;
  browserInlineCookies?: string;
  browserHeadless?: boolean;
  browserHideWindow?: boolean;
  browserKeepBrowser?: boolean;
  browserModelStrategy?: 'select' | 'current' | 'ignore';
  browserManualLogin?: boolean;
  browserManualLoginProfileDir?: string;
  browserThinkingTime?: 'light' | 'standard' | 'extended' | 'heavy';
  browserAllowCookieErrors?: boolean;
  browserAttachments?: string;
  browserInlineFiles?: boolean;
  browserBundleFiles?: boolean;
  remoteChrome?: string;
  browserPort?: number;
  browserDebugPort?: number;
  remoteHost?: string;
  remoteToken?: string;
  copyMarkdown?: boolean;
  copy?: boolean;
  verbose?: boolean;
  debugHelp?: boolean;
  heartbeat?: number;
  status?: boolean;
  dryRun?: boolean;
  wait?: boolean;
  noWait?: boolean;
  baseUrl?: string;
  azureEndpoint?: string;
  azureDeployment?: string;
  azureApiVersion?: string;
  showModelId?: boolean;
  retainHours?: number;
  writeOutput?: string;
  writeOutputPath?: string;
}

type ResolvedCliOptions = Omit<CliOptions, 'model'> & {
  model: ModelName;
  models?: ModelName[];
  effectiveModelId?: string;
  writeOutputPath?: string;
};

const VERSION = getCliVersion();
const CLI_ENTRYPOINT = fileURLToPath(import.meta.url);
const rawCliArgs = process.argv.slice(2);
const userCliArgs = rawCliArgs[0] === CLI_ENTRYPOINT ? rawCliArgs.slice(1) : rawCliArgs;
const isTty = process.stdout.isTTY;

const program = new Command();
let introPrinted = false;
program.hook('preAction', () => {
  if (introPrinted) return;
  console.log(formatIntroLine(VERSION, { env: process.env, richTty: isTty }));
  introPrinted = true;
});
applyHelpStyling(program, VERSION, isTty);
program.hook('preAction', (thisCommand) => {
  if (thisCommand !== program) {
    return;
  }
  if (userCliArgs.some((arg) => arg === '--help' || arg === '-h')) {
    return;
  }
  if (userCliArgs.length === 0) {
    // Let the root action handle zero-arg entry (help + hint to `oracle tui`).
    return;
  }
  const opts = thisCommand.optsWithGlobals() as CliOptions;
  applyHiddenAliases(opts, (key, value) => thisCommand.setOptionValue(key, value));
  const positional = thisCommand.args?.[0] as string | undefined;
  if (!opts.prompt && positional) {
    opts.prompt = positional;
    thisCommand.setOptionValue('prompt', positional);
  }
  if (shouldRequirePrompt(userCliArgs, opts)) {
    console.log(chalk.yellow('Prompt is required. Provide it via --prompt "<text>" or positional [prompt].'));
    thisCommand.help({ error: false });
    process.exitCode = 1;
    return;
  }
});
program
  .name('oracle')
  .description('One-shot GPT-5.2 Pro / GPT-5.2 / GPT-5.1 Codex tool for hard questions that benefit from large file context and server-side search.')
  .version(VERSION)
  .argument('[prompt]', 'Prompt text (shorthand for --prompt).')
  .option('-p, --prompt <text>', 'User prompt to send to the model.')
  .addOption(new Option('--message <text>', 'Alias for --prompt.').hideHelp())
  .option(
    '-f, --file <paths...>',
    'Files/directories or glob patterns to attach (prefix with !pattern to exclude). Files larger than 1 MB are rejected automatically.',
    collectPaths,
    [],
  )
  .addOption(
    new Option('--include <paths...>', 'Alias for --file.')
      .argParser(collectPaths)
      .default([])
      .hideHelp(),
  )
  .addOption(
    new Option('--files <paths...>', 'Alias for --file.')
      .argParser(collectPaths)
      .default([])
      .hideHelp(),
  )
  .addOption(
    new Option('--path <paths...>', 'Alias for --file.')
      .argParser(collectPaths)
      .default([])
      .hideHelp(),
  )
  .addOption(
    new Option('--paths <paths...>', 'Alias for --file.')
      .argParser(collectPaths)
      .default([])
      .hideHelp(),
  )
  .addOption(
    new Option(
      '--copy-markdown',
      'Copy the assembled markdown bundle to the clipboard; pair with --render to print it too.',
    ).default(false),
  )
  .addOption(new Option('--copy').hideHelp().default(false))
  .option('-s, --slug <words>', 'Custom session slug (3-5 words).')
  .option(
    '-m, --model <model>',
    'Model to target (gpt-5.2-pro default; also supports gpt-5.1-pro alias). Also gpt-5-pro, gpt-5.1, gpt-5.1-codex API-only, gpt-5.2, gpt-5.2-instant, gpt-5.2-pro, gemini-3-pro, claude-4.5-sonnet, claude-4.1-opus, or ChatGPT labels like "5.2 Thinking" for browser runs).',
    normalizeModelOption,
  )
  .addOption(
    new Option(
      '--models <models>',
      'Comma-separated API model list to query in parallel (e.g., "gpt-5.2-pro,gemini-3-pro").',
    )
      .argParser(collectModelList)
      .default([]),
  )
  .addOption(
    new Option(
      '-e, --engine <mode>',
      'Execution engine (api | browser). Browser engine: GPT models automate ChatGPT; Gemini models use a cookie-based client for gemini.google.com. If omitted, oracle picks api when OPENAI_API_KEY is set, otherwise browser.',
    ).choices(['api', 'browser'])
  )
  .addOption(
    new Option('--mode <mode>', 'Alias for --engine (api | browser).').choices(['api', 'browser']).hideHelp(),
  )
  .option('--files-report', 'Show token usage per attached file (also prints automatically when files exceed the token budget).', false)
  .option('-v, --verbose', 'Enable verbose logging for all operations.', false)
  .addOption(
    new Option('--[no-]notify', 'Desktop notification when a session finishes (default on unless CI/SSH).')
      .default(undefined),
  )
  .addOption(
    new Option('--[no-]notify-sound', 'Play a notification sound on completion (default off).').default(undefined),
  )
  .addOption(
    new Option(
      '--timeout <seconds|auto>',
      'Overall timeout before aborting the API call (auto = 60m for gpt-5.2-pro, 120s otherwise).',
    )
      .argParser(parseTimeoutOption)
      .default('auto'),
  )
  .addOption(
    new Option(
      '--preview [mode]',
      '(alias) Preview the request without calling the model (summary | json | full). Deprecated: use --dry-run instead.',
    )
      .hideHelp()
      .choices(['summary', 'json', 'full'])
      .preset('summary'),
  )
  .addOption(
    new Option('--dry-run [mode]', 'Preview without calling the model (summary | json | full).')
      .choices(['summary', 'json', 'full'])
      .preset('summary')
      .default(false),
  )
  .addOption(new Option('--exec-session <id>').hideHelp())
  .addOption(new Option('--session <id>').hideHelp())
  .addOption(new Option('--status', 'Show stored sessions (alias for `oracle status`).').default(false).hideHelp())
  .option(
    '--render-markdown',
    'Print the assembled markdown bundle for prompt + files and exit; pair with --copy to put it on the clipboard.',
    false,
  )
  .option('--render', 'Alias for --render-markdown.', false)
  .option('--render-plain', 'Render markdown without ANSI/highlighting (use plain text even in a TTY).', false)
  .option(
    '--write-output <path>',
    'Write only the final assistant message to this file (overwrites; multi-model appends .<model> before the extension).',
  )
  .option('--verbose-render', 'Show render/TTY diagnostics when replaying sessions.', false)
  .addOption(
    new Option('--search <mode>', 'Set server-side search behavior (on/off).')
      .argParser(parseSearchOption)
      .hideHelp(),
  )
  .addOption(
    new Option('--max-input <tokens>', 'Override the input token budget for the selected model.')
      .argParser(parseIntOption)
      .hideHelp(),
  )
  .addOption(
    new Option('--max-output <tokens>', 'Override the max output tokens for the selected model.')
      .argParser(parseIntOption)
      .hideHelp(),
  )
  .option(
    '--base-url <url>',
    'Override the OpenAI-compatible base URL for API runs (e.g. LiteLLM proxy endpoint).',
  )
  .option('--azure-endpoint <url>', 'Azure OpenAI Endpoint (e.g. https://resource.openai.azure.com/).')
  .option('--azure-deployment <name>', 'Azure OpenAI Deployment Name.')
  .option('--azure-api-version <version>', 'Azure OpenAI API Version.')
  .addOption(new Option('--browser', '(deprecated) Use --engine browser instead.').default(false).hideHelp())
  .addOption(new Option('--browser-chrome-profile <name>', 'Chrome profile name/path for cookie reuse.').hideHelp())
  .addOption(new Option('--browser-chrome-path <path>', 'Explicit Chrome or Chromium executable path.').hideHelp())
  .addOption(
    new Option('--browser-cookie-path <path>', 'Explicit Chrome/Chromium cookie DB path for session reuse.'),
  )
  .addOption(
    new Option(
      '--chatgpt-url <url>',
      `Override the ChatGPT web URL (e.g., workspace/folder like https://chatgpt.com/g/.../project; default ${CHATGPT_URL}).`,
    ),
  )
  .addOption(new Option('--browser-url <url>', `Alias for --chatgpt-url (default ${CHATGPT_URL}).`).hideHelp())
  .addOption(new Option('--browser-timeout <ms|s|m>', 'Maximum time to wait for an answer (default 1200s / 20m).').hideHelp())
  .addOption(
    new Option('--browser-input-timeout <ms|s|m>', 'Maximum time to wait for the prompt textarea (default 30s).').hideHelp(),
  )
  .addOption(
    new Option(
      '--browser-cookie-wait <ms|s|m>',
      'Wait before retrying cookie sync when Chrome cookies are empty or locked.',
    ).hideHelp(),
  )
  .addOption(
    new Option('--browser-port <port>', 'Use a fixed Chrome DevTools port (helpful on WSL firewalls).')
      .argParser(parseIntOption),
  )
  .addOption(
    new Option('--browser-debug-port <port>', '(alias) Use a fixed Chrome DevTools port.').argParser(parseIntOption).hideHelp(),
  )
  .addOption(new Option('--browser-cookie-names <names>', 'Comma-separated cookie allowlist for sync.').hideHelp())
  .addOption(
    new Option('--browser-inline-cookies <jsonOrBase64>', 'Inline cookies payload (JSON array or base64-encoded JSON).').hideHelp(),
  )
  .addOption(
    new Option('--browser-inline-cookies-file <path>', 'Load inline cookies from file (JSON or base64 JSON).').hideHelp(),
  )
  .addOption(new Option('--browser-no-cookie-sync', 'Skip copying cookies from Chrome.').hideHelp())
  .addOption(
    new Option(
      '--browser-manual-login',
      'Skip cookie copy; reuse a persistent automation profile and wait for manual ChatGPT login.',
    ).hideHelp(),
  )
  .addOption(new Option('--browser-headless', 'Launch Chrome in headless mode.').hideHelp())
  .addOption(new Option('--browser-hide-window', 'Hide the Chrome window after launch (macOS headful only).').hideHelp())
  .addOption(new Option('--browser-keep-browser', 'Keep Chrome running after completion.').hideHelp())
  .addOption(
    new Option(
      '--browser-model-strategy <mode>',
      'ChatGPT model picker strategy: select (default) switches to the requested model, current keeps the active model, ignore skips the picker entirely.',
    ).choices(['select', 'current', 'ignore']),
  )
  .addOption(
    new Option('--browser-thinking-time <level>', 'Thinking time intensity for Thinking/Pro models: light, standard, extended, heavy.')
      .choices(['light', 'standard', 'extended', 'heavy'])
      .hideHelp(),
  )
  .addOption(
    new Option('--browser-allow-cookie-errors', 'Continue even if Chrome cookies cannot be copied.').hideHelp(),
  )
  .addOption(
    new Option(
      '--browser-attachments <mode>',
      'How to deliver --file inputs in browser mode: auto (default) pastes inline up to ~60k chars then uploads; never always paste inline; always always upload.',
    )
      .choices(['auto', 'never', 'always'])
      .default('auto'),
  )
  .addOption(
    new Option(
      '--remote-chrome <host:port>',
      'Connect to remote Chrome DevTools Protocol (e.g., 192.168.1.10:9222 or [2001:db8::1]:9222 for IPv6).',
    ),
  )
  .addOption(new Option('--remote-host <host:port>', 'Delegate browser runs to a remote `oracle serve` instance.'))
  .addOption(new Option('--remote-token <token>', 'Access token for the remote `oracle serve` instance.'))
  .addOption(
    new Option('--browser-inline-files', 'Alias for --browser-attachments never (force pasting file contents inline).').default(false),
  )
  .addOption(new Option('--browser-bundle-files', 'Bundle all attachments into a single archive before uploading.').default(false))
  .addOption(
    new Option(
      '--youtube <url>',
      'YouTube video URL to analyze (Gemini web/cookie mode only; uses your signed-in Chrome cookies for gemini.google.com).',
    ),
  )
  .addOption(
    new Option(
      '--generate-image <file>',
      'Generate image and save to file (Gemini web/cookie mode only; requires gemini.google.com Chrome cookies).',
    ),
  )
  .addOption(new Option('--edit-image <file>', 'Edit existing image (use with --output, Gemini web/cookie mode only).'))
  .addOption(new Option('--output <file>', 'Output file path for image operations (Gemini web/cookie mode only).'))
  .addOption(
    new Option(
      '--aspect <ratio>',
      'Aspect ratio for image generation: 16:9, 1:1, 4:3, 3:4 (Gemini web/cookie mode only).',
    ),
  )
  .addOption(new Option('--gemini-show-thoughts', 'Display Gemini thinking process (Gemini web/cookie mode only).').default(false))
  .option(
    '--retain-hours <hours>',
    'Prune stored sessions older than this many hours before running (set 0 to disable).',
    parseFloatOption,
  )
  .option('--force', 'Force start a new session even if an identical prompt is already running.', false)
  .option('--debug-help', 'Show the advanced/debug option set and exit.', false)
  .option('--heartbeat <seconds>', 'Emit periodic in-progress updates (0 to disable).', parseHeartbeatOption, 30)
  .addOption(new Option('--wait').default(undefined))
  .addOption(new Option('--no-wait').default(undefined).hideHelp())
  .showHelpAfterError('(use --help for usage)');

program.addHelpText(
  'after',
  `
Examples:
  # Quick API run with two files
  oracle --prompt "Summarize the risk register" --file docs/risk-register.md docs/risk-matrix.md

  # Browser run (no API key) + globbed TypeScript sources, excluding tests
  oracle --engine browser --prompt "Review the TS data layer" \\
    --file "src/**/*.ts" --file "!src/**/*.test.ts"

  # Build, print, and copy a markdown bundle (semi-manual)
  oracle --render --copy -p "Review the TS data layer" --file "src/**/*.ts" --file "!src/**/*.test.ts"
`,
);

program
  .command('serve')
  .description('Run Oracle browser automation as a remote service for other machines.')
  .option('--host <address>', 'Interface to bind (default 0.0.0.0).')
  .option('--port <number>', 'Port to listen on (default random).', parseIntOption)
  .option('--token <value>', 'Access token clients must provide (random if omitted).')
  .action(async (commandOptions) => {
    const { serveRemote } = await import('../src/remote/server.js');
    await serveRemote({
      host: commandOptions.host,
      port: commandOptions.port,
      token: commandOptions.token,
    });
  });

program
  .command('tui')
  .description('Launch the interactive terminal UI for humans (no automation).')
  .action(async () => {
    await sessionStore.ensureStorage();
    await launchTui({ version: VERSION, printIntro: false });
  });

const sessionCommand = program
  .command('session [id]')
  .description('Attach to a stored session or list recent sessions when no ID is provided.')
  .option('--hours <hours>', 'Look back this many hours when listing sessions (default 24).', parseFloatOption, 24)
  .option('--limit <count>', 'Maximum sessions to show when listing (max 1000).', parseIntOption, 100)
  .option('--all', 'Include all stored sessions regardless of age.', false)
  .option('--clear', 'Delete stored sessions older than the provided window (24h default).', false)
  .option('--hide-prompt', 'Hide stored prompt when displaying a session.', false)
  .option('--render', 'Render completed session output as markdown (rich TTY only).', false)
  .option('--render-markdown', 'Alias for --render.', false)
  .option('--model <name>', 'Filter sessions/output for a specific model.', '')
  .option('--path', 'Print the stored session paths instead of attaching.', false)
  .addOption(new Option('--clean', 'Deprecated alias for --clear.').default(false).hideHelp())
  .action(async (sessionId, _options: StatusOptions, cmd: Command) => {
    await handleSessionCommand(sessionId, cmd);
  });

const statusCommand = program
  .command('status [id]')
  .description('List recent sessions (24h window by default) or attach to a session when an ID is provided.')
  .option('--hours <hours>', 'Look back this many hours (default 24).', parseFloatOption, 24)
  .option('--limit <count>', 'Maximum sessions to show (max 1000).', parseIntOption, 100)
  .option('--all', 'Include all stored sessions regardless of age.', false)
  .option('--clear', 'Delete stored sessions older than the provided window (24h default).', false)
  .option('--render', 'Render completed session output as markdown (rich TTY only).', false)
  .option('--render-markdown', 'Alias for --render.', false)
  .option('--model <name>', 'Filter sessions/output for a specific model.', '')
  .option('--hide-prompt', 'Hide stored prompt when displaying a session.', false)
  .addOption(new Option('--clean', 'Deprecated alias for --clear.').default(false).hideHelp())
  .action(async (sessionId: string | undefined, _options: StatusOptions, command: Command) => {
    const statusOptions = command.opts<StatusOptions>();
    const clearRequested = Boolean(statusOptions.clear || statusOptions.clean);
    if (clearRequested) {
      if (sessionId) {
        console.error('Cannot combine a session ID with --clear. Remove the ID to delete cached sessions.');
        process.exitCode = 1;
        return;
      }
      const hours = statusOptions.hours;
      const includeAll = statusOptions.all;
      const result = await sessionStore.deleteOlderThan({ hours, includeAll });
      const scope = includeAll ? 'all stored sessions' : `sessions older than ${hours}h`;
      console.log(formatSessionCleanupMessage(result, scope));
      return;
    }
    if (sessionId === 'clear' || sessionId === 'clean') {
      console.error('Session cleanup now uses --clear. Run "oracle status --clear --hours <n>" instead.');
      process.exitCode = 1;
      return;
    }
    if (sessionId) {
      const autoRender = !command.getOptionValueSource?.('render') && !command.getOptionValueSource?.('renderMarkdown')
        ? process.stdout.isTTY
        : false;
      const renderMarkdown = Boolean(statusOptions.render || statusOptions.renderMarkdown || autoRender);
      await attachSession(sessionId, { renderMarkdown, renderPrompt: !statusOptions.hidePrompt });
      return;
    }
    const showExamples = usesDefaultStatusFilters(command);
    await showStatus({
      hours: statusOptions.all ? Infinity : statusOptions.hours,
      includeAll: statusOptions.all,
      limit: statusOptions.limit,
      showExamples,
    });
  });

function buildRunOptions(options: ResolvedCliOptions, overrides: Partial<RunOracleOptions> = {}): RunOracleOptions {
  if (!options.prompt) {
    throw new Error('Prompt is required.');
  }
  const normalizedBaseUrl = normalizeBaseUrl(overrides.baseUrl ?? options.baseUrl);
  const azure =
    options.azureEndpoint || overrides.azure?.endpoint
      ? {
          endpoint: overrides.azure?.endpoint ?? options.azureEndpoint,
          deployment: overrides.azure?.deployment ?? options.azureDeployment,
          apiVersion: overrides.azure?.apiVersion ?? options.azureApiVersion,
        }
      : undefined;

  return {
    prompt: options.prompt,
    model: options.model,
    models: overrides.models ?? options.models,
    effectiveModelId: overrides.effectiveModelId ?? options.effectiveModelId ?? options.model,
    file: overrides.file ?? options.file ?? [],
    slug: overrides.slug ?? options.slug,
    filesReport: overrides.filesReport ?? options.filesReport,
    maxInput: overrides.maxInput ?? options.maxInput,
    maxOutput: overrides.maxOutput ?? options.maxOutput,
    system: overrides.system ?? options.system,
    timeoutSeconds: overrides.timeoutSeconds ?? (options.timeout as number | 'auto' | undefined),
    silent: overrides.silent ?? options.silent,
    search: overrides.search ?? options.search,
    preview: overrides.preview ?? undefined,
    previewMode: overrides.previewMode ?? options.previewMode,
    apiKey: overrides.apiKey ?? options.apiKey,
    baseUrl: normalizedBaseUrl,
    azure,
    sessionId: overrides.sessionId ?? options.sessionId,
    verbose: overrides.verbose ?? options.verbose,
    heartbeatIntervalMs: overrides.heartbeatIntervalMs ?? resolveHeartbeatIntervalMs(options.heartbeat),
    browserAttachments: overrides.browserAttachments ?? (options.browserAttachments as 'auto' | 'never' | 'always' | undefined) ?? 'auto',
    browserInlineFiles: overrides.browserInlineFiles ?? options.browserInlineFiles ?? false,
    browserBundleFiles: overrides.browserBundleFiles ?? options.browserBundleFiles ?? false,
    background: overrides.background ?? undefined,
    renderPlain: overrides.renderPlain ?? options.renderPlain ?? false,
    writeOutputPath: overrides.writeOutputPath ?? options.writeOutputPath,
  };
}

export function enforceBrowserSearchFlag(
  runOptions: RunOracleOptions,
  sessionMode: SessionMode,
  logFn: (message: string) => void = console.log,
): void {
  if (sessionMode === 'browser' && runOptions.search === false) {
    logFn(chalk.dim('Note: search is not available in browser engine; ignoring search=false.'));
    runOptions.search = undefined;
  }
}

function resolveHeartbeatIntervalMs(seconds: number | undefined): number | undefined {
  if (typeof seconds !== 'number' || seconds <= 0) {
    return undefined;
  }
  return Math.round(seconds * 1000);
}

function buildRunOptionsFromMetadata(metadata: SessionMetadata): RunOracleOptions {
  const stored = metadata.options ?? {};
  return {
    prompt: stored.prompt ?? '',
    model: (stored.model as ModelName) ?? DEFAULT_MODEL,
    models: stored.models as ModelName[] | undefined,
    effectiveModelId: stored.effectiveModelId ?? stored.model,
    file: stored.file ?? [],
    slug: stored.slug,
    filesReport: stored.filesReport,
    maxInput: stored.maxInput,
    maxOutput: stored.maxOutput,
    system: stored.system,
    silent: stored.silent,
    search: stored.search,
    preview: false,
    previewMode: undefined,
    apiKey: undefined,
    baseUrl: normalizeBaseUrl(stored.baseUrl),
    azure: stored.azure,
    sessionId: metadata.id,
    verbose: stored.verbose,
    heartbeatIntervalMs: stored.heartbeatIntervalMs,
    browserAttachments: stored.browserAttachments,
    browserInlineFiles: stored.browserInlineFiles,
    browserBundleFiles: stored.browserBundleFiles,
    background: stored.background,
    renderPlain: stored.renderPlain,
    writeOutputPath: stored.writeOutputPath,
  };
}

function getSessionMode(metadata: SessionMetadata): SessionMode {
  return metadata.mode ?? metadata.options?.mode ?? 'api';
}

function getBrowserConfigFromMetadata(metadata: SessionMetadata): BrowserSessionConfig | undefined {
  return metadata.options?.browserConfig ?? metadata.browser?.config;
}

async function runRootCommand(options: CliOptions): Promise<void> {
  if (process.env.ORACLE_FORCE_TUI === '1') {
    await sessionStore.ensureStorage();
    await launchTui({ version: VERSION, printIntro: false });
    return;
  }
  const userConfig = (await loadUserConfig()).config;
  const helpRequested = rawCliArgs.some((arg: string) => arg === '--help' || arg === '-h');
  const multiModelProvided = Array.isArray(options.models) && options.models.length > 0;
  if (multiModelProvided) {
    const modelFromConfigOrCli = normalizeModelOption(options.model ?? userConfig.model ?? '');
    if (modelFromConfigOrCli) {
      throw new Error('--models cannot be combined with --model.');
    }
  }
  const optionUsesDefault = (name: string): boolean => {
    // Commander reports undefined for untouched options, so treat undefined/default the same
    const source = program.getOptionValueSource?.(name);
    return source == null || source === 'default';
  };
  if (helpRequested) {
    if (options.verbose) {
      console.log('');
      printDebugHelp(program.name());
      console.log('');
    }
    program.help({ error: false });
    return;
  }
  const previewMode = resolvePreviewMode(options.dryRun || options.preview);
  const mergedFileInputs = mergePathLikeOptions(
    options.file,
    options.include,
    options.files,
    options.path,
    options.paths,
  );
  if (mergedFileInputs.length > 0) {
    const { deduped, duplicates } = dedupePathInputs(mergedFileInputs, { cwd: process.cwd() });
    if (duplicates.length > 0) {
      const preview = duplicates.slice(0, 8).join(', ');
      const suffix = duplicates.length > 8 ? ` (+${duplicates.length - 8} more)` : '';
      console.log(chalk.dim(`Ignoring duplicate --file inputs: ${preview}${suffix}`));
    }
    options.file = deduped;
  }
  const copyMarkdown = options.copyMarkdown || options.copy;
  const renderMarkdown = resolveRenderFlag(options.render, options.renderMarkdown);
  const renderPlain = resolveRenderPlain(options.renderPlain, options.render, options.renderMarkdown);

  const applyRetentionOption = (): void => {
    if (optionUsesDefault('retainHours') && typeof userConfig.sessionRetentionHours === 'number') {
      options.retainHours = userConfig.sessionRetentionHours;
    }
    const envRetention = process.env.ORACLE_RETAIN_HOURS;
    if (optionUsesDefault('retainHours') && envRetention) {
      const parsed = Number.parseFloat(envRetention);
      if (!Number.isNaN(parsed)) {
        options.retainHours = parsed;
      }
    }
  };
  applyRetentionOption();

  const remoteHost =
    options.remoteHost ?? userConfig.remoteHost ?? userConfig.remote?.host ?? process.env.ORACLE_REMOTE_HOST;
  const remoteToken =
    options.remoteToken ?? userConfig.remoteToken ?? userConfig.remote?.token ?? process.env.ORACLE_REMOTE_TOKEN;
  if (remoteHost) {
    console.log(chalk.dim(`Remote browser host detected: ${remoteHost}`));
  }

  if (userCliArgs.length === 0) {
    console.log(chalk.yellow('No prompt or subcommand supplied. Run `oracle --help` or `oracle tui` for the TUI.'));
    program.outputHelp();
    return;
  }
  const retentionHours = typeof options.retainHours === 'number' ? options.retainHours : undefined;
  await sessionStore.ensureStorage();
  await pruneOldSessions(retentionHours, (message) => console.log(chalk.dim(message)));

  if (options.debugHelp) {
    printDebugHelp(program.name());
    return;
  }
  if (options.dryRun && options.renderMarkdown) {
    throw new Error('--dry-run cannot be combined with --render-markdown.');
  }

  const preferredEngine = options.engine ?? userConfig.engine;
  let engine: EngineMode = resolveEngine({ engine: preferredEngine, browserFlag: options.browser, env: process.env });
  if (options.browser) {
    console.log(chalk.yellow('`--browser` is deprecated; use `--engine browser` instead.'));
  }
  if (optionUsesDefault('model') && userConfig.model) {
    options.model = userConfig.model;
  }
  if (optionUsesDefault('search') && userConfig.search) {
    options.search = userConfig.search === 'on';
  }
  if (optionUsesDefault('filesReport') && userConfig.filesReport != null) {
    options.filesReport = Boolean(userConfig.filesReport);
  }
  if (optionUsesDefault('heartbeat') && typeof userConfig.heartbeatSeconds === 'number') {
    options.heartbeat = userConfig.heartbeatSeconds;
  }
  if (optionUsesDefault('baseUrl') && userConfig.apiBaseUrl) {
    options.baseUrl = userConfig.apiBaseUrl;
  }

  if (remoteHost && engine !== 'browser') {
    throw new Error('--remote-host requires --engine browser.');
  }
  if (remoteHost && options.remoteChrome) {
    throw new Error('--remote-host cannot be combined with --remote-chrome.');
  }

  if (optionUsesDefault('azureEndpoint')) {
    if (process.env.AZURE_OPENAI_ENDPOINT) {
      options.azureEndpoint = process.env.AZURE_OPENAI_ENDPOINT;
    } else if (userConfig.azure?.endpoint) {
      options.azureEndpoint = userConfig.azure.endpoint;
    }
  }
  if (optionUsesDefault('azureDeployment')) {
    if (process.env.AZURE_OPENAI_DEPLOYMENT) {
      options.azureDeployment = process.env.AZURE_OPENAI_DEPLOYMENT;
    } else if (userConfig.azure?.deployment) {
      options.azureDeployment = userConfig.azure.deployment;
    }
  }
  if (optionUsesDefault('azureApiVersion')) {
    if (process.env.AZURE_OPENAI_API_VERSION) {
      options.azureApiVersion = process.env.AZURE_OPENAI_API_VERSION;
    } else if (userConfig.azure?.apiVersion) {
      options.azureApiVersion = userConfig.azure.apiVersion;
    }
  }

  const normalizedMultiModels: ModelName[] = multiModelProvided
    ? Array.from(new Set(options.models!.map((entry) => resolveApiModel(entry))))
    : [];
  const cliModelArg = normalizeModelOption(options.model) || (multiModelProvided ? '' : DEFAULT_MODEL);
  const resolvedModelCandidate: ModelName = multiModelProvided
    ? normalizedMultiModels[0]
    : engine === 'browser'
      ? inferModelFromLabel(cliModelArg || DEFAULT_MODEL)
      : resolveApiModel(cliModelArg || DEFAULT_MODEL);
  const primaryModelCandidate = normalizedMultiModels[0] ?? resolvedModelCandidate;
  const isGemini = primaryModelCandidate.startsWith('gemini');
  const isCodex = primaryModelCandidate.startsWith('gpt-5.1-codex');
  const isClaude = primaryModelCandidate.startsWith('claude');
  const userForcedBrowser = options.browser || options.engine === 'browser';
  const isBrowserCompatible = (model: string) => model.startsWith('gpt-') || model.startsWith('gemini');
  const hasNonBrowserCompatibleTarget =
    (engine === 'browser' || userForcedBrowser) &&
    (normalizedMultiModels.length > 0
      ? normalizedMultiModels.some((model) => !isBrowserCompatible(model))
      : !isBrowserCompatible(resolvedModelCandidate));
  if (hasNonBrowserCompatibleTarget) {
    throw new Error(
      'Browser engine only supports GPT and Gemini models. Re-run with --engine api for Grok, Claude, or other models.'
    );
  }
  if (isClaude && engine === 'browser') {
    console.log(chalk.dim('Browser engine is not supported for Claude models; switching to API.'));
    engine = 'api';
  }
  if (isCodex && engine === 'browser') {
    console.log(chalk.dim('Browser engine is not supported for gpt-5.1-codex; switching to API.'));
    engine = 'api';
  }
  if (normalizedMultiModels.length > 0) {
    engine = 'api';
  }
  if (remoteHost && normalizedMultiModels.length > 0) {
    throw new Error('--remote-host does not support --models yet. Use API engine locally instead.');
  }
  const resolvedModel: ModelName =
    normalizedMultiModels[0] ?? (isGemini ? resolveApiModel(cliModelArg) : resolvedModelCandidate);
  const effectiveModelId = resolvedModel.startsWith('gemini')
    ? resolveGeminiModelId(resolvedModel)
    : isKnownModel(resolvedModel)
      ? MODEL_CONFIGS[resolvedModel].apiModel ?? resolvedModel
      : resolvedModel;
  const resolvedBaseUrl = normalizeBaseUrl(
    options.baseUrl ?? (isClaude ? process.env.ANTHROPIC_BASE_URL : process.env.OPENAI_BASE_URL),
  );
  const { models: _rawModels, ...optionsWithoutModels } = options;
  const resolvedOptions: ResolvedCliOptions = { ...optionsWithoutModels, model: resolvedModel };
  if (normalizedMultiModels.length > 0) {
    resolvedOptions.models = normalizedMultiModels;
  }
  resolvedOptions.baseUrl = resolvedBaseUrl;
  resolvedOptions.effectiveModelId = effectiveModelId;
  resolvedOptions.writeOutputPath = resolveOutputPath(options.writeOutput, process.cwd());

  // Decide whether to block until completion:
  // - explicit --wait / --no-wait wins
  // - otherwise block for fast models (gpt-5.1, browser) and detach by default for pro API runs
  let waitPreference = resolveWaitFlag({
    waitFlag: options.wait,
    noWaitFlag: options.noWait,
    model: resolvedModel,
    engine,
  });
  if (remoteHost && !waitPreference) {
    console.log(chalk.dim('Remote browser runs require --wait; ignoring --no-wait.'));
    waitPreference = true;
  }

  if (await handleStatusFlag(options, { attachSession, showStatus })) {
    return;
  }

  if (await handleSessionAlias(options, { attachSession })) {
    return;
  }

  if (options.execSession) {
    await executeSession(options.execSession);
    return;
  }

  if (renderMarkdown || copyMarkdown) {
    if (!options.prompt) {
      throw new Error('Prompt is required when using --render-markdown or --copy-markdown.');
    }
    const bundle = await buildMarkdownBundle(
      { prompt: options.prompt, file: options.file, system: options.system },
      { cwd: process.cwd() },
    );
    const modelConfig = isKnownModel(resolvedModel) ? MODEL_CONFIGS[resolvedModel] : MODEL_CONFIGS['gpt-5.1'];
    const requestBody = buildRequestBody({
      modelConfig,
      systemPrompt: bundle.systemPrompt,
      userPrompt: bundle.promptWithFiles,
      searchEnabled: options.search !== false,
      background: false,
      storeResponse: false,
    });
    const estimatedTokens = estimateRequestTokens(requestBody, modelConfig);
    const warnThreshold = Math.min(196_000, modelConfig.inputLimit ?? 196_000);
    warnIfOversizeBundle(estimatedTokens, warnThreshold, console.log);
    if (renderMarkdown) {
      const output = renderPlain
        ? bundle.markdown
        : await formatRenderedMarkdown(bundle.markdown, { richTty: isTty });
      // Trim trailing newlines from the rendered bundle so we print exactly one blank before the summary line.
      console.log(output.replace(/\n+$/u, ''));
    }
    if (copyMarkdown) {
      const result = await copyToClipboard(bundle.markdown);
      if (result.success) {
        const filesPart = bundle.files.length > 0 ? `; ${bundle.files.length} files` : '';
        const summary = `Copied markdown to clipboard (~${formatCompactNumber(estimatedTokens)} tokens${filesPart}).`;
        console.log(chalk.green(summary));
      } else {
        const reason = result.error instanceof Error ? result.error.message : String(result.error ?? 'unknown error');
        console.log(
          chalk.dim(
            `Copy failed (${reason}); markdown not printed. Re-run with --render-markdown if you need the content.`,
          ),
        );
      }
    }
    return;
  }

  if (previewMode) {
    if (!options.prompt) {
      throw new Error('Prompt is required when using --dry-run/preview.');
    }
    if (userConfig.promptSuffix) {
      options.prompt = `${options.prompt.trim()}\n${userConfig.promptSuffix}`;
    }
    resolvedOptions.prompt = options.prompt;
    const runOptions = buildRunOptions(resolvedOptions, { preview: true, previewMode, baseUrl: resolvedBaseUrl });
    if (engine === 'browser') {
      await runBrowserPreview(
        {
          runOptions,
          cwd: process.cwd(),
          version: VERSION,
          previewMode,
          log: console.log,
        },
        {},
      );
      return;
    }
    // API dry-run/preview path
    if (previewMode === 'summary') {
      await runDryRunSummary(
        {
          engine,
          runOptions,
          cwd: process.cwd(),
          version: VERSION,
          log: console.log,
        },
        {},
      );
      return;
    }
    await runDryRunSummary(
      {
        engine,
        runOptions,
        cwd: process.cwd(),
        version: VERSION,
        log: console.log,
      },
      {},
    );
    return;
  }

  if (!options.prompt) {
    throw new Error('Prompt is required when starting a new session.');
  }

  if (userConfig.promptSuffix) {
    options.prompt = `${options.prompt.trim()}\n${userConfig.promptSuffix}`;
  }
  resolvedOptions.prompt = options.prompt;

  const duplicateBlocked = await shouldBlockDuplicatePrompt({
    prompt: resolvedOptions.prompt,
    force: options.force,
    sessionStore,
    log: console.log,
  });
  if (duplicateBlocked) {
    process.exitCode = 1;
    return;
  }

  if (options.file && options.file.length > 0) {
    const isBrowserMode = engine === 'browser' || userForcedBrowser;
    const filesToValidate = isBrowserMode ? options.file.filter((f: string) => !isMediaFile(f)) : options.file;
    if (filesToValidate.length > 0) {
      await readFiles(filesToValidate, { cwd: process.cwd() });
    }
  }

  const getSource = (key: keyof CliOptions) => program.getOptionValueSource?.(key as string) ?? undefined;
  applyBrowserDefaultsFromConfig(options, userConfig, getSource);

  const notifications = resolveNotificationSettings({
    cliNotify: options.notify,
    cliNotifySound: options.notifySound,
    env: process.env,
    config: userConfig.notify,
  });

  const sessionMode: SessionMode = engine === 'browser' ? 'browser' : 'api';
  const browserModelLabelOverride =
    sessionMode === 'browser' ? resolveBrowserModelLabel(cliModelArg, resolvedModel) : undefined;
  const browserConfig =
    sessionMode === 'browser'
      ? await buildBrowserConfig({
          ...options,
          model: resolvedModel,
          browserModelLabel: browserModelLabelOverride,
        })
      : undefined;

  let browserDeps: BrowserSessionRunnerDeps | undefined;
  if (browserConfig && remoteHost) {
    browserDeps = {
      executeBrowser: createRemoteBrowserExecutor({ host: remoteHost, token: remoteToken }),
    };
    console.log(chalk.dim(`Routing browser automation to remote host ${remoteHost}`));
  } else if (browserConfig && resolvedModel.startsWith('gemini')) {
    browserDeps = {
      executeBrowser: createGeminiWebExecutor({
        youtube: options.youtube,
        generateImage: options.generateImage,
        editImage: options.editImage,
        outputPath: options.output,
        aspectRatio: options.aspect,
        showThoughts: options.geminiShowThoughts,
      }),
    };
    console.log(chalk.dim('Using Gemini web client for browser automation'));
    if (browserConfig.modelStrategy && browserConfig.modelStrategy !== 'select') {
      console.log(chalk.dim('Browser model strategy is ignored for Gemini web runs.'));
    }
  }
  const remoteExecutionActive = Boolean(browserDeps);

  if (options.dryRun) {
    const baseRunOptions = buildRunOptions(resolvedOptions, {
      preview: false,
      previewMode: undefined,
      baseUrl: resolvedBaseUrl,
    });
    await runDryRunSummary(
      {
        engine,
        runOptions: baseRunOptions,
        cwd: process.cwd(),
        version: VERSION,
        log: console.log,
        browserConfig,
      },
      {},
    );
    return;
  }

  await sessionStore.ensureStorage();
  const baseRunOptions = buildRunOptions(resolvedOptions, {
    preview: false,
    previewMode: undefined,
    background: userConfig.background ?? resolvedOptions.background,
    baseUrl: resolvedBaseUrl,
  });
  enforceBrowserSearchFlag(baseRunOptions, sessionMode, console.log);
  if (sessionMode === 'browser' && baseRunOptions.search === false) {
    console.log(chalk.dim('Note: search is not available in browser engine; ignoring search=false.'));
    baseRunOptions.search = undefined;
  }
  const sessionMeta = await sessionStore.createSession(
    {
      ...baseRunOptions,
      mode: sessionMode,
      browserConfig,
    },
    process.cwd(),
    notifications,
  );
  const liveRunOptions: RunOracleOptions = {
    ...baseRunOptions,
    sessionId: sessionMeta.id,
    effectiveModelId,
  };
  const disableDetachEnv = process.env.ORACLE_NO_DETACH === '1';
  const detachAllowed = remoteExecutionActive
    ? false
    : shouldDetachSession({
        engine,
        model: resolvedModel,
        waitPreference,
        disableDetachEnv,
      });
  const detached = !detachAllowed
    ? false
    : await launchDetachedSession(sessionMeta.id).catch((error) => {
      const message = error instanceof Error ? error.message : String(error);
      console.log(chalk.yellow(`Unable to detach session runner (${message}). Running inline...`));
      return false;
    });

  if (!waitPreference) {
    if (!detached) {
      console.log(chalk.red('Unable to start in background; use --wait to run inline.'));
      process.exitCode = 1;
      return;
    }
    console.log(chalk.blue(`Session running in background. Reattach via: oracle session ${sessionMeta.id}`));
    console.log(
      chalk.dim('Pro runs can take up to 60 minutes (usually 10-15). Add --wait to stay attached.'),
    );
    return;
  }

  if (detached === false) {
    await runInteractiveSession(
      sessionMeta,
      liveRunOptions,
      sessionMode,
      browserConfig,
      false,
      notifications,
      userConfig,
      true,
      browserDeps,
    );
    return;
  }
  if (detached) {
    console.log(chalk.blue(`Reattach via: oracle session ${sessionMeta.id}`));
    await attachSession(sessionMeta.id, { suppressMetadata: true });
  }
}

async function runInteractiveSession(
  sessionMeta: SessionMetadata,
  runOptions: RunOracleOptions,
  mode: SessionMode,
  browserConfig?: BrowserSessionConfig,
  showReattachHint = true,
  notifications?: NotificationSettings,
  userConfig?: UserConfig,
  suppressSummary = false,
  browserDeps?: BrowserSessionRunnerDeps,
): Promise<void> {
  const { logLine, writeChunk, stream } = sessionStore.createLogWriter(sessionMeta.id);
  let headerAugmented = false;
  const combinedLog = (message = ''): void => {
    if (!headerAugmented && message.startsWith('oracle (')) {
      headerAugmented = true;
      if (showReattachHint) {
        console.log(`${message}\n${chalk.blue(`Reattach via: oracle session ${sessionMeta.id}`)}`);
      } else {
        console.log(message);
      }
      logLine(message);
      return;
    }
    console.log(message);
    logLine(message);
  };
  const combinedWrite = (chunk: string): boolean => {
    // runOracle handles stdout; keep this write hook for session logs only to avoid double-printing
    writeChunk(chunk);
    return true;
  };
  try {
    await performSessionRun({
      sessionMeta,
      runOptions,
      mode,
      browserConfig,
      cwd: process.cwd(),
      log: combinedLog,
      write: combinedWrite,
      version: VERSION,
      notifications:
        notifications ?? deriveNotificationSettingsFromMetadata(sessionMeta, process.env, userConfig?.notify),
      browserDeps,
    });
    const latest = await sessionStore.readSession(sessionMeta.id);
    if (!suppressSummary) {
      const summary = latest ? formatCompletionSummary(latest, { includeSlug: true }) : null;
      if (summary) {
        console.log('\n' + chalk.green.bold(summary));
        logLine(summary); // plain text in log, colored on stdout
      }
    }
  } catch (error) {
    throw error;
  } finally {
    stream.end();
  }
}

async function launchDetachedSession(sessionId: string): Promise<boolean> {
  return new Promise((resolve, reject) => {
    try {
      const args = ['--', CLI_ENTRYPOINT, '--exec-session', sessionId];
      const child = spawn(process.execPath, args, {
        detached: true,
        stdio: 'ignore',
        env: process.env,
      });
      child.once('error', reject);
      child.once('spawn', () => {
        child.unref();
        resolve(true);
      });
    } catch (error) {
      reject(error);
    }
  });
}

async function executeSession(sessionId: string) {
  const metadata = await sessionStore.readSession(sessionId);
  if (!metadata) {
    console.error(chalk.red(`No session found with ID ${sessionId}`));
    process.exitCode = 1;
    return;
  }
  const runOptions = buildRunOptionsFromMetadata(metadata);
  const sessionMode = getSessionMode(metadata);
  const browserConfig = getBrowserConfigFromMetadata(metadata);
  const { logLine, writeChunk, stream } = sessionStore.createLogWriter(sessionId);
  const userConfig = (await loadUserConfig()).config;
  const notifications = deriveNotificationSettingsFromMetadata(metadata, process.env, userConfig.notify);
  try {
    await performSessionRun({
      sessionMeta: metadata,
      runOptions,
      mode: sessionMode,
      browserConfig,
      cwd: metadata.cwd ?? process.cwd(),
      log: logLine,
      write: writeChunk,
      version: VERSION,
      notifications,
    });
  } catch {
    // Errors are already logged to the session log; keep quiet to mirror stored-session behavior.
  } finally {
    stream.end();
  }
}

function printDebugHelp(cliName: string): void {
  console.log(chalk.bold('Advanced Options'));
  printDebugOptionGroup([
    ['--search <on|off>', 'Enable or disable the server-side search tool (default on).'],
    ['--max-input <tokens>', 'Override the input token budget.'],
    ['--max-output <tokens>', 'Override the max output tokens (model default otherwise).'],
  ]);
  console.log('');
  console.log(chalk.bold('Browser Options'));
  printDebugOptionGroup([
    ['--chatgpt-url <url>', 'Override the ChatGPT web URL (workspace/folder targets).'],
    ['--browser-chrome-profile <name>', 'Reuse cookies from a specific Chrome profile.'],
    ['--browser-chrome-path <path>', 'Point to a custom Chrome/Chromium binary.'],
    ['--browser-cookie-path <path>', 'Use a specific Chrome/Chromium cookie store file.'],
    ['--browser-url <url>', 'Alias for --chatgpt-url.'],
    ['--browser-timeout <ms|s|m>', 'Cap total wait time for the assistant response.'],
    ['--browser-input-timeout <ms|s|m>', 'Cap how long we wait for the composer textarea.'],
    ['--browser-cookie-wait <ms|s|m>', 'Wait before retrying cookie sync when Chrome cookies are empty or locked.'],
    ['--browser-no-cookie-sync', 'Skip copying cookies from your main profile.'],
    ['--browser-manual-login', 'Skip cookie copy; reuse a persistent automation profile and log in manually.'],
    ['--browser-headless', 'Launch Chrome in headless mode.'],
    ['--browser-hide-window', 'Hide the Chrome window (macOS headful only).'],
    ['--browser-keep-browser', 'Leave Chrome running after completion.'],
  ]);
  console.log('');
  console.log(chalk.dim(`Tip: run \`${cliName} --help\` to see the primary option set.`));
}

function printDebugOptionGroup(entries: Array<[string, string]>): void {
  const flagWidth = Math.max(...entries.map(([flag]) => flag.length));
  entries.forEach(([flag, description]) => {
    const label = chalk.cyan(flag.padEnd(flagWidth + 2));
    console.log(`  ${label}${description}`);
  });
}

function resolveWaitFlag({
  waitFlag,
  noWaitFlag,
  model,
  engine,
}: {
  waitFlag?: boolean;
  noWaitFlag?: boolean;
  model: ModelName;
  engine: EngineMode;
}): boolean {
  if (waitFlag === true) return true;
  if (noWaitFlag === true) return false;
  return defaultWaitPreference(model, engine);
}

program.action(async function (this: Command) {
  const options = this.optsWithGlobals() as CliOptions;
  await runRootCommand(options);
});

async function main(): Promise<void> {
  const parsePromise = program.parseAsync(process.argv);
  const sigintPromise = once(process, 'SIGINT').then(() => 'sigint' as const);
  const result = await Promise.race([parsePromise.then(() => 'parsed' as const), sigintPromise]);
  if (result === 'sigint') {
    console.log(chalk.yellow('\nCancelled.'));
    process.exitCode = 130;
  }
}

void main().catch((error: unknown) => {
  if (error instanceof Error) {
    if (!isErrorLogged(error)) {
      console.error(chalk.red('✖'), error.message);
    }
  } else {
    console.error(chalk.red('✖'), error);
  }
  process.exitCode = 1;
});


--- bin/oracle-mcp.ts ---
#!/usr/bin/env node
import { startMcpServer } from '../src/mcp/server.js';

startMcpServer().catch((error) => {
  console.error('oracle-mcp exited with an error:', error);
  process.exitCode = 1;
});


--- scripts/agent-send.ts ---
#!/usr/bin/env bun
// @ts-nocheck

/**
 * Lightweight helper to send a one-off message to a tmux-based agent session.
 *
 * Usage:
 *   bun scripts/agent-send.ts --session claude-haiku -- "/model"
 *
 * Options:
 *   --session NAME             Target tmux session (or session:window.pane)
 *   --entry single|double|none How many Enter keys to send (default single)
 *   --escape                   Send ESC before typing (to interrupt/resume)
 *   --wait-ms N                Extra wait (ms) after typing before Enter
 */

import { spawnSync } from 'node:child_process';
import { sleepSync } from 'bun';

type EntryMode = 'single' | 'double' | 'none';

interface CliOptions {
  session: string;
  entry: EntryMode;
  escape: boolean;
  waitMs: number;
  message: string;
}

function usage(message?: string): never {
  if (message) {
    console.error(`Error: ${message}`);
  }
  console.error(`\
Usage: bun scripts/agent-send.ts --session <name[:window[.pane]]> [--entry single|double|none] [--escape] [--wait-ms N] -- "<message>"

Examples:
  bun scripts/agent-send.ts --session claude-haiku -- "/model"
  bun scripts/agent-send.ts --session ma-worker-1 --escape --entry double -- "Continue and focus on API routes"
`);
  process.exit(1);
}

function parseArgs(argv: string[]): CliOptions {
  let session: string | undefined;
  let entry: EntryMode = 'single';
  let shouldEscape = false;
  let waitMs = 400;
  const literalSeparator = argv.indexOf('--');
  const optionPart = literalSeparator === -1 ? argv : argv.slice(0, literalSeparator);
  const literalPart = literalSeparator === -1 ? [] : argv.slice(literalSeparator + 1);

  for (let i = 0; i < optionPart.length; i += 1) {
    const token = optionPart[i];
    if (!token.startsWith('--')) {
      usage(`Unexpected argument: ${token}`);
    }
    const key = token.slice(2);
    switch (key) {
      case 'session': {
        const value = optionPart[i + 1];
        if (!value) usage('--session requires a value');
        session = value;
        i += 1;
        break;
      }
      case 'entry': {
        const value = optionPart[i + 1];
        if (value !== 'single' && value !== 'double' && value !== 'none') {
          usage(`Unknown entry mode: ${value}`);
        }
        entry = value;
        i += 1;
        break;
      }
      case 'escape': {
        shouldEscape = true;
        break;
      }
      case 'wait-ms': {
        const value = optionPart[i + 1];
        if (!value || Number.isNaN(Number.parseInt(value, 10))) {
          usage('--wait-ms requires an integer value');
        }
        waitMs = Number.parseInt(value, 10);
        i += 1;
        break;
      }
      default:
        usage(`Unknown option: --${key}`);
    }
  }

  const message = literalPart.join(' ').trim();
  if (!session) usage('Missing --session');
  if (!message) usage('Missing message (provide text after -- separator)');

  return { session, entry, escape: shouldEscape, waitMs, message };
}

function runTmux(args: string[], allowFailure = false): string {
  const result = spawnSync('tmux', args, { encoding: 'utf8' });
  if (result.error) {
    if (allowFailure) return '';
    throw result.error;
  }
  if (result.status !== 0) {
    if (allowFailure) return result.stderr?.trim() ?? '';
    throw new Error(`tmux ${args.join(' ')} failed: ${result.stderr?.trim()}`);
  }
  return result.stdout?.trimEnd() ?? '';
}

function ensureSession(target: string): void {
  const session = target.split(':')[0] ?? target;
  const result = spawnSync('tmux', ['has-session', '-t', session]);
  if (result.status !== 0) {
    usage(`tmux session '${session}' not found. Start it first (e.g., tmux new-session -s ${session} ...)`);
  }
}

function sendMessage(options: CliOptions): void {
  ensureSession(options.session);

  if (options.escape) {
    runTmux(['send-keys', '-t', options.session, 'Escape'], true);
    sleepSync(200);
  }

  // Clear existing prompt
  runTmux(['send-keys', '-t', options.session, 'Escape'], true);
  sleepSync(120);
  runTmux(['send-keys', '-t', options.session, 'C-u'], true);
  sleepSync(120);

  // Type the message
  runTmux(['send-keys', '-t', options.session, '-l', options.message], true);
  sleepSync(Math.max(120, options.waitMs));

  // Send Enter(s)
  const pressEnter = () => runTmux(['send-keys', '-t', options.session, 'C-m'], true);
  switch (options.entry) {
    case 'single':
      pressEnter();
      break;
    case 'double':
      pressEnter();
      sleepSync(200);
      pressEnter();
      break;
    case 'none':
      break;
    default:
      usage(`Unsupported entry mode: ${options.entry}`);
  }

  sleepSync(600);
  const tail = runTmux(['capture-pane', '-pt', options.session, '-S', '-6'], true);
  console.log(tail);
}

try {
  const options = parseArgs(process.argv.slice(2));
  sendMessage(options);
} catch (error) {
  usage(error instanceof Error ? error.message : String(error));
}


--- scripts/browser-tools.ts ---
#!/usr/bin/env ts-node

/**
 * Minimal Chrome DevTools helpers inspired by Mario Zechner's
 * "What if you don't need MCP?" article.
 *
 * Keeps everything in one TypeScript CLI so agents (or humans) can drive Chrome
 * directly via the DevTools protocol without pulling in a large MCP server.
 */
import { Command } from 'commander';
import { execSync, spawn } from 'node:child_process';
import http from 'node:http';
import os from 'node:os';
import path from 'node:path';
import readline from 'node:readline/promises';
import { stdin as input, stdout as output } from 'node:process';
import puppeteer from 'puppeteer-core';

/** Utility type so TypeScript knows the async function constructor */
type AsyncFunctionCtor = new (...args: string[]) => (...fnArgs: unknown[]) => Promise<unknown>;

const DEFAULT_PORT = 9222;
const DEFAULT_PROFILE_DIR = path.join(os.homedir(), '.cache', 'scraping');
const DEFAULT_CHROME_BIN = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';

function browserURL(port: number): string {
  return `http://localhost:${port}`;
}

async function connectBrowser(port: number) {
  return puppeteer.connect({ browserURL: browserURL(port), defaultViewport: null });
}

async function getActivePage(port: number) {
  const browser = await connectBrowser(port);
  const pages = await browser.pages();
  const page = pages.at(-1);
  if (!page) {
    await browser.disconnect();
    throw new Error('No active tab found');
  }
  return { browser, page };
}

const program = new Command();
program
  .name('browser-tools')
  .description('Lightweight Chrome DevTools helpers (no MCP required).')
  .configureHelp({ sortSubcommands: true })
  .showSuggestionAfterError();

program
  .command('start')
  .description('Launch Chrome with remote debugging enabled.')
  .option('-p, --port <number>', 'Remote debugging port (default: 9222)', (value) => Number.parseInt(value, 10), DEFAULT_PORT)
  .option('--profile', 'Copy your default Chrome profile before launch.', false)
  .option('--profile-dir <path>', 'Directory for the temporary Chrome profile.', DEFAULT_PROFILE_DIR)
  .option('--chrome-path <path>', 'Path to the Chrome binary.', DEFAULT_CHROME_BIN)
  .action(async (options) => {
    const { port, profile, profileDir, chromePath } = options as {
      port: number;
      profile: boolean;
      profileDir: string;
      chromePath: string;
    };

    try {
      execSync("killall 'Google Chrome'", { stdio: 'ignore' });
    } catch {
      // ignore missing processes
    }
    await new Promise((resolve) => setTimeout(resolve, 1000));
    execSync(`mkdir -p "${profileDir}"`);
    if (profile) {
      const source = `${path.join(os.homedir(), 'Library', 'Application Support', 'Google', 'Chrome')}/`;
      execSync(`rsync -a --delete "${source}" "${profileDir}/"`, { stdio: 'ignore' });
    }

    spawn(chromePath, [`--remote-debugging-port=${port}`, `--user-data-dir=${profileDir}`, '--no-first-run', '--disable-popup-blocking'], {
      detached: true,
      stdio: 'ignore',
    }).unref();

    let connected = false;
    for (let attempt = 0; attempt < 30; attempt++) {
      try {
        const browser = await connectBrowser(port);
        await browser.disconnect();
        connected = true;
        break;
      } catch {
        await new Promise((resolve) => setTimeout(resolve, 500));
      }
    }

    if (!connected) {
      console.error(`✗ Failed to start Chrome on port ${port}`);
      process.exit(1);
    }
    console.log(`✓ Chrome listening on http://localhost:${port}${profile ? ' (profile copied)' : ''}`);
  });

program
  .command('nav <url>')
  .description('Navigate the current tab or open a new tab.')
  .option('--port <number>', 'Debugger port (default: 9222)', (value) => Number.parseInt(value, 10), DEFAULT_PORT)
  .option('--new', 'Open in a new tab.', false)
  .action(async (url: string, options) => {
    const port = options.port as number;
    const browser = await connectBrowser(port);
    try {
      if (options.new) {
        const page = await browser.newPage();
        await page.goto(url, { waitUntil: 'domcontentloaded' });
        console.log('✓ Opened in new tab:', url);
      } else {
        const pages = await browser.pages();
        const page = pages.at(-1);
        if (!page) {
          throw new Error('No active tab found');
        }
        await page.goto(url, { waitUntil: 'domcontentloaded' });
        console.log('✓ Navigated current tab to:', url);
      }
    } finally {
      await browser.disconnect();
    }
  });

program
  .command('eval <code...>')
  .description('Evaluate JavaScript in the active page context.')
  .option('--port <number>', 'Debugger port (default: 9222)', (value) => Number.parseInt(value, 10), DEFAULT_PORT)
  .action(async (code: string[], options) => {
    const snippet = code.join(' ');
    const port = options.port as number;
    const { browser, page } = await getActivePage(port);
    try {
      const result = await page.evaluate((body) => {
        const ASYNC_FN = Object.getPrototypeOf(async () => {}).constructor as AsyncFunctionCtor;
        return new ASYNC_FN(`return (${body})`)();
      }, snippet);

      if (Array.isArray(result)) {
        result.forEach((entry, index) => {
          if (index > 0) {
            console.log('');
          }
          Object.entries(entry).forEach(([key, value]) => {
            console.log(`${key}: ${value}`);
          });
        });
      } else if (typeof result === 'object' && result !== null) {
        Object.entries(result).forEach(([key, value]) => {
          console.log(`${key}: ${value}`);
        });
      } else {
        console.log(result);
      }
    } finally {
      await browser.disconnect();
    }
  });

program
  .command('screenshot')
  .description('Capture the current viewport and print the temp PNG path.')
  .option('--port <number>', 'Debugger port (default: 9222)', (value) => Number.parseInt(value, 10), DEFAULT_PORT)
  .action(async (options) => {
    const port = options.port as number;
    const { browser, page } = await getActivePage(port);
    try {
      const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
      const filePath = path.join(
        os.tmpdir(),
        `screenshot-${timestamp}.png`,
      ) as `${string}.png`;
      await page.screenshot({ path: filePath });
      console.log(filePath);
    } finally {
      await browser.disconnect();
    }
  });

program
  .command('pick <message...>')
  .description('Interactive DOM picker that prints metadata for clicked elements.')
  .option('--port <number>', 'Debugger port (default: 9222)', (value) => Number.parseInt(value, 10), DEFAULT_PORT)
  .action(async (messageParts: string[], options) => {
    const message = messageParts.join(' ');
    const port = options.port as number;
    const { browser, page } = await getActivePage(port);
    try {
      await page.evaluate(() => {
        const scope = globalThis as typeof globalThis & {
          pickOverlayInjected?: boolean;
          pick?: (prompt: string) => Promise<unknown>;
        };
        if (scope.pickOverlayInjected) {
          return;
        }
        scope.pickOverlayInjected = true;
        scope.pick = async (prompt: string) =>
          new Promise((resolve) => {
            const selections: unknown[] = [];
            const selectedElements = new Set<HTMLElement>();

            const overlay = document.createElement('div');
            overlay.style.cssText =
              'position:fixed;top:0;left:0;width:100%;height:100%;z-index:2147483647;pointer-events:none';

            const highlight = document.createElement('div');
            highlight.style.cssText =
              'position:absolute;border:2px solid #3b82f6;background:rgba(59,130,246,0.1);transition:all 0.05s ease';
            overlay.appendChild(highlight);

            const banner = document.createElement('div');
            banner.style.cssText =
              'position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background:#1f2937;color:#fff;padding:12px 24px;border-radius:8px;font:14px system-ui;box-shadow:0 4px 12px rgba(0,0,0,0.3);pointer-events:auto;z-index:2147483647';

            const updateBanner = () => {
              banner.textContent = `${prompt} (${selections.length} selected, Cmd/Ctrl+click to add, Enter to finish, ESC to cancel)`;
            };

            const cleanup = () => {
              document.removeEventListener('mousemove', onMove, true);
              document.removeEventListener('click', onClick, true);
              document.removeEventListener('keydown', onKey, true);
              overlay.remove();
              banner.remove();
              selectedElements.forEach((el) => {
                el.style.outline = '';
              });
            };

            const serialize = (el: HTMLElement) => {
              const parents: string[] = [];
              let current = el.parentElement;
              while (current && current !== document.body) {
                const id = current.id ? `#${current.id}` : '';
                const cls = current.className ? `.${current.className.trim().split(/\s+/).join('.')}` : '';
                parents.push(`${current.tagName.toLowerCase()}${id}${cls}`);
                current = current.parentElement;
              }
              return {
                tag: el.tagName.toLowerCase(),
                id: el.id || null,
                class: el.className || null,
                text: el.textContent?.trim()?.slice(0, 200) || null,
                html: el.outerHTML.slice(0, 500),
                parents: parents.join(' > '),
              };
            };

            const onMove = (event: MouseEvent) => {
              const node = document.elementFromPoint(event.clientX, event.clientY) as HTMLElement | null;
              if (!node || overlay.contains(node) || banner.contains(node)) return;
              const rect = node.getBoundingClientRect();
              highlight.style.cssText = `position:absolute;border:2px solid #3b82f6;background:rgba(59,130,246,0.1);top:${rect.top}px;left:${rect.left}px;width:${rect.width}px;height:${rect.height}px`;
            };
            const onClick = (event: MouseEvent) => {
              if (banner.contains(event.target as Node)) return;
              event.preventDefault();
              event.stopPropagation();
              const node = document.elementFromPoint(event.clientX, event.clientY) as HTMLElement | null;
              if (!node || overlay.contains(node) || banner.contains(node)) return;

              if (event.metaKey || event.ctrlKey) {
                if (!selectedElements.has(node)) {
                  selectedElements.add(node);
                  node.style.outline = '3px solid #10b981';
                  selections.push(serialize(node));
                  updateBanner();
                }
              } else {
                cleanup();
                const info = serialize(node);
                resolve(selections.length > 0 ? selections : info);
              }
            };

            const onKey = (event: KeyboardEvent) => {
              if (event.key === 'Escape') {
                cleanup();
                resolve(null);
              } else if (event.key === 'Enter' && selections.length > 0) {
                cleanup();
                resolve(selections);
              }
            };

            document.addEventListener('mousemove', onMove, true);
            document.addEventListener('click', onClick, true);
            document.addEventListener('keydown', onKey, true);

            document.body.append(overlay, banner);
            updateBanner();
          });
      });

      const result = await page.evaluate((msg) => {
        const pickFn = (window as Window & { pick?: (message: string) => Promise<unknown> }).pick;
        if (!pickFn) {
          return null;
        }
        return pickFn(msg);
      }, message);

      if (Array.isArray(result)) {
        result.forEach((entry, index) => {
          if (index > 0) {
            console.log('');
          }
          Object.entries(entry).forEach(([key, value]) => {
            console.log(`${key}: ${value}`);
          });
        });
      } else if (result && typeof result === 'object') {
        Object.entries(result).forEach(([key, value]) => {
          console.log(`${key}: ${value}`);
        });
      } else {
        console.log(result);
      }
    } finally {
      await browser.disconnect();
    }
  });

program
  .command('cookies')
  .description('Dump cookies from the active tab as JSON.')
  .option('--port <number>', 'Debugger port (default: 9222)', (value) => Number.parseInt(value, 10), DEFAULT_PORT)
  .action(async (options) => {
    const port = options.port as number;
    const { browser, page } = await getActivePage(port);
    try {
      const cookies = await page.cookies();
      console.log(JSON.stringify(cookies, null, 2));
    } finally {
      await browser.disconnect();
    }
  });

program
  .command('inspect')
  .description('List Chrome processes launched with --remote-debugging-port and show their open tabs.')
  .option('--ports <list>', 'Comma-separated list of ports to include.', parseNumberListArg)
  .option('--pids <list>', 'Comma-separated list of PIDs to include.', parseNumberListArg)
  .option('--json', 'Emit machine-readable JSON output.', false)
  .action(async (options) => {
    const ports = (options.ports as number[] | undefined)?.filter((entry) => Number.isFinite(entry) && entry > 0);
    const pids = (options.pids as number[] | undefined)?.filter((entry) => Number.isFinite(entry) && entry > 0);
    const sessions = await describeChromeSessions({
      ports,
      pids,
      includeAll: !ports?.length && !pids?.length,
    });
    if (options.json) {
      console.log(JSON.stringify(sessions, null, 2));
      return;
    }
    if (sessions.length === 0) {
      console.log('No Chrome instances with DevTools ports found.');
      return;
    }
    sessions.forEach((session, index) => {
      if (index > 0) {
        console.log('');
      }
      const header = [`Chrome PID ${session.pid}`, `(port ${session.port})`];
      if (session.version?.Browser) {
        header.push(`- ${session.version.Browser}`);
      }
      console.log(header.join(' '));
      if (session.tabs.length === 0) {
        console.log('  (no tabs reported)');
        return;
      }
      session.tabs.forEach((tab, idx) => {
        const title = tab.title || '(untitled)';
        const url = tab.url || '(no url)';
        console.log(`  Tab ${idx + 1}: ${title}`);
        console.log(`           ${url}`);
      });
    });
  });

program
  .command('kill')
  .description('Terminate Chrome instances that have DevTools ports open.')
  .option('--ports <list>', 'Comma-separated list of ports to target.', parseNumberListArg)
  .option('--pids <list>', 'Comma-separated list of PIDs to target.', parseNumberListArg)
  .option('--all', 'Kill every matching Chrome instance.', false)
  .option('--force', 'Skip the confirmation prompt.', false)
  .action(async (options) => {
    const ports = (options.ports as number[] | undefined)?.filter((entry) => Number.isFinite(entry) && entry > 0);
    const pids = (options.pids as number[] | undefined)?.filter((entry) => Number.isFinite(entry) && entry > 0);
    const killAll = Boolean(options.all);
    if (!killAll && (!ports?.length && !pids?.length)) {
      console.error('Specify --all, --ports <list>, or --pids <list> to select targets.');
      process.exit(1);
    }
    const sessions = await describeChromeSessions({ ports, pids, includeAll: killAll });
    if (sessions.length === 0) {
      console.log('No matching Chrome instances found.');
      return;
    }
    if (!options.force) {
      console.log('About to terminate the following Chrome sessions:');
      sessions.forEach((session) => {
        console.log(`  PID ${session.pid} (port ${session.port})`);
      });
      const rl = readline.createInterface({ input, output });
      const answer = (await rl.question('Proceed? [y/N] ')).trim().toLowerCase();
      rl.close();
      if (answer !== 'y' && answer !== 'yes') {
        console.log('Aborted.');
        return;
      }
    }
    const failures: { pid: number; error: string }[] = [];
    sessions.forEach((session) => {
      try {
        process.kill(session.pid);
        console.log(`✓ Killed Chrome PID ${session.pid} (port ${session.port})`);
      } catch (error) {
        const message = error instanceof Error ? error.message : String(error);
        console.error(`✗ Failed to kill PID ${session.pid}: ${message}`);
        failures.push({ pid: session.pid, error: message });
      }
    });
    if (failures.length > 0) {
      process.exitCode = 1;
    }
  });

interface ChromeProcessInfo {
  pid: number;
  port: number;
  command: string;
}

interface ChromeTabInfo {
  id?: string;
  title?: string;
  url?: string;
  type?: string;
}

interface ChromeSessionDescription extends ChromeProcessInfo {
  version?: Record<string, string>;
  tabs: ChromeTabInfo[];
}

function parseNumberListArg(value: string): number[] {
  return parseNumberList(value) ?? [];
}

function parseNumberList(inputValue: string | undefined): number[] | undefined {
  if (!inputValue) {
    return undefined;
  }
  const parsed = inputValue
    .split(',')
    .map((entry) => Number.parseInt(entry.trim(), 10))
    .filter((value) => Number.isFinite(value));
  return parsed.length > 0 ? parsed : undefined;
}

async function describeChromeSessions(options: {
  ports?: number[];
  pids?: number[];
  includeAll?: boolean;
}): Promise<ChromeSessionDescription[]> {
  const { ports, pids, includeAll } = options;
  const processes = await listDevtoolsChromes();
  const portSet = new Set(ports ?? []);
  const pidSet = new Set(pids ?? []);
  const candidates = processes.filter((proc) => {
    if (includeAll) {
      return true;
    }
    if (portSet.size > 0 && portSet.has(proc.port)) {
      return true;
    }
    if (pidSet.size > 0 && pidSet.has(proc.pid)) {
      return true;
    }
    return false;
  });
  const results: ChromeSessionDescription[] = [];
  for (const proc of candidates) {
    const [version, tabs] = await Promise.all([
      fetchJson(`http://localhost:${proc.port}/json/version`).catch(() => undefined),
      fetchJson(`http://localhost:${proc.port}/json/list`).catch(() => []),
    ]);
    const filteredTabs = Array.isArray(tabs)
      ? (tabs as ChromeTabInfo[]).filter((tab) => {
          const type = tab.type?.toLowerCase() ?? '';
          if (type && type !== 'page' && type !== 'app') {
            if (!tab.url || tab.url.startsWith('devtools://') || tab.url.startsWith('chrome-extension://')) {
              return false;
            }
          }
          if (!tab.url || tab.url.trim().length === 0) {
            return false;
          }
          return true;
        })
      : [];
    results.push({
      ...proc,
      version: (version as Record<string, string>) ?? undefined,
      tabs: filteredTabs,
    });
  }
  return results;
}

async function listDevtoolsChromes(): Promise<ChromeProcessInfo[]> {
  if (process.platform !== 'darwin' && process.platform !== 'linux') {
    console.warn('Chrome inspection is only supported on macOS and Linux for now.');
    return [];
  }
  let output = '';
  try {
    output = execSync('ps -ax -o pid=,command=', { encoding: 'utf8' });
  } catch (error) {
    const message = error instanceof Error ? error.message : String(error);
    throw new Error(`Failed to enumerate processes: ${message}`);
  }
  const processes: ChromeProcessInfo[] = [];
  output
    .split('\n')
    .map((line) => line.trim())
    .filter(Boolean)
    .forEach((line) => {
      const match = line.match(/^(\d+)\s+(.+)$/);
      if (!match) {
        return;
      }
      const pid = Number.parseInt(match[1], 10);
      const command = match[2];
      if (!Number.isFinite(pid) || pid <= 0) {
        return;
      }
      if (!/chrome/i.test(command) || !/--remote-debugging-port/.test(command)) {
        return;
      }
      const portMatch = command.match(/--remote-debugging-port(?:=|\s+)(\d+)/);
      if (!portMatch) {
        return;
      }
      const port = Number.parseInt(portMatch[1], 10);
      if (!Number.isFinite(port)) {
        return;
      }
      processes.push({ pid, port, command });
    });
  return processes;
}

function fetchJson(url: string, timeoutMs = 2000): Promise<unknown> {
  return new Promise((resolve, reject) => {
    const request = http.get(url, { timeout: timeoutMs }, (response) => {
      const chunks: Buffer[] = [];
      response.on('data', (chunk) => chunks.push(chunk));
      response.on('end', () => {
        const body = Buffer.concat(chunks).toString('utf8');
        if ((response.statusCode ?? 500) >= 400) {
          reject(new Error(`HTTP ${response.statusCode} for ${url}`));
          return;
        }
        try {
          resolve(JSON.parse(body));
        } catch {
          resolve(undefined);
        }
      });
    });
    request.on('timeout', () => {
      request.destroy(new Error(`Request to ${url} timed out`));
    });
    request.on('error', (error) => {
      reject(error);
    });
  });
}

program.parseAsync(process.argv);


--- scripts/build-vendor.js ---
import { cp, mkdir, stat } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

async function main() {
  const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
  const source = path.join(root, 'vendor', 'oracle-notifier');
  const targetDir = path.join(root, 'dist', 'vendor');
  const target = path.join(targetDir, 'oracle-notifier');

  try {
    await stat(source);
  } catch (error) {
    const message = error instanceof Error ? error.message : String(error);
    console.warn(`Skipping vendor copy: ${message}`);
    return;
  }

  await mkdir(targetDir, { recursive: true });
  await cp(source, target, { recursive: true });
  console.log(`Copied ${source} -> ${target}`);
}

main().catch((error) => {
  console.warn(`Skipping vendor copy: ${error instanceof Error ? error.message : String(error)}`);
});


--- scripts/check.ts ---
#!/usr/bin/env bun
import process from 'node:process';

type BunBuildConfig = {
  entrypoints: string[];
  outdir?: string;
  target?: string;
  minify?: boolean;
  write?: boolean;
  sourcemap?: 'inline' | 'external' | 'none';
};

const buildConfig: BunBuildConfig = {
  entrypoints: ['./bin/oracle-cli.js'],
  outdir: './.bun-check',
  target: 'bun',
  minify: false,
  write: false,
};

const result = await Bun.build(buildConfig);

if (!result.success) {
  console.error('Build failed while checking syntax:');
  for (const log of result.logs) {
    console.error(log.message);
    if (log.position) {
      console.error(`\tat ${log.position.file}:${log.position.line}:${log.position.column}`);
    }
  }
  process.exit(1);
}

console.log('Syntax OK');


--- scripts/git-policy.ts ---
import { resolve } from 'node:path';

export type GitInvocation = {
  index: number;
  argv: string[];
};

export type GitCommandInfo = {
  name: string;
  index: number;
};

export type GitExecutionContext = {
  invocation: GitInvocation | null;
  command: GitCommandInfo | null;
  subcommand: string | null;
  workDir: string;
};

export type GitPolicyEvaluation = {
  requiresCommitHelper: boolean;
  requiresExplicitConsent: boolean;
  isDestructive: boolean;
};

const COMMIT_HELPER_SUBCOMMANDS = new Set(['add', 'commit']);
const GUARDED_SUBCOMMANDS = new Set(['push', 'pull', 'merge', 'rebase', 'cherry-pick']);
const DESTRUCTIVE_SUBCOMMANDS = new Set([
  'reset',
  'checkout',
  'clean',
  'restore',
  'switch',
  'stash',
  'branch',
  'filter-branch',
  'fast-import',
]);

export function extractGitInvocation(commandArgs: string[]): GitInvocation | null {
  for (const [index, token] of commandArgs.entries()) {
    if (token === 'git' || token.endsWith('/git')) {
      return { index, argv: commandArgs.slice(index) };
    }
  }
  return null;
}

export function findGitSubcommand(commandArgs: string[]): GitCommandInfo | null {
  if (commandArgs.length <= 1) {
    return null;
  }

  const optionsWithValue = new Set(['-C', '--git-dir', '--work-tree', '-c']);
  let index = 1;

  while (index < commandArgs.length) {
    const token = commandArgs[index];
    if (token === undefined) {
      break;
    }
    if (token === '--') {
      const next = commandArgs[index + 1];
      return next ? { name: next, index: index + 1 } : null;
    }
    if (!token.startsWith('-')) {
      return { name: token, index };
    }
    if (token.includes('=')) {
      index += 1;
      continue;
    }
    if (optionsWithValue.has(token)) {
      index += 2;
      continue;
    }
    index += 1;
  }
  return null;
}

export function determineGitWorkdir(baseDir: string, gitArgs: string[], command: GitCommandInfo | null): string {
  let workDir = baseDir;
  const limit = command ? command.index : gitArgs.length;
  let index = 1;

  while (index < limit) {
    const token = gitArgs[index];
    if (token === undefined) {
      break;
    }
    if (token === '-C') {
      const next = gitArgs[index + 1];
      if (next) {
        workDir = resolve(workDir, next);
      }
      index += 2;
      continue;
    }
    if (token.startsWith('-C')) {
      const pathSegment = token.slice(2);
      if (pathSegment.length > 0) {
        workDir = resolve(workDir, pathSegment);
      }
    }
    index += 1;
  }

  return workDir;
}

export function analyzeGitExecution(commandArgs: string[], workspaceDir: string): GitExecutionContext {
  const invocation = extractGitInvocation(commandArgs);
  const command = invocation ? findGitSubcommand(invocation.argv) : null;
  const workDir = invocation ? determineGitWorkdir(workspaceDir, invocation.argv, command) : workspaceDir;

  return {
    invocation,
    command,
    subcommand: command?.name ?? null,
    workDir,
  };
}

export function requiresCommitHelper(subcommand: string | null): boolean {
  if (!subcommand) {
    return false;
  }
  return COMMIT_HELPER_SUBCOMMANDS.has(subcommand);
}

export function requiresExplicitGitConsent(subcommand: string | null): boolean {
  if (!subcommand) {
    return false;
  }
  return GUARDED_SUBCOMMANDS.has(subcommand);
}

export function isDestructiveGitSubcommand(command: GitCommandInfo | null, gitArgv: string[]): boolean {
  if (!command) {
    return false;
  }

  const subcommand = command.name;
  if (DESTRUCTIVE_SUBCOMMANDS.has(subcommand)) {
    return true;
  }

  if (subcommand === 'bisect') {
    const action = gitArgv[command.index + 1] ?? '';
    return action === 'reset';
  }

  return false;
}

export function evaluateGitPolicies(context: GitExecutionContext): GitPolicyEvaluation {
  const invocationArgv = context.invocation?.argv;
  const normalizedArgv = Array.isArray(invocationArgv) ? invocationArgv : [];
  return {
    requiresCommitHelper: requiresCommitHelper(context.subcommand),
    requiresExplicitConsent: requiresExplicitGitConsent(context.subcommand),
    isDestructive: isDestructiveGitSubcommand(context.command, normalizedArgv),
  };
}


--- scripts/run-cli.ts ---
#!/usr/bin/env node
import { spawn } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const rawArgs = process.argv.slice(2);
const args: string[] = rawArgs[0] === '--' ? rawArgs.slice(1) : rawArgs;

const here = path.dirname(fileURLToPath(import.meta.url));
const cliEntry = path.join(here, '../bin/oracle-cli.js');

const child = spawn(process.execPath, ['--', cliEntry, ...args], {
  stdio: 'inherit',
});
child.on('exit', (code) => {
  process.exit(code ?? 0);
});


--- scripts/runner.ts ---
#!/usr/bin/env bun
/**
 * Sweetistics runner wrapper: enforces timeouts, git policy, and trash-safe deletes before dispatching any repo command.
 * When you tweak its behavior, add a short note to AGENTS.md via `./scripts/committer "docs: update AGENTS for runner" "AGENTS.md"` so other agents know the new expectations.
 */

import { type ChildProcess, spawn } from 'node:child_process';
import { cpSync, existsSync, renameSync, rmSync } from 'node:fs';
import { constants as osConstants } from 'node:os';
import { basename, isAbsolute, join, normalize, resolve } from 'node:path';
import process from 'node:process';

import {
  analyzeGitExecution,
  evaluateGitPolicies,
  type GitCommandInfo,
  type GitExecutionContext,
  type GitInvocation,
} from './git-policy';

const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
const EXTENDED_TIMEOUT_MS = 20 * 60 * 1000;
const LONG_TIMEOUT_MS = 25 * 60 * 1000; // Build + full-suite commands (Next.js build, test:all) routinely spike past 20 minutes—give them explicit headroom before tmux escalation.
const LINT_TIMEOUT_MS = 30 * 60 * 1000;
const LONG_RUN_REPORT_THRESHOLD_MS = 60 * 1000;
const ENABLE_DEBUG_LOGS = process.env.RUNNER_DEBUG === '1';
const MAX_SLEEP_SECONDS = 30;

const WRAPPER_COMMANDS = new Set([
  'sudo',
  '/usr/bin/sudo',
  'env',
  '/usr/bin/env',
  'command',
  '/bin/command',
  'nohup',
  '/usr/bin/nohup',
]);

type SummaryStyle = 'compact' | 'minimal' | 'verbose';
const SUMMARY_STYLE = resolveSummaryStyle(process.env.RUNNER_SUMMARY_STYLE);

// biome-ignore format: keep each keyword on its own line for grep-friendly diffs.
const LONG_SCRIPT_KEYWORDS = [
  'build',
  'test:all',
  'test:browser',
  'test:e2e',
  'test:e2e:headed',
  'vitest.browser',
  'vitest.browser.config.ts',
];
const EXTENDED_SCRIPT_KEYWORDS = ['lint', 'test', 'playwright', 'check', 'docker'];
const SINGLE_TEST_SCRIPTS = new Set(['test:file']);
const SINGLE_TEST_FLAGS = new Set(['--run', '--filter']);
const TEST_BINARIES = new Set(['vitest', 'playwright', 'jest']);
const LINT_BINARIES = new Set(['eslint', 'biome', 'oxlint', 'knip']);

type RunnerExecutionContext = {
  commandArgs: string[];
  workspaceDir: string;
  timeoutMs: number;
};

type CommandInterceptionResult = { handled: true } | { handled: false; gitContext: GitExecutionContext };

type GitRmPlan = {
  paths: string[];
  stagingOptions: string[];
  allowMissing: boolean;
  shouldIntercept: boolean;
};

type MoveResult = {
  missing: string[];
  errors: string[];
};

let cachedTrashCliCommand: string | null | undefined;

(async () => {
  const commandArgs = parseArgs(process.argv.slice(2));

  if (commandArgs.length === 0) {
    printUsage('Missing command to execute.');
    process.exit(1);
  }

  const workspaceDir = process.cwd();
  const timeoutMs = determineEffectiveTimeoutMs(commandArgs);
  const context: RunnerExecutionContext = {
    commandArgs,
    workspaceDir,
    timeoutMs,
  };

  const interception = await resolveCommandInterception(context);
  if (interception.handled) {
    return;
  }

  enforceGitPolicies(interception.gitContext);

  await runCommand(context);
})().catch((error) => {
  console.error('[runner] Unexpected failure:', error instanceof Error ? error.message : String(error));
  process.exit(1);
});

// Parses the runner CLI args and rejects unsupported flags early.
function parseArgs(argv: string[]): string[] {
  const commandArgs: string[] = [];
  let parsingOptions = true;

  for (const token of argv) {
    if (!parsingOptions) {
      commandArgs.push(token);
      continue;
    }

    if (token === '--') {
      parsingOptions = false;
      continue;
    }

    if (token === '--help' || token === '-h') {
      printUsage();
      process.exit(0);
    }

    if (token === '--timeout' || token.startsWith('--timeout=')) {
      console.error('[runner] --timeout is no longer supported; rely on the automatic timeouts.');
      process.exit(1);
    }

    parsingOptions = false;
    commandArgs.push(token);
  }

  return commandArgs;
}

// Computes the timeout tier for the provided command tokens.
function determineEffectiveTimeoutMs(commandArgs: string[]): number {
  const strippedTokens = stripWrappersAndAssignments(commandArgs);
  if (isTestRunnerSuiteInvocation(strippedTokens, 'integration')) {
    return EXTENDED_TIMEOUT_MS;
  }
  if (referencesIntegrationSpec(strippedTokens)) {
    return EXTENDED_TIMEOUT_MS;
  }
  if (shouldUseLintTimeout(commandArgs)) {
    return LINT_TIMEOUT_MS;
  }
  if (shouldUseLongTimeout(commandArgs)) {
    return LONG_TIMEOUT_MS;
  }
  if (shouldExtendTimeout(commandArgs) && !isSingleTestInvocation(commandArgs)) {
    return EXTENDED_TIMEOUT_MS;
  }
  return DEFAULT_TIMEOUT_MS;
}

// Determines whether the command matches any keyword requiring extra time.
function shouldExtendTimeout(commandArgs: string[]): boolean {
  const tokens = stripWrappersAndAssignments(commandArgs);
  if (tokens.length === 0) {
    return false;
  }

  const [first, ...rest] = tokens;
  if (!first) {
    return false;
  }

  if (first === 'pnpm') {
    return shouldExtendViaPnpm(rest);
  }
  if (first === 'bun') {
    return shouldExtendViaBun(rest);
  }

  if (shouldExtendForScript(first) || TEST_BINARIES.has(first.toLowerCase())) {
    return true;
  }

  return rest.some((token) => shouldExtendForScript(token) || TEST_BINARIES.has(token.toLowerCase()));
}

function shouldExtendViaPnpm(rest: string[]): boolean {
  if (rest.length === 0) {
    return false;
  }
  const subcommand = rest[0];
  if (!subcommand) {
    return false;
  }
  if (subcommand === 'run') {
    const script = rest[1];
    return typeof script === 'string' && shouldExtendForScript(script);
  }
  if (subcommand === 'exec') {
    const execTarget = rest[1];
    if (execTarget && (shouldExtendForScript(execTarget) || TEST_BINARIES.has(execTarget.toLowerCase()))) {
      return true;
    }
    return rest.slice(1).some((token) => shouldExtendForScript(token) || TEST_BINARIES.has(token.toLowerCase()));
  }
  return shouldExtendForScript(subcommand);
}

function shouldExtendViaBun(rest: string[]): boolean {
  if (rest.length === 0) {
    return false;
  }
  const subcommand = rest[0];
  if (!subcommand) {
    return false;
  }
  if (subcommand === 'run') {
    const script = rest[1];
    return typeof script === 'string' && shouldExtendForScript(script);
  }
  if (subcommand === 'test') {
    return true;
  }
  if (subcommand === 'x' || subcommand === 'bunx') {
    const execTarget = rest[1];
    if (execTarget && TEST_BINARIES.has(execTarget.toLowerCase())) {
      return true;
    }
  }
  return shouldExtendForScript(subcommand);
}

// Checks script names for long-running markers (lint/test/build/etc.).
function shouldExtendForScript(script: string): boolean {
  if (SINGLE_TEST_SCRIPTS.has(script)) {
    return false;
  }
  return matchesScriptKeyword(script, EXTENDED_SCRIPT_KEYWORDS);
}

// Gives lint invocations the dedicated timeout bucket.
function shouldUseLintTimeout(commandArgs: string[]): boolean {
  const tokens = stripWrappersAndAssignments(commandArgs);
  if (tokens.length === 0) {
    return false;
  }

  const [first, ...rest] = tokens;
  if (!first) {
    return false;
  }

  if (first === 'pnpm') {
    return shouldUseLintTimeoutViaPnpm(rest);
  }
  if (first === 'bun') {
    return shouldUseLintTimeoutViaBun(rest);
  }

  return LINT_BINARIES.has(first.toLowerCase());
}

function shouldUseLintTimeoutViaPnpm(rest: string[]): boolean {
  if (rest.length === 0) {
    return false;
  }
  const subcommand = rest[0];
  if (!subcommand) {
    return false;
  }
  if (subcommand === 'run') {
    const script = rest[1];
    return typeof script === 'string' && script.startsWith('lint');
  }
  if (subcommand === 'exec') {
    const execTarget = rest[1];
    if (execTarget && LINT_BINARIES.has(execTarget.toLowerCase())) {
      return true;
    }
    return rest.slice(1).some((token) => LINT_BINARIES.has(token.toLowerCase()));
  }
  return LINT_BINARIES.has(subcommand.toLowerCase());
}

function shouldUseLintTimeoutViaBun(rest: string[]): boolean {
  if (rest.length === 0) {
    return false;
  }
  const subcommand = rest[0];
  if (!subcommand) {
    return false;
  }
  if (subcommand === 'run') {
    const script = rest[1];
    return typeof script === 'string' && script.startsWith('lint');
  }
  if (subcommand === 'x' || subcommand === 'bunx') {
    return rest.slice(1).some((token) => LINT_BINARIES.has(token.toLowerCase()));
  }
  return LINT_BINARIES.has(subcommand.toLowerCase());
}

// Detects when a user is running a single spec so we can keep the shorter timeout.
function isSingleTestInvocation(commandArgs: string[]): boolean {
  const tokens = stripWrappersAndAssignments(commandArgs);
  if (tokens.length === 0) {
    return false;
  }

  if (tokens.some((token) => SINGLE_TEST_FLAGS.has(token))) {
    return true;
  }

  const [first, ...rest] = tokens;
  if (!first) {
    return false;
  }

  if (first === 'pnpm') {
    return isSingleTestViaPnpm(rest);
  }
  if (first === 'bun') {
    return isSingleTestViaBun(rest);
  }
  if (first === 'vitest') {
    return rest.some((token) => SINGLE_TEST_FLAGS.has(token));
  }

  return SINGLE_TEST_SCRIPTS.has(first);
}

function isSingleTestViaPnpm(rest: string[]): boolean {
  if (rest.length === 0) {
    return false;
  }
  const subcommand = rest[0];
  if (!subcommand) {
    return false;
  }
  if (subcommand === 'run') {
    const script = rest[1];
    return typeof script === 'string' && SINGLE_TEST_SCRIPTS.has(script);
  }
  if (subcommand === 'exec') {
    return rest.slice(1).some((token) => SINGLE_TEST_FLAGS.has(token));
  }
  return SINGLE_TEST_SCRIPTS.has(subcommand);
}

function isSingleTestViaBun(rest: string[]): boolean {
  if (rest.length === 0) {
    return false;
  }
  const subcommand = rest[0];
  if (!subcommand) {
    return false;
  }
  if (subcommand === 'run') {
    const script = rest[1];
    return typeof script === 'string' && SINGLE_TEST_SCRIPTS.has(script);
  }
  if (subcommand === 'test') {
    return true;
  }
  if (subcommand === 'x' || subcommand === 'bunx') {
    return rest.slice(1).some((token) => SINGLE_TEST_FLAGS.has(token));
  }
  return false;
}

// Normalizes potential file paths/flags to aid comparison across shells.
function normalizeForPathComparison(token: string): string {
  return token.replaceAll('\\', '/');
}

// Heuristically checks if a CLI token references an integration spec.
function tokenReferencesIntegrationTest(token: string): boolean {
  const normalized = normalizeForPathComparison(token);
  if (normalized.includes('tests/integration/')) {
    return true;
  }
  if (normalized.startsWith('--run=') || normalized.startsWith('--include=')) {
    const value = normalized.split('=', 2)[1] ?? '';
    return value.includes('tests/integration/');
  }
  return false;
}

// Scans the entire command for integration spec references.
function referencesIntegrationSpec(tokens: string[]): boolean {
  for (let index = 0; index < tokens.length; index += 1) {
    const token = tokens[index];
    if (!token) {
      continue;
    }
    if (token === '--run' || token === '--include') {
      const next = tokens[index + 1];
      if (next && tokenReferencesIntegrationTest(next)) {
        return true;
      }
    }
    if (tokenReferencesIntegrationTest(token)) {
      return true;
    }
  }
  return false;
}

// Helper that matches a script token against a keyword allowlist.
function matchesScriptKeyword(script: string, keywords: readonly string[]): boolean {
  const lowered = script.toLowerCase();
  return keywords.some((keyword) => lowered === keyword || lowered.startsWith(`${keyword}:`));
}

// Removes wrapper binaries/env assignments so heuristics see the real command.
function stripWrappersAndAssignments(args: string[]): string[] {
  const tokens = [...args];

  while (tokens.length > 0) {
    const candidate = tokens[0];
    if (!candidate) {
      break;
    }
    if (!isEnvAssignment(candidate)) {
      break;
    }
    tokens.shift();
  }

  while (tokens.length > 0) {
    const wrapper = tokens[0];
    if (!wrapper) {
      break;
    }
    if (!WRAPPER_COMMANDS.has(wrapper)) {
      break;
    }
    tokens.shift();
    while (tokens.length > 0) {
      const assignment = tokens[0];
      if (!assignment) {
        break;
      }
      if (!isEnvAssignment(assignment)) {
        break;
      }
      tokens.shift();
    }
  }

  return tokens;
}

// Checks whether a token is an inline environment variable assignment.
function isEnvAssignment(token: string): boolean {
  return /^[A-Za-z_][A-Za-z0-9_]*=.*/.test(token);
}

// Detects `pnpm test:<suite>` style calls regardless of wrappers.
function isTestRunnerSuiteInvocation(tokens: string[], suite: string): boolean {
  if (tokens.length === 0) {
    return false;
  }

  const normalizedSuite = suite.toLowerCase();
  for (let index = 0; index < tokens.length; index += 1) {
    const token = tokens[index];
    if (!token) {
      continue;
    }
    const normalizedToken = token.replace(/^[./\\]+/, '');
    if (normalizedToken === 'scripts/test-runner.ts' || normalizedToken.endsWith('/scripts/test-runner.ts')) {
      const suiteToken = tokens[index + 1]?.toLowerCase();
      if (suiteToken === normalizedSuite) {
        return true;
      }
    }
  }

  return false;
}

// Grants the longest timeout to explicitly tagged long-running scripts.
function shouldUseLongTimeout(commandArgs: string[]): boolean {
  const tokens = stripWrappersAndAssignments(commandArgs);
  if (tokens.length === 0) {
    return false;
  }

  const first = tokens[0];
  if (!first) {
    return false;
  }
  const rest = tokens.slice(1);
  const matches = (token: string): boolean => matchesScriptKeyword(token, LONG_SCRIPT_KEYWORDS);

  if (first === 'pnpm') {
    if (rest.length === 0) {
      return false;
    }
    const subcommand = rest[0];
    if (!subcommand) {
      return false;
    }
    if (subcommand === 'run') {
      const script = rest[1];
      if (script && matches(script)) {
        return true;
      }
    } else if (matches(subcommand)) {
      return true;
    }
    for (const token of rest.slice(1)) {
      if (matches(token)) {
        return true;
      }
    }
    return false;
  }

  if (matches(first)) {
    return true;
  }

  for (const token of rest) {
    if (matches(token)) {
      return true;
    }
  }

  return false;
}

// Kicks off the requested command with logging, timeouts, and monitoring.
async function runCommand(context: RunnerExecutionContext): Promise<void> {
  const { command, args, env } = buildExecutionParams(context.commandArgs, context.workspaceDir);
  const commandLabel = formatDisplayCommand(context.commandArgs);

  const startTime = Date.now();

  const child = spawn(command, args, {
    cwd: context.workspaceDir,
    env,
    stdio: ['inherit', 'pipe', 'pipe'],
  });

  if (isRunnerTmuxSession()) {
    const childPidInfo = typeof child.pid === 'number' ? ` (pid ${child.pid})` : '';
    console.error(`[runner] Watching ${commandLabel}${childPidInfo}. Wait for the closing sentinel before moving on.`);
  }

  const removeSignalHandlers = registerSignalForwarding(child);

  if (child.stdout) {
    child.stdout.on('data', (chunk: Buffer) => {
      process.stdout.write(chunk);
    });
  }

  if (child.stderr) {
    child.stderr.on('data', (chunk: Buffer) => {
      process.stderr.write(chunk);
    });
  }

  let killTimer: NodeJS.Timeout | null = null;
  try {
    const result = await new Promise<{ exitCode: number; timedOut: boolean }>((resolve, reject) => {
      let timedOut = false;
      const timeout = setTimeout(() => {
        timedOut = true;
        if (ENABLE_DEBUG_LOGS) {
          console.error(`[runner] Command exceeded ${formatDuration(context.timeoutMs)}; sending SIGTERM.`);
        }
        if (!child.killed) {
          child.kill('SIGTERM');
          killTimer = setTimeout(() => {
            if (!child.killed) {
              child.kill('SIGKILL');
            }
          }, 5_000);
        }
      }, context.timeoutMs);

      child.once('error', (error) => {
        clearTimeout(timeout);
        if (killTimer) {
          clearTimeout(killTimer);
        }
        removeSignalHandlers();
        reject(error);
      });

      child.once('exit', (code, signal) => {
        clearTimeout(timeout);
        if (killTimer) {
          clearTimeout(killTimer);
        }
        removeSignalHandlers();
        resolve({ exitCode: code ?? exitCodeFromSignal(signal), timedOut });
      });
    });
    const { exitCode, timedOut } = result;

    const elapsedMs = Date.now() - startTime;
    if (timedOut) {
      console.error(
        `[runner] Command terminated after ${formatDuration(context.timeoutMs)}. Re-run inside tmux for long-lived work.`
      );
      console.error(
        formatCompletionSummary({ exitCode, elapsedMs, timedOut: true, commandLabel })
      );
      process.exit(124);
    }

    if (elapsedMs >= LONG_RUN_REPORT_THRESHOLD_MS) {
      console.error(
        `[runner] Completed in ${formatDuration(elapsedMs)}. For long-running tasks, prefer tmux directly.`
      );
    }

    console.error(formatCompletionSummary({ exitCode, elapsedMs, commandLabel }));
    process.exit(exitCode);
  } catch (error) {
    console.error('[runner] Failed to launch command:', error instanceof Error ? error.message : String(error));
    process.exit(1);
    return;
  }
}

async function runCommandWithoutTimeout(context: RunnerExecutionContext): Promise<void> {
  const { command, args, env } = buildExecutionParams(context.commandArgs, context.workspaceDir);
  const commandLabel = formatDisplayCommand(context.commandArgs);
  const startTime = Date.now();

  const child = spawn(command, args, {
    cwd: context.workspaceDir,
    env,
    stdio: 'inherit',
  });

  const removeSignalHandlers = registerSignalForwarding(child);

  try {
    const exitCode = await new Promise<number>((resolve, reject) => {
      child.once('error', (error) => {
        removeSignalHandlers();
        reject(error);
      });
      child.once('exit', (code, signal) => {
        removeSignalHandlers();
        resolve(code ?? exitCodeFromSignal(signal));
      });
    });
    const elapsedMs = Date.now() - startTime;
    console.error(formatCompletionSummary({ exitCode, elapsedMs, commandLabel }));
    process.exit(exitCode);
  } catch (error) {
    console.error('[runner] Failed to launch command:', error instanceof Error ? error.message : String(error));
    process.exit(1);
  }
}

// Prepares the executable, args, and sanitized env for the child process.
function buildExecutionParams(
  commandArgs: string[],
  workspaceDir: string,
): { command: string; args: string[]; env: NodeJS.ProcessEnv } {
  const env = { ...process.env };
  injectWorkspaceBinDirs(env, workspaceDir);
  const args: string[] = [];
  let commandStarted = false;

  for (const token of commandArgs) {
    if (!commandStarted && isEnvAssignment(token)) {
      const [key, ...rest] = token.split('=');
      if (key) {
        env[key] = rest.join('=');
      }
      continue;
    }
    commandStarted = true;
    args.push(token);
  }

  if (args.length === 0 || !args[0]) {
    printUsage('Missing command to execute.');
    process.exit(1);
  }

  const [command, ...restArgs] = args;
  return { command, args: restArgs, env };
}

function injectWorkspaceBinDirs(env: NodeJS.ProcessEnv, workspaceDir: string): void {
  if (ENABLE_DEBUG_LOGS) {
    console.error(`[runner] Checking workspace bin dirs under ${workspaceDir}`);
  }
  const binCandidates = [
    join(workspaceDir, 'node_modules', '.bin'),
    join(workspaceDir, 'bin'),
  ];
  const existingPath = env.PATH ?? process.env.PATH ?? '';
  const existingSegments = existingPath
    .split(':')
    .map((segment) => segment.trim())
    .filter((segment) => segment.length > 0);

  const additions: string[] = [];
  for (const dir of binCandidates) {
    if (!dir || !existsSync(dir)) {
      continue;
    }
    if (existingSegments.includes(dir) || additions.includes(dir)) {
      continue;
    }
    additions.push(dir);
  }

  if (additions.length === 0) {
    return;
  }

  if (ENABLE_DEBUG_LOGS) {
    console.error(`[runner] Prepending workspace PATH entries: ${additions.join(', ')}`);
  }

  const merged = [...additions, ...existingSegments];
  env.PATH = merged.join(':');
}

// Forwards termination signals to the child and returns an unregister hook.
function registerSignalForwarding(child: ChildProcess): () => void {
  const signals: NodeJS.Signals[] = ['SIGINT', 'SIGTERM'];
  const handlers = new Map<NodeJS.Signals, () => void>();

  for (const signal of signals) {
    const handler = () => {
      if (!child.killed) {
        child.kill(signal);
      }
    };
    handlers.set(signal, handler);
    process.on(signal, handler);
  }

  return () => {
    for (const [signal, handler] of handlers) {
      process.off(signal, handler);
    }
  };
}

// Maps a terminating signal to the exit code conventions bash expects.
function exitCodeFromSignal(signal: NodeJS.Signals | null): number {
  if (!signal) {
    return 0;
  }
  const code = (osConstants.signals as Record<string, number | undefined>)[signal];
  if (typeof code === 'number') {
    return 128 + code;
  }
  return 1;
}

// Gives policy interceptors a chance to fully handle a command before exec.
async function resolveCommandInterception(context: RunnerExecutionContext): Promise<CommandInterceptionResult> {
  const interceptors: Array<(ctx: RunnerExecutionContext) => Promise<boolean>> = [
    maybeHandleTmuxInvocation,
    maybeHandleFindInvocation,
    maybeHandleRmInvocation,
    maybeHandleSleepInvocation,
  ];

  for (const interceptor of interceptors) {
    if (await interceptor(context)) {
      return { handled: true };
    }
  }

  const gitContext = analyzeGitExecution(context.commandArgs, context.workspaceDir);

  if (await maybeHandleGitRm(gitContext)) {
    return { handled: true };
  }

  return { handled: false, gitContext };
}

// Runs the shared git policy analyzers before dispatching the command.
function enforceGitPolicies(gitContext: GitExecutionContext) {
  const evaluation = evaluateGitPolicies(gitContext);
  const hasConsentOverride = process.env.RUNNER_THE_USER_GAVE_ME_CONSENT === '1';

  if (gitContext.subcommand === 'rebase' && !hasConsentOverride) {
    console.error(
      'git rebase requires the user to explicitly type "rebase" in chat. Once they do, rerun with RUNNER_THE_USER_GAVE_ME_CONSENT=1 in the same command (e.g. RUNNER_THE_USER_GAVE_ME_CONSENT=1 ./runner git rebase --continue).'
    );
    process.exit(1);
  }

  if (evaluation.requiresCommitHelper) {
    console.error(
      'Direct git add/commit is disabled. Use ./scripts/committer "chore(runner): describe change" "scripts/runner.ts" instead—see AGENTS.md and ./scripts/committer for details. The helper auto-stashes unrelated files before committing.'
    );
    process.exit(1);
  }

  if (evaluation.requiresExplicitConsent || evaluation.isDestructive) {
    if (hasConsentOverride) {
      if (ENABLE_DEBUG_LOGS) {
        const reason = evaluation.isDestructive ? 'destructive git command' : 'guarded git command';
        console.error(`[runner] Proceeding with ${reason} because RUNNER_THE_USER_GAVE_ME_CONSENT=1.`);
      }
    } else {
      if (evaluation.isDestructive) {
        console.error(
          `git ${gitContext.subcommand ?? ''} can overwrite or discard work. Confirm with the user first, then re-run with RUNNER_THE_USER_GAVE_ME_CONSENT=1 if they approve.`
        );
      } else {
        console.error(
          `Using git ${gitContext.subcommand ?? ''} requires consent. Set RUNNER_THE_USER_GAVE_ME_CONSENT=1 after verifying with the user, or ask them explicitly before proceeding.`
        );
      }
      process.exit(1);
    }
  }
}

// Handles guarded `find` invocations that may delete files outright.
async function maybeHandleFindInvocation(context: RunnerExecutionContext): Promise<boolean> {
  const findInvocation = extractFindInvocation(context.commandArgs);
  if (!findInvocation) {
    return false;
  }

  const findPlan = await buildFindDeletePlan(findInvocation.argv, context.workspaceDir);
  if (!findPlan) {
    return false;
  }

  const moveResult = await movePathsToTrash(findPlan.paths, context.workspaceDir, { allowMissing: false });
  if (moveResult.missing.length > 0) {
    for (const path of moveResult.missing) {
      console.error(`find: ${path}: No such file or directory`);
    }
    process.exit(1);
  }
  if (moveResult.errors.length > 0) {
    for (const error of moveResult.errors) {
      console.error(error);
    }
    process.exit(1);
  }
  process.exit(0);
  return true;
}

// Intercepts plain `rm` commands to route them through trash safeguards.
async function maybeHandleRmInvocation(context: RunnerExecutionContext): Promise<boolean> {
  const rmInvocation = extractRmInvocation(context.commandArgs);
  if (!rmInvocation) {
    return false;
  }

  const rmPlan = parseRmArguments(rmInvocation.argv);
  if (!rmPlan?.shouldIntercept) {
    return false;
  }

  try {
    const moveResult = await movePathsToTrash(rmPlan.targets, context.workspaceDir, { allowMissing: rmPlan.force });
    reportMissingForRm(moveResult.missing, rmPlan.force);
    if (moveResult.errors.length > 0) {
      for (const error of moveResult.errors) {
        console.error(error);
      }
      process.exit(1);
    }
    process.exit(0);
  } catch (error) {
    console.error(formatTrashError(error));
    process.exit(1);
  }
  return true;
}

// Applies git-specific rm protections before the command executes.
async function maybeHandleGitRm(gitContext: GitExecutionContext): Promise<boolean> {
  if (gitContext.command?.name !== 'rm' || !gitContext.invocation) {
    return false;
  }

  const gitRmPlan = parseGitRmArguments(gitContext.invocation.argv, gitContext.command);
  if (!gitRmPlan?.shouldIntercept) {
    return false;
  }

  try {
    const moveResult = await movePathsToTrash(gitRmPlan.paths, gitContext.workDir, {
      allowMissing: gitRmPlan.allowMissing,
    });
    if (!gitRmPlan.allowMissing && moveResult.missing.length > 0) {
      for (const path of moveResult.missing) {
        console.error(`git rm: ${path}: No such file or directory`);
      }
      process.exit(1);
    }
    if (moveResult.errors.length > 0) {
      for (const error of moveResult.errors) {
        console.error(error);
      }
      process.exit(1);
    }
    await stageGitRm(gitContext.workDir, gitRmPlan);
    process.exit(0);
  } catch (error) {
    console.error(formatTrashError(error));
    process.exit(1);
  }
  return true;
}

// Blocks `sleep` calls longer than the AGENTS.md ceiling so scripts cannot stall the runner.
async function maybeHandleSleepInvocation(context: RunnerExecutionContext): Promise<boolean> {
  const tokens = stripWrappersAndAssignments(context.commandArgs);
  if (tokens.length === 0) {
    return false;
  }
  const [first, ...rest] = tokens;
  if (!first || !isSleepBinary(first) || rest.length === 0) {
    return false;
  }

  const commandIndex = context.commandArgs.length - tokens.length;
  if (commandIndex < 0) {
    return false;
  }

  const adjustedArgs = [...context.commandArgs];
  const adjustments: string[] = [];

  for (let offset = 0; offset < rest.length; offset += 1) {
    const token = rest[offset];
    const durationSeconds = parseSleepDurationSeconds(token);
    if (durationSeconds == null || durationSeconds <= MAX_SLEEP_SECONDS) {
      continue;
    }
    adjustments.push(`${token}→${formatSleepDuration(MAX_SLEEP_SECONDS)}`);
    adjustedArgs[commandIndex + 1 + offset] = formatSleepArgument(MAX_SLEEP_SECONDS);
  }

  if (adjustments.length === 0) {
    return false;
  }

  console.error(
    `[runner] sleep arguments exceed ${MAX_SLEEP_SECONDS}s; clamping (${adjustments.join(', ')}).`
  );
  context.commandArgs = adjustedArgs;
  return false;
}

async function maybeHandleTmuxInvocation(context: RunnerExecutionContext): Promise<boolean> {
  const tokens = stripWrappersAndAssignments(context.commandArgs);
  if (tokens.length === 0) {
    return false;
  }
  const candidate = tokens[0];
  if (!candidate) {
    return false;
  }
  if (basename(candidate) !== 'tmux') {
    return false;
  }
  console.error('[runner] Detected tmux invocation; executing command without runner timeout guardrails.');
  await runCommandWithoutTimeout(context);
  return true;
}

function parseSleepDurationSeconds(token: string): number | null {
  const match = /^(\d+(?:\.\d+)?)([smhdSMHD]?)$/.exec(token);
  if (!match) {
    return null;
  }
  const value = Number(match[1]);
  if (!Number.isFinite(value)) {
    return null;
  }
  const unit = match[2]?.toLowerCase() ?? '';
  const multiplier = unit === 'm' ? 60 : unit === 'h' ? 60 * 60 : unit === 'd' ? 60 * 60 * 24 : 1;
  return value * multiplier;
}

function formatSleepArgument(seconds: number): string {
  return Number.isInteger(seconds) ? `${seconds}` : seconds.toString();
}

function formatSleepDuration(seconds: number): string {
  if (Number.isInteger(seconds)) {
    return `${seconds}s`;
  }
  return `${seconds.toFixed(2)}s`;
}

function isSleepBinary(token: string): boolean {
  return token === 'sleep' || token.endsWith('/sleep');
}

// Detects `git find` invocations that need policy enforcement.
function extractFindInvocation(commandArgs: string[]): GitInvocation | null {
  for (const [index, token] of commandArgs.entries()) {
    if (token === 'find' || token.endsWith('/find')) {
      return { index, argv: commandArgs.slice(index) };
    }
  }
  return null;
}

// Detects `git rm` variants so we can intercept destructive operations.
function extractRmInvocation(commandArgs: string[]): GitInvocation | null {
  if (commandArgs.length === 0) {
    return null;
  }

  const wrappers = new Set([
    'sudo',
    '/usr/bin/sudo',
    'env',
    '/usr/bin/env',
    'command',
    '/bin/command',
    'nohup',
    '/usr/bin/nohup',
  ]);

  let index = 0;
  while (index < commandArgs.length) {
    const token = commandArgs[index];
    if (!token) {
      break;
    }
    if (token.includes('=') && !token.startsWith('-')) {
      index += 1;
      continue;
    }
    if (wrappers.has(token)) {
      index += 1;
      continue;
    }
    break;
  }

  const commandToken = commandArgs[index];
  if (!commandToken) {
    return null;
  }

  const isRmCommand =
    commandToken === 'rm' ||
    commandToken.endsWith('/rm') ||
    commandToken === 'rm.exe' ||
    commandToken.endsWith('\\rm.exe');

  if (!isRmCommand) {
    return null;
  }

  return { index, argv: commandArgs.slice(index) };
}

// Expands guarded find expressions into an explicit delete plan for review.
async function buildFindDeletePlan(findArgs: string[], workspaceDir: string): Promise<{ paths: string[] } | null> {
  if (!findArgs.some((token) => token === '-delete')) {
    return null;
  }

  if (findArgs.some((token) => token === '-exec' || token === '-execdir' || token === '-ok' || token === '-okdir')) {
    console.error(
      'Runner cannot safely translate find invocations that combine -delete with -exec/-ok. Run the command manually after reviewing the paths.'
    );
    process.exit(1);
  }

  const printableArgs: string[] = [];
  for (const token of findArgs) {
    if (token === '-delete') {
      continue;
    }
    printableArgs.push(token);
  }
  printableArgs.push('-print0');

  const proc = Bun.spawn(printableArgs, {
    cwd: workspaceDir,
    stdout: 'pipe',
    stderr: 'pipe',
  });

  const [exitCode, stdoutBuf, stderrBuf] = await Promise.all([
    proc.exited,
    readProcessStream(proc.stdout),
    readProcessStream(proc.stderr),
  ]);

  if (exitCode !== 0) {
    const stderrText = stderrBuf.trim();
    const stdoutText = stdoutBuf.trim();
    if (stderrText.length > 0) {
      console.error(stderrText);
    } else if (stdoutText.length > 0) {
      console.error(stdoutText);
    }
    process.exit(exitCode);
  }

  const matches = stdoutBuf.split('\0').filter((entry: string) => entry.length > 0);
  if (matches.length === 0) {
    return { paths: [] };
  }

  const uniquePaths = new Map<string, string>();
  const workspaceCanonical = normalize(workspaceDir);

  for (const match of matches) {
    const absolute = isAbsolute(match) ? match : resolve(workspaceDir, match);
    const canonical = normalize(absolute);
    if (canonical === workspaceCanonical) {
      console.error('Refusing to trash the current workspace via find -delete. Narrow your find predicate.');
      process.exit(1);
    }
    if (!uniquePaths.has(canonical)) {
      uniquePaths.set(canonical, match);
    }
  }

  return { paths: Array.from(uniquePaths.values()) };
}

// Parses rm flags/targets to decide whether the runner should intervene.
function parseRmArguments(argv: string[]): { targets: string[]; force: boolean; shouldIntercept: boolean } | null {
  if (argv.length <= 1) {
    return null;
  }
  const targets: string[] = [];
  let force = false;
  let treatAsTarget = false;

  let index = 1;
  while (index < argv.length) {
    const token = argv[index];
    if (token === undefined) {
      break;
    }
    if (!treatAsTarget && token === '--') {
      treatAsTarget = true;
      index += 1;
      continue;
    }
    if (!treatAsTarget && token.startsWith('-') && token.length > 1) {
      if (token.includes('f')) {
        force = true;
      }
      if (token.includes('i') || token === '--interactive') {
        return null;
      }
      if (token === '--help' || token === '--version') {
        return null;
      }
      index += 1;
      continue;
    }
    targets.push(token);
    index += 1;
  }

  const firstTarget = targets[0];
  if (firstTarget === undefined) {
    return null;
  }

  return { targets, force, shouldIntercept: true };
}

// Generates a safe plan for git rm invocations, honoring guarded paths.
function parseGitRmArguments(argv: string[], command: GitCommandInfo): GitRmPlan | null {
  const stagingOptions: string[] = [];
  const paths: string[] = [];
  const optionsExpectingValue = new Set(['--pathspec-from-file']);
  let allowMissing = false;
  let treatAsPath = false;

  let index = command.index + 1;
  while (index < argv.length) {
    const token = argv[index];
    if (token === undefined) {
      break;
    }
    if (!treatAsPath && token === '--') {
      treatAsPath = true;
      index += 1;
      continue;
    }
    if (!treatAsPath && token.startsWith('-') && token.length > 1) {
      if (token === '--cached' || token === '--dry-run' || token === '-n') {
        return null;
      }
      if (token === '--ignore-unmatch' || token === '--force' || token === '-f') {
        allowMissing = true;
        stagingOptions.push(token);
        index += 1;
        continue;
      }
      if (optionsExpectingValue.has(token)) {
        const value = argv[index + 1];
        if (value) {
          stagingOptions.push(token, value);
          index += 2;
        } else {
          index += 1;
        }
        continue;
      }
      if (!token.startsWith('--')) {
        const flags = token.slice(1).split('');
        const retainedFlags: string[] = [];
        for (const flag of flags) {
          if (flag === 'n') {
            return null;
          }
          if (flag === 'f') {
            allowMissing = true;
            continue;
          }
          retainedFlags.push(flag);
        }
        if (retainedFlags.length > 0) {
          stagingOptions.push(`-${retainedFlags.join('')}`);
        }
        index += 1;
        continue;
      }
      stagingOptions.push(token);
      index += 1;
      continue;
    }
    if (token.length > 0) {
      paths.push(token);
    }
    index += 1;
  }

  if (paths.length === 0) {
    return null;
  }
  return {
    paths,
    stagingOptions,
    allowMissing,
    shouldIntercept: true,
  };
}

// Emits actionable messaging when git rm targets are already gone.
function reportMissingForRm(missing: string[], forced: boolean) {
  if (missing.length === 0 || forced) {
    return;
  }
  for (const path of missing) {
    console.error(`rm: ${path}: No such file or directory`);
  }
  process.exit(1);
}

// Attempts to move the provided paths into trash instead of deleting in place.
async function movePathsToTrash(
  paths: string[],
  baseDir: string,
  options: { allowMissing: boolean }
): Promise<MoveResult> {
  const missing: string[] = [];
  const existing: { raw: string; absolute: string }[] = [];

  for (const rawPath of paths) {
    const absolute = resolvePath(baseDir, rawPath);
    if (!existsSync(absolute)) {
      if (!options.allowMissing) {
        missing.push(rawPath);
      }
      continue;
    }
    existing.push({ raw: rawPath, absolute });
  }

  if (existing.length === 0) {
    return { missing, errors: [] };
  }

  const trashCliCommand = await findTrashCliCommand();
  if (trashCliCommand) {
    try {
      const cliArgs = [trashCliCommand, ...existing.map((item) => item.absolute)];
      const proc = Bun.spawn(cliArgs, {
        stdout: 'ignore',
        stderr: 'pipe',
      });
      const [exitCode, stderrText] = await Promise.all([proc.exited, readProcessStream(proc.stderr)]);
      if (exitCode === 0) {
        return { missing, errors: [] };
      }
      if (ENABLE_DEBUG_LOGS && stderrText.trim().length > 0) {
        console.error(`[runner] trash-cli error (${trashCliCommand}): ${stderrText.trim()}`);
      }
    } catch (error) {
      if (ENABLE_DEBUG_LOGS) {
        console.error(`[runner] trash-cli invocation failed: ${formatTrashError(error)}`);
      }
    }
  }

  const trashDir = getTrashDirectory();
  if (!trashDir) {
    return {
      missing,
      errors: ['Unable to locate macOS Trash directory (HOME/.Trash).'],
    };
  }

  const errors: string[] = [];

  for (const item of existing) {
    try {
      const target = buildTrashTarget(trashDir, item.absolute);
      try {
        renameSync(item.absolute, target);
      } catch (error) {
        if (isCrossDeviceError(error)) {
          cpSync(item.absolute, target, { recursive: true });
          rmSync(item.absolute, { recursive: true, force: true });
        } else {
          throw error;
        }
      }
    } catch (error) {
      errors.push(`Failed to move ${item.raw} to Trash: ${formatTrashError(error)}`);
    }
  }

  return { missing, errors };
}

// Resolves a potentially relative path against the workspace root.
function resolvePath(baseDir: string, input: string): string {
  if (input.startsWith('/')) {
    return input;
  }
  return resolve(baseDir, input);
}

// Returns the trash CLI directory if available so deletes can be safe.
function getTrashDirectory(): string | null {
  const home = process.env.HOME;
  if (!home) {
    return null;
  }
  const trash = join(home, '.Trash');
  if (!existsSync(trash)) {
    return null;
  }
  return trash;
}

// Builds the destination path inside the trash directory for a file.
function buildTrashTarget(trashDir: string, absolutePath: string): string {
  const baseName = basename(absolutePath);
  const timestamp = Date.now();
  let attempt = 0;
  let candidate = join(trashDir, baseName);
  while (existsSync(candidate)) {
    candidate = join(trashDir, `${baseName}-${timestamp}${attempt > 0 ? `-${attempt}` : ''}`);
    attempt += 1;
  }
  return candidate;
}

// Determines whether a rename failed because the devices differ.
function isCrossDeviceError(error: unknown): boolean {
  return error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'EXDEV';
}

// Normalizes trash/rename errors into a readable string.
function formatTrashError(error: unknown): string {
  if (error instanceof Error) {
    return error.message;
  }
  return String(error);
}

// Replays a git rm plan via spawn so we can surface errors consistently.
async function stageGitRm(workDir: string, plan: GitRmPlan) {
  if (plan.paths.length === 0) {
    return;
  }
  const args = ['git', 'rm', '--cached', '--quiet', ...plan.stagingOptions, '--', ...plan.paths];
  const proc = Bun.spawn(args, {
    cwd: workDir,
    stdout: 'inherit',
    stderr: 'inherit',
  });
  const exitCode = await proc.exited;
  if (exitCode !== 0) {
    throw new Error(`git rm --cached exited with status ${exitCode}.`);
  }
}

// Locates a usable trash CLI binary, caching the lookup per runner process.
async function findTrashCliCommand(): Promise<string | null> {
  if (cachedTrashCliCommand !== undefined) {
    return cachedTrashCliCommand;
  }

  const candidateNames = ['trash-put', 'trash'];
  const searchDirs = new Set<string>();

  if (process.env.PATH) {
    for (const segment of process.env.PATH.split(':')) {
      if (segment && segment.length > 0) {
        searchDirs.add(segment);
      }
    }
  }

  const homebrewPrefix = process.env.HOMEBREW_PREFIX ?? '/opt/homebrew';
  searchDirs.add(join(homebrewPrefix, 'opt', 'trash', 'bin'));
  searchDirs.add('/usr/local/opt/trash/bin');

  const candidatePaths = new Set<string>();
  for (const name of candidateNames) {
    candidatePaths.add(name);
    for (const dir of searchDirs) {
      candidatePaths.add(join(dir, name));
    }
  }

  for (const candidate of candidatePaths) {
    try {
      const proc = Bun.spawn([candidate, '--help'], {
        stdout: 'ignore',
        stderr: 'ignore',
      });
      const exitCode = await proc.exited;
      if (exitCode === 0 || exitCode === 1) {
        cachedTrashCliCommand = candidate;
        return candidate;
      }
    } catch (error) {
      if (ENABLE_DEBUG_LOGS) {
        console.error(`[runner] trash-cli probe failed for ${candidate}: ${formatTrashError(error)}`);
      }
    }
  }

  cachedTrashCliCommand = null;
  return null;
}

// Consumes a child process stream to completion for logging/error output.
async function readProcessStream(stream: unknown): Promise<string> {
  if (!stream) {
    return '';
  }
  try {
    const candidate = stream as { text?: () => Promise<string> };
    if (candidate.text) {
      return (await candidate.text()) ?? '';
    }
  } catch {
    // ignore
  }
  try {
    if (stream instanceof ReadableStream) {
      return await new Response(stream).text();
    }
    if (typeof stream === 'object' && stream !== null) {
      return await new Response(stream as BodyInit).text();
    }
  } catch {
    // ignore errors and return empty string
  }
  return '';
}

// Shows CLI usage plus optional error messaging.
function printUsage(message?: string) {
  if (message) {
    console.error(`[runner] ${message}`);
  }
  console.error('Usage: runner [--] <command...>');
  console.error('');
  console.error(
    `Defaults: ${formatDuration(DEFAULT_TIMEOUT_MS)} timeout for most commands, ${formatDuration(
      EXTENDED_TIMEOUT_MS
    )} when lint/test suites are detected.`
  );
}

// Pretty-prints a millisecond duration for logs.
function formatDuration(durationMs: number): string {
  if (durationMs < 1000) {
    return `${durationMs}ms`;
  }
  const seconds = durationMs / 1000;
  if (seconds < 60) {
    return `${seconds.toFixed(1)}s`;
  }
  const minutes = Math.floor(seconds / 60);
  const remainingSeconds = Math.round(seconds % 60);
  if (minutes < 60) {
    if (remainingSeconds === 0) {
      return `${minutes}m`;
    }
    return `${minutes}m ${remainingSeconds}s`;
  }
  const hours = Math.floor(minutes / 60);
  const remainingMinutes = minutes % 60;
  if (remainingMinutes === 0) {
    return `${hours}h`;
  }
  return `${hours}h ${remainingMinutes}m`;
}

function resolveSummaryStyle(rawValue: string | undefined | null): SummaryStyle {
  if (!rawValue) {
    return 'compact';
  }
  const normalized = rawValue.trim().toLowerCase();
  switch (normalized) {
    case 'minimal':
      return 'minimal';
    case 'verbose':
      return 'verbose';
    case 'short':
      return 'compact';
    default:
      return 'compact';
  }
}

function formatCompletionSummary(options: {
  exitCode: number;
  elapsedMs?: number;
  timedOut?: boolean;
  commandLabel: string;
}): string {
  const { exitCode, elapsedMs, timedOut, commandLabel } = options;
  const durationText = typeof elapsedMs === 'number' ? formatDuration(elapsedMs) : null;
  // biome-ignore lint/nursery/noUnnecessaryConditions: switch makes the formatter easier to scan.
  switch (SUMMARY_STYLE) {
    case 'minimal': {
      const parts = [`${exitCode}`];
      if (durationText) {
        parts.push(durationText);
      }
      if (timedOut) {
        parts.push('timeout');
      }
      return `[runner] ${parts.join(' · ')}`;
    }
    case 'verbose': {
      const elapsedPart = durationText ? `, elapsed ${durationText}` : '';
      const timeoutPart = timedOut ? '; timed out' : '';
      return `[runner] Finished ${commandLabel} (exit ${exitCode}${elapsedPart}${timeoutPart}).`;
    }
    default: {
      const elapsedPart = durationText ? ` in ${durationText}` : '';
      const timeoutPart = timedOut ? ' (timeout)' : '';
      return `[runner] exit ${exitCode}${elapsedPart}${timeoutPart}`;
    }
  }
}

// Joins the command args in a shell-friendly way for log display.
function formatDisplayCommand(commandArgs: string[]): string {
  return commandArgs.map((token) => (token.includes(' ') ? `"${token}"` : token)).join(' ');
}

// Tells whether the runner is already executing inside the tmux guard.
function isRunnerTmuxSession(): boolean {
  const value = process.env.RUNNER_TMUX;
  if (value) {
    return value !== '0' && value.toLowerCase() !== 'false';
  }
  return Boolean(process.env.TMUX);
}


--- scripts/test-browser.ts ---
#!/usr/bin/env tsx
/**
 * Lightweight browser connectivity smoke test.
 * - Launches Chrome headful with a fixed DevTools port (default 45871 or env ORACLE_BROWSER_PORT/ORACLE_BROWSER_DEBUG_PORT).
 * - Verifies the DevTools /json/version endpoint responds.
 * - Prints a WSL-friendly firewall hint if the port is unreachable.
 */

import { setTimeout as sleep } from 'node:timers/promises';
import { launch } from 'chrome-launcher';
import os from 'node:os';
import { readFileSync } from 'node:fs';

const DEFAULT_PORT = 45871;
const port = normalizePort(process.env.ORACLE_BROWSER_PORT ?? process.env.ORACLE_BROWSER_DEBUG_PORT) ?? DEFAULT_PORT;
const hostHint = resolveWslHost();
const targetHost = hostHint ?? '127.0.0.1';

function normalizePort(raw?: string | null): number | null {
  if (!raw) return null;
  const value = Number.parseInt(raw, 10);
  if (!Number.isFinite(value) || value <= 0 || value > 65535) return null;
  return value;
}

function isWsl(): boolean {
  if (process.platform !== 'linux') return false;
  if (process.env.WSL_DISTRO_NAME) return true;
  return os.release().toLowerCase().includes('microsoft');
}

function resolveWslHost(): string | null {
  if (!isWsl()) return null;
  try {
    const resolv = readFileSync('/etc/resolv.conf', 'utf8');
    for (const line of resolv.split('\n')) {
      const match = line.match(/^nameserver\s+([0-9.]+)/);
      if (match?.[1]) return match[1];
    }
  } catch {
    // ignore
  }
  return null;
}

function firewallHint(host: string, devtoolsPort: number): string | null {
  if (!isWsl()) return null;
  return [
    `DevTools port ${host}:${devtoolsPort} is blocked from WSL.`,
    '',
    'PowerShell (admin):',
    `New-NetFirewallRule -DisplayName 'Chrome DevTools ${devtoolsPort}' -Direction Inbound -Action Allow -Protocol TCP -LocalPort ${devtoolsPort}`,
    "New-NetFirewallRule -DisplayName 'Chrome DevTools (chrome.exe)' -Direction Inbound -Action Allow -Program 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe' -Protocol TCP",
    '',
    'Re-run ./runner pnpm test:browser after adding the rule.',
  ].join('\n');
}

async function fetchVersion(host: string, devtoolsPort: number): Promise<boolean> {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), 5000);
  try {
    const res = await fetch(`http://${host}:${devtoolsPort}/json/version`, { signal: controller.signal });
    if (!res.ok) return false;
    const json = (await res.json()) as { webSocketDebuggerUrl?: string };
    return Boolean(json.webSocketDebuggerUrl);
  } catch {
    return false;
  } finally {
    clearTimeout(timer);
  }
}

async function main() {
  console.log(`[browser-test] launching Chrome on ${targetHost}:${port} (headful)…`);
  const chrome = await launch({
    port,
    chromeFlags: ['--remote-debugging-address=0.0.0.0'],
  });

  let ok = await fetchVersion(targetHost, chrome.port);
  if (!ok) {
    await sleep(500);
    ok = await fetchVersion(targetHost, chrome.port);
  }

  await chrome.kill();

  if (ok) {
    console.log(`[browser-test] PASS: DevTools responding on ${targetHost}:${chrome.port}`);
    process.exit(0);
  }

  const hint = firewallHint(targetHost, chrome.port);
  console.error(`[browser-test] FAIL: DevTools not reachable at ${targetHost}:${chrome.port}`);
  if (hint) {
    console.error(hint);
  }
  process.exit(1);
}

main().catch((error) => {
  console.error('[browser-test] Unexpected failure:', error instanceof Error ? error.message : String(error));
  process.exit(1);
});


--- scripts/test-remote-chrome.ts ---
#!/usr/bin/env npx tsx
/**
 * POC: Test connecting to remote Chrome instance
 *
 * On remote machine with display, run:
 *   google-chrome --remote-debugging-port=9222 --remote-debugging-address=0.0.0.0
 *
 * Then run this script:
 *   npx tsx scripts/test-remote-chrome.ts <remote-host> [port]
 */

import CDP from 'chrome-remote-interface';

async function main() {
  const host = process.argv[2] || 'localhost';
  const port = parseInt(process.argv[3] || '9222', 10);

  console.log(`Attempting to connect to Chrome at ${host}:${port}...`);

  try {
    // Test connection
    const client = await CDP({ host, port });
    console.log('✓ Connected to Chrome DevTools Protocol');

    const { Network, Page, Runtime } = client;

    // Enable domains
    await Promise.all([Network.enable(), Page.enable()]);
    console.log('✓ Enabled Network and Page domains');

    // Get browser version info
    const version = await CDP.Version({ host, port });
    console.log(`✓ Browser: ${version.Browser}`);
    console.log(`✓ Protocol: ${version['Protocol-Version']}`);

    // Navigate to ChatGPT
    console.log('\nNavigating to ChatGPT...');
    await Page.navigate({ url: 'https://chatgpt.com/' });
    await Page.loadEventFired();
    console.log('✓ Page loaded');

    // Check current URL
    const evalResult = await Runtime.evaluate({ expression: 'window.location.href' });
    console.log(`✓ Current URL: ${evalResult.result.value}`);

    // Check if logged in (look for specific elements)
    const checkLogin = await Runtime.evaluate({
      expression: `
        // Check for composer textarea (indicates logged in)
        const composer = document.querySelector('textarea, [contenteditable="true"]');
        const hasComposer = !!composer;

        // Check for login button (indicates logged out)
        const loginBtn = document.querySelector('a[href*="login"], button[data-testid*="login"]');
        const hasLogin = !!loginBtn;

        ({ hasComposer, hasLogin, loggedIn: hasComposer && !hasLogin })
      `,
    });
    console.log(`✓ Login status: ${JSON.stringify(checkLogin.result.value)}`);

    await client.close();
    console.log('\n✓ POC successful! Remote Chrome connection works.');
    console.log('\nTo use Oracle with remote Chrome, you would need to:');
    console.log('1. Ensure cookies are loaded in remote Chrome');
    console.log('2. Configure Oracle with --remote-chrome <host:port> to use this instance');
    console.log('3. Ensure Oracle skips local Chrome launch when --remote-chrome is specified');

  } catch (error) {
    console.error('✗ Connection failed:', error instanceof Error ? error.message : error);
    console.log('\nTroubleshooting:');
    console.log('1. Ensure Chrome is running on remote machine with:');
    console.log(`   google-chrome --remote-debugging-port=${port} --remote-debugging-address=0.0.0.0`);
    console.log('2. Check firewall allows connections to port', port);
    console.log('3. Verify network connectivity to', host);
    process.exit(1);
  }
}

void main();


--- src/browserMode.ts ---
export type {
  BrowserAutomationConfig,
  BrowserRunOptions,
  BrowserRunResult,
} from './browser/index.js';

export {
  runBrowserMode,
  CHATGPT_URL,
  DEFAULT_MODEL_STRATEGY,
  DEFAULT_MODEL_TARGET,
  parseDuration,
  normalizeChatgptUrl,
  isTemporaryChatUrl,
} from './browser/index.js';


--- src/config.ts ---
import fs from 'node:fs/promises';
import path from 'node:path';
import JSON5 from 'json5';
import { getOracleHomeDir } from './oracleHome.js';
import type { BrowserModelStrategy } from './browser/types.js';
import type { ThinkingTimeLevel } from './oracle/types.js';

export type EnginePreference = 'api' | 'browser';

export interface NotifyConfig {
  enabled?: boolean;
  sound?: boolean;
  muteIn?: Array<'CI' | 'SSH'>;
}

export interface BrowserConfigDefaults {
  chromeProfile?: string | null;
  chromePath?: string | null;
  chromeCookiePath?: string | null;
  chatgptUrl?: string | null;
  url?: string;
  timeoutMs?: number;
  debugPort?: number | null;
  inputTimeoutMs?: number;
  cookieSyncWaitMs?: number;
  headless?: boolean;
  hideWindow?: boolean;
  keepBrowser?: boolean;
  modelStrategy?: BrowserModelStrategy;
  /** Thinking time intensity (ChatGPT Thinking/Pro models): 'light', 'standard', 'extended', 'heavy' */
  thinkingTime?: ThinkingTimeLevel;
  /** Skip cookie sync and reuse a persistent automation profile (waits for manual ChatGPT login). */
  manualLogin?: boolean;
  /** Manual-login profile directory override (also available via ORACLE_BROWSER_PROFILE_DIR). */
  manualLoginProfileDir?: string | null;
}

export interface AzureConfig {
  endpoint?: string;
  deployment?: string;
  apiVersion?: string;
}

export interface RemoteServiceConfig {
  host?: string;
  token?: string;
}

export interface UserConfig {
  engine?: EnginePreference;
  model?: string;
  search?: 'on' | 'off';
  notify?: NotifyConfig;
  browser?: BrowserConfigDefaults;
  heartbeatSeconds?: number;
  filesReport?: boolean;
  background?: boolean;
  promptSuffix?: string;
  apiBaseUrl?: string;
  azure?: AzureConfig;
  sessionRetentionHours?: number;
  remote?: RemoteServiceConfig;
  remoteHost?: string;
  remoteToken?: string;
}

function resolveConfigPath(): string {
  return path.join(getOracleHomeDir(), 'config.json');
}

export interface LoadConfigResult {
  config: UserConfig;
  path: string;
  loaded: boolean;
}

export async function loadUserConfig(): Promise<LoadConfigResult> {
  const CONFIG_PATH = resolveConfigPath();
  try {
    const raw = await fs.readFile(CONFIG_PATH, 'utf8');
    const parsed = JSON5.parse(raw) as UserConfig;
    return { config: parsed ?? {}, path: CONFIG_PATH, loaded: true };
  } catch (error) {
    const code = (error as { code?: string }).code;
    if (code === 'ENOENT') {
      return { config: {}, path: CONFIG_PATH, loaded: false };
    }
    console.warn(`Failed to read ${CONFIG_PATH}: ${error instanceof Error ? error.message : String(error)}`);
    return { config: {}, path: CONFIG_PATH, loaded: false };
  }
}
export function configPath(): string {
  return resolveConfigPath();
}


--- src/heartbeat.ts ---
export interface HeartbeatConfig {
  intervalMs?: number;
  log: (message: string) => void;
  isActive: () => boolean;
  makeMessage: (elapsedMs: number) => Promise<string | null> | string | null;
}

export function startHeartbeat(config: HeartbeatConfig): () => void {
  const { intervalMs, log, isActive, makeMessage } = config;
  if (!intervalMs || intervalMs <= 0) {
    return () => {};
  }
  let stopped = false;
  let pending = false;
  const start = Date.now();
  const timer = setInterval(async () => {
    // stop flag flips asynchronously
    if (stopped || pending) {
      return;
    }
    if (!isActive()) {
      stop();
      return;
    }
    pending = true;
    try {
      const elapsed = Date.now() - start;
      const message = await makeMessage(elapsed);
      if (message && !stopped) {
        log(message);
      }
    } catch {
      // ignore heartbeat errors
    } finally {
      pending = false;
    }
  }, intervalMs);
  timer.unref?.();
  const stop = () => {
    // multiple callers may race to stop
    if (stopped) {
      return;
    }
    stopped = true;
    clearInterval(timer);
  };
  return stop;
}


--- src/oracle.ts ---
export * from './oracle/types.js';
export {
  MODEL_CONFIGS,
  DEFAULT_MODEL,
  PRO_MODELS,
  DEFAULT_SYSTEM_PROMPT,
  TOKENIZER_OPTIONS,
} from './oracle/config.js';
export { readFiles, createFileSections } from './oracle/files.js';
export { buildPrompt, buildRequestBody, renderPromptMarkdown } from './oracle/request.js';
export { estimateRequestTokens } from './oracle/tokenEstimate.js';
export { formatUSD, formatNumber, formatElapsed } from './oracle/format.js';
export { formatFileSection } from './oracle/markdown.js';
export { getFileTokenStats, printFileTokenStats } from './oracle/tokenStats.js';
export {
  OracleResponseError,
  OracleTransportError,
  OracleUserError,
  FileValidationError,
  BrowserAutomationError,
  PromptValidationError,
  describeTransportError,
  extractResponseMetadata,
  asOracleUserError,
  toTransportError,
} from './oracle/errors.js';
export { createDefaultClientFactory } from './oracle/client.js';
export { runOracle, extractTextOutput } from './oracle/run.js';
export { resolveGeminiModelId } from './oracle/gemini.js';


--- src/oracleHome.ts ---
import os from 'node:os';
import path from 'node:path';

let oracleHomeDirOverride: string | null = null;

/**
 * Test-only hook: avoid mutating process.env (shared across Vitest worker threads).
 * This override is scoped to the current Node worker.
 */
export function setOracleHomeDirOverrideForTest(dir: string | null): void {
  oracleHomeDirOverride = dir;
}

export function getOracleHomeDir(): string {
  return oracleHomeDirOverride ?? process.env.ORACLE_HOME_DIR ?? path.join(os.homedir(), '.oracle');
}



--- src/sessionManager.ts ---
import path from 'node:path';
import fs from 'node:fs/promises';
import { createWriteStream } from 'node:fs';
import type { WriteStream } from 'node:fs';
import net from 'node:net';
import type { BrowserModelStrategy, CookieParam } from './browser/types.js';
import type { TransportFailureReason, AzureOptions, ModelName, ThinkingTimeLevel } from './oracle.js';
import { DEFAULT_MODEL } from './oracle.js';
import { safeModelSlug } from './oracle/modelResolver.js';
import { getOracleHomeDir } from './oracleHome.js';

export type SessionMode = 'api' | 'browser';

export interface BrowserSessionConfig {
  chromeProfile?: string | null;
  chromePath?: string | null;
  chromeCookiePath?: string | null;
  chatgptUrl?: string | null;
  url?: string;
  timeoutMs?: number;
  debugPort?: number | null;
  inputTimeoutMs?: number;
  cookieSync?: boolean;
  cookieNames?: string[] | null;
  cookieSyncWaitMs?: number;
  inlineCookies?: CookieParam[] | null;
  inlineCookiesSource?: string | null;
  headless?: boolean;
  keepBrowser?: boolean;
  hideWindow?: boolean;
  desiredModel?: string | null;
  modelStrategy?: BrowserModelStrategy;
  debug?: boolean;
  allowCookieErrors?: boolean;
  remoteChrome?: { host: string; port: number } | null;
  manualLogin?: boolean;
  manualLoginProfileDir?: string | null;
  manualLoginCookieSync?: boolean;
  /** Thinking time intensity: 'light', 'standard', 'extended', 'heavy' */
  thinkingTime?: ThinkingTimeLevel;
}

export interface BrowserRuntimeMetadata {
  chromePid?: number;
  chromePort?: number;
  chromeHost?: string;
  userDataDir?: string;
  chromeTargetId?: string;
  tabUrl?: string;
  conversationId?: string;
  /** PID of the controller process that launched this browser run. Helps detect orphaned sessions. */
  controllerPid?: number;
}

export interface BrowserMetadata {
  config?: BrowserSessionConfig;
  runtime?: BrowserRuntimeMetadata;
}

export interface SessionResponseMetadata {
  id?: string;
  requestId?: string | null;
  status?: string;
  incompleteReason?: string | null;
}

export interface SessionTransportMetadata {
  reason?: TransportFailureReason;
}

export interface SessionUserErrorMetadata {
  category?: string;
  message?: string;
  details?: Record<string, unknown>;
}

export interface StoredRunOptions {
  prompt?: string;
  file?: string[];
  model?: string;
  models?: ModelName[];
  maxInput?: number;
  system?: string;
  maxOutput?: number;
  silent?: boolean;
  filesReport?: boolean;
  slug?: string;
  mode?: SessionMode;
  browserConfig?: BrowserSessionConfig;
  verbose?: boolean;
  heartbeatIntervalMs?: number;
  browserAttachments?: 'auto' | 'never' | 'always';
  browserInlineFiles?: boolean;
  browserBundleFiles?: boolean;
  background?: boolean;
  search?: boolean;
  baseUrl?: string;
  azure?: AzureOptions;
  effectiveModelId?: string;
  renderPlain?: boolean;
  writeOutputPath?: string;
}

export interface SessionMetadata {
  id: string;
  createdAt: string;
  status: string;
  promptPreview?: string;
  model?: string;
  models?: SessionModelRun[];
  cwd?: string;
  options: StoredRunOptions;
  notifications?: SessionNotifications;
  startedAt?: string;
  completedAt?: string;
  mode?: SessionMode;
  usage?: {
    inputTokens: number;
    outputTokens: number;
    reasoningTokens: number;
    totalTokens: number;
    cost?: number;
  };
  errorMessage?: string;
  elapsedMs?: number;
  browser?: BrowserMetadata;
  response?: SessionResponseMetadata;
  transport?: SessionTransportMetadata;
  error?: SessionUserErrorMetadata;
}

export type SessionStatus = 'pending' | 'running' | 'completed' | 'error' | 'cancelled';

export interface SessionModelRun {
  model: string;
  status: SessionStatus;
  queuedAt?: string;
  startedAt?: string;
  completedAt?: string;
  usage?: {
    inputTokens: number;
    outputTokens: number;
    reasoningTokens: number;
    totalTokens: number;
    cost?: number;
  };
  response?: SessionResponseMetadata;
  transport?: SessionTransportMetadata;
  error?: SessionUserErrorMetadata;
  log?: {
    path: string;
    bytes?: number;
  };
}

export interface SessionNotifications {
  enabled: boolean;
  sound: boolean;
}

interface SessionLogWriter {
  stream: WriteStream;
  logLine: (line?: string) => void;
  writeChunk: (chunk: string) => boolean;
  logPath: string;
}

interface InitializeSessionOptions extends StoredRunOptions {
  prompt?: string;
  model: string;
}

export function getSessionsDir(): string {
  return path.join(getOracleHomeDir(), 'sessions');
}
const METADATA_FILENAME = 'meta.json';
const LEGACY_SESSION_FILENAME = 'session.json';
const LEGACY_REQUEST_FILENAME = 'request.json';
const MODELS_DIRNAME = 'models';
const MODEL_JSON_EXTENSION = '.json';
const MODEL_LOG_EXTENSION = '.log';
const MAX_STATUS_LIMIT = 1000;
const ZOMBIE_MAX_AGE_MS = 60 * 60 * 1000; // 60 minutes
const CHROME_RUNTIME_TIMEOUT_MS = 250;
const DEFAULT_SLUG = 'session';
const MAX_SLUG_WORDS = 5;
const MIN_CUSTOM_SLUG_WORDS = 3;
const MAX_SLUG_WORD_LENGTH = 10;

async function ensureDir(dirPath: string): Promise<void> {
  await fs.mkdir(dirPath, { recursive: true });
}

export async function ensureSessionStorage(): Promise<void> {
  await ensureDir(getSessionsDir());
}

function slugify(text: string | undefined, maxWords = MAX_SLUG_WORDS): string {
  const normalized = text?.toLowerCase() ?? '';
  const words = normalized.match(/[a-z0-9]+/g) ?? [];
  const trimmed = words
    .slice(0, maxWords)
    .map((word) => word.slice(0, MAX_SLUG_WORD_LENGTH));
  return trimmed.length > 0 ? trimmed.join('-') : DEFAULT_SLUG;
}

function countSlugWords(slug: string): number {
  return slug.split('-').filter(Boolean).length;
}

function normalizeCustomSlug(candidate: string): string {
  const slug = slugify(candidate, MAX_SLUG_WORDS);
  const wordCount = countSlugWords(slug);
  if (wordCount < MIN_CUSTOM_SLUG_WORDS || wordCount > MAX_SLUG_WORDS) {
    throw new Error(`Custom slug must include between ${MIN_CUSTOM_SLUG_WORDS} and ${MAX_SLUG_WORDS} words.`);
  }
  return slug;
}

export function createSessionId(prompt: string, customSlug?: string): string {
  if (customSlug) {
    return normalizeCustomSlug(customSlug);
  }
  return slugify(prompt);
}

function sessionDir(id: string): string {
  return path.join(getSessionsDir(), id);
}

function metaPath(id: string): string {
  return path.join(sessionDir(id), METADATA_FILENAME);
}

function requestPath(id: string): string {
  return path.join(sessionDir(id), LEGACY_REQUEST_FILENAME);
}

function legacySessionPath(id: string): string {
  return path.join(sessionDir(id), LEGACY_SESSION_FILENAME);
}

function logPath(id: string): string {
  return path.join(sessionDir(id), 'output.log');
}

function modelsDir(id: string): string {
  return path.join(sessionDir(id), MODELS_DIRNAME);
}

function modelJsonPath(id: string, model: string): string {
  const slug = safeModelSlug(model);
  return path.join(modelsDir(id), `${slug}${MODEL_JSON_EXTENSION}`);
}

function modelLogPath(id: string, model: string): string {
  const slug = safeModelSlug(model);
  return path.join(modelsDir(id), `${slug}${MODEL_LOG_EXTENSION}`);
}

async function fileExists(targetPath: string): Promise<boolean> {
  try {
    await fs.access(targetPath);
    return true;
  } catch {
    return false;
  }
}

async function ensureUniqueSessionId(baseSlug: string): Promise<string> {
  let candidate = baseSlug;
  let suffix = 2;
  while (await fileExists(sessionDir(candidate))) {
    candidate = `${baseSlug}-${suffix}`;
    suffix += 1;
  }
  return candidate;
}

async function listModelRunFiles(sessionId: string): Promise<SessionModelRun[]> {
  const dir = modelsDir(sessionId);
  const entries = await fs.readdir(dir).catch(() => []);
  const result: SessionModelRun[] = [];
  for (const entry of entries) {
    if (!entry.endsWith(MODEL_JSON_EXTENSION)) {
      continue;
    }
    const jsonPath = path.join(dir, entry);
    try {
      const raw = await fs.readFile(jsonPath, 'utf8');
      const parsed = JSON.parse(raw) as SessionModelRun;
      const normalized = ensureModelLogReference(sessionId, parsed);
      result.push(normalized);
    } catch {
      // ignore malformed model files
    }
  }
  return result;
}

function ensureModelLogReference(sessionId: string, record: SessionModelRun): SessionModelRun {
  const logPathRelative =
    record.log?.path ?? path.relative(sessionDir(sessionId), modelLogPath(sessionId, record.model));
  return {
    ...record,
    log: { path: logPathRelative, bytes: record.log?.bytes },
  };
}

async function readModelRunFile(sessionId: string, model: string): Promise<SessionModelRun | null> {
  try {
    const raw = await fs.readFile(modelJsonPath(sessionId, model), 'utf8');
    const parsed = JSON.parse(raw) as SessionModelRun;
    return ensureModelLogReference(sessionId, parsed);
  } catch {
    return null;
  }
}

export async function updateModelRunMetadata(
  sessionId: string,
  model: string,
  updates: Partial<SessionModelRun>,
): Promise<SessionModelRun> {
  await ensureDir(modelsDir(sessionId));
  const existing = (await readModelRunFile(sessionId, model)) ?? {
    model,
    status: 'pending',
  };
  const next: SessionModelRun = ensureModelLogReference(sessionId, {
    ...existing,
    ...updates,
    model,
  });
  await fs.writeFile(modelJsonPath(sessionId, model), JSON.stringify(next, null, 2), 'utf8');
  return next;
}

export async function readModelRunMetadata(sessionId: string, model: string): Promise<SessionModelRun | null> {
  return readModelRunFile(sessionId, model);
}

export async function initializeSession(
  options: InitializeSessionOptions,
  cwd: string,
  notifications?: SessionNotifications,
): Promise<SessionMetadata> {
  await ensureSessionStorage();
  const baseSlug = createSessionId(options.prompt || DEFAULT_SLUG, options.slug);
  const sessionId = await ensureUniqueSessionId(baseSlug);
  const dir = sessionDir(sessionId);
  await ensureDir(dir);
  const mode = options.mode ?? 'api';
  const browserConfig = options.browserConfig;
  const modelList: ModelName[] =
    Array.isArray(options.models) && options.models.length > 0
      ? options.models
      : options.model
        ? [options.model as ModelName]
        : [];

  const metadata: SessionMetadata = {
    id: sessionId,
    createdAt: new Date().toISOString(),
    status: 'pending',
    promptPreview: (options.prompt || '').slice(0, 160),
    model: modelList[0] ?? options.model,
    models: modelList.map((modelName) => ({
      model: modelName,
      status: 'pending',
    })),
    cwd,
    mode,
    browser: browserConfig ? { config: browserConfig } : undefined,
    notifications,
    options: {
      prompt: options.prompt,
      file: options.file ?? [],
      model: options.model,
      models: modelList,
      effectiveModelId: options.effectiveModelId,
      maxInput: options.maxInput,
      system: options.system,
      maxOutput: options.maxOutput,
      silent: options.silent,
      filesReport: options.filesReport,
      slug: sessionId,
      mode,
      browserConfig,
      verbose: options.verbose,
      heartbeatIntervalMs: options.heartbeatIntervalMs,
      browserAttachments: options.browserAttachments,
      browserInlineFiles: options.browserInlineFiles,
      browserBundleFiles: options.browserBundleFiles,
      background: options.background,
      search: options.search,
      baseUrl: options.baseUrl,
      azure: options.azure,
      writeOutputPath: options.writeOutputPath,
    },
  };
  await ensureDir(modelsDir(sessionId));
  await fs.writeFile(metaPath(sessionId), JSON.stringify(metadata, null, 2), 'utf8');
  await Promise.all(
    (modelList.length > 0 ? modelList : [metadata.model ?? DEFAULT_MODEL]).map(async (modelName) => {
      const jsonPath = modelJsonPath(sessionId, modelName);
      const logFilePath = modelLogPath(sessionId, modelName);
      const modelRecord: SessionModelRun = {
        model: modelName,
        status: 'pending',
        log: { path: path.relative(sessionDir(sessionId), logFilePath) },
      };
      await fs.writeFile(jsonPath, JSON.stringify(modelRecord, null, 2), 'utf8');
      await fs.writeFile(logFilePath, '', 'utf8');
    }),
  );
  await fs.writeFile(logPath(sessionId), '', 'utf8');
  return metadata;
}

export async function readSessionMetadata(sessionId: string): Promise<SessionMetadata | null> {
  const modern = await readModernSessionMetadata(sessionId);
  if (modern) {
    return modern;
  }
  const legacy = await readLegacySessionMetadata(sessionId);
  if (legacy) {
    return legacy;
  }
  return null;
}

export async function updateSessionMetadata(
  sessionId: string,
  updates: Partial<SessionMetadata>,
): Promise<SessionMetadata> {
  const existing =
    (await readModernSessionMetadata(sessionId)) ??
    (await readLegacySessionMetadata(sessionId)) ??
    ({ id: sessionId } as SessionMetadata);
  const next = { ...existing, ...updates };
  await fs.writeFile(metaPath(sessionId), JSON.stringify(next, null, 2), 'utf8');
  return next;
}

async function readModernSessionMetadata(sessionId: string): Promise<SessionMetadata | null> {
  try {
    const raw = await fs.readFile(metaPath(sessionId), 'utf8');
    const parsed = JSON.parse(raw) as SessionMetadata | StoredRunOptions;
    if (!isSessionMetadataRecord(parsed)) {
      return null;
    }
    const enriched = await attachModelRuns(parsed, sessionId);
    const runtimeChecked = await markDeadBrowser(enriched, { persist: false });
    return await markZombie(runtimeChecked, { persist: false });
  } catch {
    return null;
  }
}

async function readLegacySessionMetadata(sessionId: string): Promise<SessionMetadata | null> {
  try {
    const raw = await fs.readFile(legacySessionPath(sessionId), 'utf8');
    const parsed = JSON.parse(raw) as SessionMetadata;
    const enriched = await attachModelRuns(parsed, sessionId);
    const runtimeChecked = await markDeadBrowser(enriched, { persist: false });
    return await markZombie(runtimeChecked, { persist: false });
  } catch {
    return null;
  }
}

function isSessionMetadataRecord(value: unknown): value is SessionMetadata {
  return Boolean(value && typeof (value as SessionMetadata).id === 'string' && (value as SessionMetadata).status);
}

async function attachModelRuns(meta: SessionMetadata, sessionId: string): Promise<SessionMetadata> {
  const runs = await listModelRunFiles(sessionId);
  if (runs.length === 0) {
    return meta;
  }
  return { ...meta, models: runs };
}

export function createSessionLogWriter(sessionId: string, model?: string): SessionLogWriter {
  const targetPath = model ? modelLogPath(sessionId, model) : logPath(sessionId);
  if (model) {
    void ensureDir(modelsDir(sessionId));
  }
  const stream = createWriteStream(targetPath, { flags: 'a' });
  const logLine = (line = ''): void => {
    stream.write(`${line}\n`);
  };
  const writeChunk = (chunk: string): boolean => {
    stream.write(chunk);
    return true;
  };
  return { stream, logLine, writeChunk, logPath: targetPath };
}

export async function listSessionsMetadata(): Promise<SessionMetadata[]> {
  await ensureSessionStorage();
  const entries = await fs.readdir(getSessionsDir()).catch(() => []);
  const metas: SessionMetadata[] = [];
  for (const entry of entries) {
    let meta = await readSessionMetadata(entry);
    if (meta) {
      meta = await markDeadBrowser(meta, { persist: true });
      meta = await markZombie(meta, { persist: true }); // keep stored metadata consistent with zombie detection
      metas.push(meta);
    }
  }
  return metas.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
}

export function filterSessionsByRange(
  metas: SessionMetadata[],
  { hours = 24, includeAll = false, limit = 100 }: { hours?: number; includeAll?: boolean; limit?: number },
): { entries: SessionMetadata[]; truncated: boolean; total: number } {
  const maxLimit = Math.min(limit, MAX_STATUS_LIMIT);
  let filtered = metas;
  if (!includeAll) {
    const cutoff = Date.now() - hours * 60 * 60 * 1000;
    filtered = metas.filter((meta) => new Date(meta.createdAt).getTime() >= cutoff);
  }
  const limited = filtered.slice(0, maxLimit);
  const truncated = filtered.length > maxLimit;
  return { entries: limited, truncated, total: filtered.length };
}

export async function readSessionLog(sessionId: string): Promise<string> {
  const runs = await listModelRunFiles(sessionId);
  if (runs.length === 0) {
    try {
      return await fs.readFile(logPath(sessionId), 'utf8');
    } catch {
      return '';
    }
  }
  const sections: string[] = [];
  let hasContent = false;
  const ordered = runs
    .slice()
    .sort((a, b) => (a.startedAt && b.startedAt ? a.startedAt.localeCompare(b.startedAt) : a.model.localeCompare(b.model)));
  for (const run of ordered) {
    const logFile =
      run.log?.path
        ? path.isAbsolute(run.log.path)
          ? run.log.path
          : path.join(sessionDir(sessionId), run.log.path)
        : modelLogPath(sessionId, run.model);
    let body = '';
    try {
      body = await fs.readFile(logFile, 'utf8');
    } catch {
      body = '';
    }
    if (body.length > 0) {
      hasContent = true;
    }
    sections.push(`=== ${run.model} ===\n${body}`.trimEnd());
  }
  if (!hasContent) {
    try {
      return await fs.readFile(logPath(sessionId), 'utf8');
    } catch {
      // ignore and return structured header-only log
    }
  }
  return sections.join('\n\n');
}

export async function readModelLog(sessionId: string, model: string): Promise<string> {
  try {
    return await fs.readFile(modelLogPath(sessionId, model), 'utf8');
  } catch {
    return '';
  }
}

export async function readSessionRequest(sessionId: string): Promise<StoredRunOptions | null> {
  const modern = await readModernSessionMetadata(sessionId);
  if (modern?.options) {
    return modern.options;
  }
  try {
    const raw = await fs.readFile(requestPath(sessionId), 'utf8');
    const parsed = JSON.parse(raw);
    if (isSessionMetadataRecord(parsed)) {
      return parsed.options ?? null;
    }
    return parsed as StoredRunOptions;
  } catch {
    return null;
  }
}

export async function deleteSessionsOlderThan({
  hours = 24,
  includeAll = false,
}: { hours?: number; includeAll?: boolean } = {}): Promise<{ deleted: number; remaining: number }> {
  await ensureSessionStorage();
  const entries = await fs.readdir(getSessionsDir()).catch(() => []);
  if (!entries.length) {
    return { deleted: 0, remaining: 0 };
  }
  const cutoff = includeAll ? Number.NEGATIVE_INFINITY : Date.now() - hours * 60 * 60 * 1000;
  let deleted = 0;

  for (const entry of entries) {
    const dir = sessionDir(entry);
    let createdMs: number | undefined;
    const meta = await readSessionMetadata(entry);
    if (meta?.createdAt) {
      const parsed = Date.parse(meta.createdAt);
      if (!Number.isNaN(parsed)) {
        createdMs = parsed;
      }
    }
    if (createdMs == null) {
      try {
        const stats = await fs.stat(dir);
        createdMs = stats.birthtimeMs || stats.mtimeMs;
      } catch {
        continue;
      }
    }
    if (includeAll || (createdMs != null && createdMs < cutoff)) {
      await fs.rm(dir, { recursive: true, force: true });
      deleted += 1;
    }
  }

  const remaining = Math.max(entries.length - deleted, 0);
  return { deleted, remaining };
}

export async function wait(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

export { MAX_STATUS_LIMIT };
export { ZOMBIE_MAX_AGE_MS };

export async function getSessionPaths(sessionId: string): Promise<{
  dir: string;
  metadata: string;
  log: string;
  request: string;
}> {
  const dir = sessionDir(sessionId);
  const metadata = metaPath(sessionId);
  const log = logPath(sessionId);
  const request = requestPath(sessionId);

  const required = [metadata, log];
  const missing: string[] = [];
  for (const file of required) {
    if (!(await fileExists(file))) {
      missing.push(path.basename(file));
    }
  }

  if (missing.length > 0) {
    throw new Error(`Session "${sessionId}" is missing: ${missing.join(', ')}`);
  }
  return { dir, metadata, log, request };
}

async function markZombie(meta: SessionMetadata, { persist }: { persist: boolean }): Promise<SessionMetadata> {
  if (!isZombie(meta)) {
    return meta;
  }
  if (meta.mode === 'browser') {
    const runtime = meta.browser?.runtime;
    if (runtime) {
      const signals: boolean[] = [];
      if (runtime.chromePid) {
        signals.push(isProcessAlive(runtime.chromePid));
      }
      if (runtime.chromePort) {
        const host = runtime.chromeHost ?? '127.0.0.1';
        signals.push(await isPortOpen(host, runtime.chromePort));
      }
      if (signals.some(Boolean)) {
        return meta;
      }
    }
  }
  const updated: SessionMetadata = {
    ...meta,
    status: 'error',
    errorMessage: 'Session marked as zombie (>60m stale)',
    completedAt: new Date().toISOString(),
  };
  if (persist) {
    await fs.writeFile(metaPath(meta.id), JSON.stringify(updated, null, 2), 'utf8');
  }
  return updated;
}

async function markDeadBrowser(meta: SessionMetadata, { persist }: { persist: boolean }): Promise<SessionMetadata> {
  if (meta.status !== 'running' || meta.mode !== 'browser') {
    return meta;
  }
  const runtime = meta.browser?.runtime;
  if (!runtime) {
    return meta;
  }
  const signals: boolean[] = [];
  if (runtime.chromePid) {
    signals.push(isProcessAlive(runtime.chromePid));
  }
  if (runtime.chromePort) {
    const host = runtime.chromeHost ?? '127.0.0.1';
    signals.push(await isPortOpen(host, runtime.chromePort));
  }
  if (signals.length === 0 || signals.some(Boolean)) {
    return meta;
  }
  const response = meta.response
    ? {
        ...meta.response,
        status: 'error',
        incompleteReason: meta.response.incompleteReason ?? 'chrome-disconnected',
      }
    : { status: 'error', incompleteReason: 'chrome-disconnected' };
  const updated: SessionMetadata = {
    ...meta,
    status: 'error',
    errorMessage: 'Browser session ended (Chrome is no longer reachable)',
    completedAt: new Date().toISOString(),
    response,
  };
  if (persist) {
    await fs.writeFile(metaPath(meta.id), JSON.stringify(updated, null, 2), 'utf8');
  }
  return updated;
}

function isZombie(meta: SessionMetadata): boolean {
  if (meta.status !== 'running') {
    return false;
  }
  const reference = meta.startedAt ?? meta.createdAt;
  if (!reference) {
    return false;
  }
  const startedMs = Date.parse(reference);
  if (Number.isNaN(startedMs)) {
    return false;
  }
  return Date.now() - startedMs > ZOMBIE_MAX_AGE_MS;
}

function isProcessAlive(pid?: number): boolean {
  if (!pid) return false;
  try {
    process.kill(pid, 0);
    return true;
  } catch (error) {
    const code = error instanceof Error ? (error as NodeJS.ErrnoException).code : undefined;
    if (code === 'ESRCH' || code === 'EINVAL') {
      return false;
    }
    if (code === 'EPERM') {
      return true;
    }
    return true;
  }
}

async function isPortOpen(host: string, port: number): Promise<boolean> {
  if (!port || port <= 0 || port > 65535) {
    return false;
  }
  return new Promise((resolve) => {
    const socket = net.createConnection({ host, port });
    let settled = false;
    const cleanup = (result: boolean) => {
      if (settled) return;
      settled = true;
      socket.removeAllListeners();
      socket.end();
      socket.destroy();
      socket.unref();
      resolve(result);
    };
    const timer = setTimeout(() => cleanup(false), CHROME_RUNTIME_TIMEOUT_MS);
    socket.once('connect', () => {
      clearTimeout(timer);
      cleanup(true);
    });
    socket.once('error', () => {
      clearTimeout(timer);
      cleanup(false);
    });
  });
}


--- src/sessionStore.ts ---
import type { SessionMetadata, SessionNotifications, StoredRunOptions, SessionModelRun } from './sessionManager.js';
import {
  ensureSessionStorage,
  initializeSession,
  readSessionMetadata,
  updateSessionMetadata,
  createSessionLogWriter,
  readSessionLog,
  readModelLog,
  readSessionRequest,
  listSessionsMetadata,
  filterSessionsByRange,
  deleteSessionsOlderThan,
  updateModelRunMetadata,
  getSessionPaths,
  getSessionsDir,
} from './sessionManager.js';
type InitializeSessionOptionsType = Parameters<typeof initializeSession>[0];

export interface SessionStore {
  ensureStorage(): Promise<void>;
  createSession(
    options: InitializeSessionOptionsType,
    cwd: string,
    notifications?: SessionNotifications,
  ): Promise<SessionMetadata>;
  readSession(sessionId: string): Promise<SessionMetadata | null>;
  updateSession(sessionId: string, updates: Partial<SessionMetadata>): Promise<SessionMetadata>;
  createLogWriter(sessionId: string, model?: string): ReturnType<typeof createSessionLogWriter>;
  updateModelRun(sessionId: string, model: string, updates: Partial<SessionModelRun>): Promise<SessionModelRun>;
  readLog(sessionId: string): Promise<string>;
  readModelLog(sessionId: string, model: string): Promise<string>;
  readRequest(sessionId: string): Promise<StoredRunOptions | null>;
  listSessions(): Promise<SessionMetadata[]>;
  filterSessions(
    metas: SessionMetadata[],
    options: { hours?: number; includeAll?: boolean; limit?: number },
  ): ReturnType<typeof filterSessionsByRange>;
  deleteOlderThan(options?: { hours?: number; includeAll?: boolean }): Promise<{ deleted: number; remaining: number }>;
  getPaths(sessionId: string): Promise<{ dir: string; metadata: string; log: string; request: string }>;
  sessionsDir(): string;
}

class FileSessionStore implements SessionStore {
  ensureStorage(): Promise<void> {
    return ensureSessionStorage();
  }

  createSession(
    options: InitializeSessionOptionsType,
    cwd: string,
    notifications?: SessionNotifications,
  ): Promise<SessionMetadata> {
    return initializeSession(options, cwd, notifications);
  }

  readSession(sessionId: string): Promise<SessionMetadata | null> {
    return readSessionMetadata(sessionId);
  }

  updateSession(sessionId: string, updates: Partial<SessionMetadata>): Promise<SessionMetadata> {
    return updateSessionMetadata(sessionId, updates);
  }

  createLogWriter(sessionId: string, model?: string): ReturnType<typeof createSessionLogWriter> {
    return createSessionLogWriter(sessionId, model);
  }

  updateModelRun(sessionId: string, model: string, updates: Partial<SessionModelRun>): Promise<SessionModelRun> {
    return updateModelRunMetadata(sessionId, model, updates);
  }

  readLog(sessionId: string): Promise<string> {
    return readSessionLog(sessionId);
  }

  readModelLog(sessionId: string, model: string): Promise<string> {
    return readModelLog(sessionId, model);
  }

  readRequest(sessionId: string): Promise<StoredRunOptions | null> {
    return readSessionRequest(sessionId);
  }

  listSessions(): Promise<SessionMetadata[]> {
    return listSessionsMetadata();
  }

  filterSessions(
    metas: SessionMetadata[],
    options: { hours?: number; includeAll?: boolean; limit?: number },
  ): ReturnType<typeof filterSessionsByRange> {
    return filterSessionsByRange(metas, options);
  }

  deleteOlderThan(options?: { hours?: number; includeAll?: boolean }): Promise<{ deleted: number; remaining: number }> {
    return deleteSessionsOlderThan(options);
  }

  getPaths(sessionId: string): Promise<{ dir: string; metadata: string; log: string; request: string }> {
    return getSessionPaths(sessionId);
  }

  sessionsDir(): string {
    return getSessionsDir();
  }
}

export const sessionStore: SessionStore = new FileSessionStore();
export { wait } from './sessionManager.js';
export type {
  SessionMetadata,
  SessionMode,
  BrowserSessionConfig,
  BrowserRuntimeMetadata,
  SessionTransportMetadata,
  SessionUserErrorMetadata,
  SessionStatus,
  SessionModelRun,
} from './sessionManager.js';

export async function pruneOldSessions(
  hours?: number,
  log?: (message: string) => void,
): Promise<void> {
  if (typeof hours !== 'number' || Number.isNaN(hours) || hours <= 0) {
    return;
  }
  const result = await sessionStore.deleteOlderThan({ hours });
  if (result.deleted > 0) {
    log?.(`Pruned ${result.deleted} stored sessions older than ${hours}h.`);
  }
}


--- src/version.ts ---
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

let cachedVersion: string | null = null;

export function getCliVersion(): string {
  if (cachedVersion) {
    return cachedVersion;
  }
  cachedVersion = readVersionFromPackage();
  return cachedVersion;
}

function readVersionFromPackage(): string {
  const modulePath = fileURLToPath(import.meta.url);
  let currentDir = path.dirname(modulePath);
  const filesystemRoot = path.parse(currentDir).root;

  // biome-ignore lint/nursery/noUnnecessaryConditions: deliberate sentinel loop to walk up directories
  while (true) {
    const candidate = path.join(currentDir, 'package.json');
    try {
      const raw = readFileSync(candidate, 'utf8');
      const parsed = JSON.parse(raw) as { version?: unknown };
      const version =
        typeof parsed.version === 'string' && parsed.version.trim().length > 0
          ? parsed.version.trim()
          : '0.0.0';
      return version;
    } catch (error) {
      const code = error instanceof Error && 'code' in error ? (error as { code?: string }).code : undefined;
      if (code && code !== 'ENOENT') {
        break;
      }
    }
    if (currentDir === filesystemRoot) {
      break;
    }
    currentDir = path.dirname(currentDir);
  }
  return '0.0.0';
}


--- src/oracle/background.ts ---
import { APIConnectionError, APIConnectionTimeoutError } from 'openai';
import chalk from 'chalk';
import { formatElapsed } from './format.js';
import { startHeartbeat } from '../heartbeat.js';
import {
  OracleResponseError,
  OracleTransportError,
  describeTransportError,
  toTransportError,
} from './errors.js';
import type { ClientLike, OracleResponse, OracleRequestBody } from './types.js';

const BACKGROUND_MAX_WAIT_MS = 30 * 60 * 1000;
const BACKGROUND_POLL_INTERVAL_MS = 5000;
const BACKGROUND_RETRY_BASE_MS = 3000;
const BACKGROUND_RETRY_MAX_MS = 15000;

interface BackgroundExecutionParams {
  client: ClientLike;
  requestBody: OracleRequestBody;
  log: (message: string) => void;
  wait: (ms: number) => Promise<void>;
  heartbeatIntervalMs?: number;
  now: () => number;
  maxWaitMs: number;
}

export async function executeBackgroundResponse(params: BackgroundExecutionParams): Promise<OracleResponse> {
  const { client, requestBody, log, wait, heartbeatIntervalMs, now, maxWaitMs } = params;
  let initialResponse: OracleResponse;
  try {
    initialResponse = await client.responses.create(requestBody);
  } catch (error) {
    const transportError = toTransportError(error, requestBody.model);
    log(chalk.yellow(describeTransportError(transportError, maxWaitMs)));
    throw transportError;
  }
  if (!initialResponse || !initialResponse.id) {
    throw new OracleResponseError('API did not return a response ID for the background run.', initialResponse);
  }
  const responseId = initialResponse.id;
  log(
    chalk.dim(
      `API scheduled background response ${responseId} (status=${initialResponse.status ?? 'unknown'}). Monitoring up to ${Math.round(
        BACKGROUND_MAX_WAIT_MS / 60000,
      )} minutes for completion...`,
    ),
  );
  let heartbeatActive = false;
  let stopHeartbeat: (() => void) | null = null;
  const stopHeartbeatNow = () => {
    if (!heartbeatActive) return;
    heartbeatActive = false;
    stopHeartbeat?.();
    stopHeartbeat = null;
  };
  if (heartbeatIntervalMs && heartbeatIntervalMs > 0) {
    heartbeatActive = true;
    stopHeartbeat = startHeartbeat({
      intervalMs: heartbeatIntervalMs,
      log: (message) => log(message),
      isActive: () => heartbeatActive,
      makeMessage: (elapsedMs) => {
        const elapsedText = formatElapsed(elapsedMs);
        return `API background run still in progress — ${elapsedText} elapsed.`;
      },
    });
  }
  try {
    return await pollBackgroundResponse({
      client,
      responseId,
      initialResponse,
      log,
      wait,
      now,
      maxWaitMs,
    });
  } finally {
    stopHeartbeatNow();
  }
}

interface BackgroundPollParams {
  client: ClientLike;
  responseId: string;
  initialResponse: OracleResponse;
  log: (message: string) => void;
  wait: (ms: number) => Promise<void>;
  now: () => number;
  maxWaitMs: number;
}

async function pollBackgroundResponse(params: BackgroundPollParams): Promise<OracleResponse> {
  const { client, responseId, initialResponse, log, wait, now, maxWaitMs } = params;
  const startMark = now();
  let response = initialResponse;
  let firstCycle = true;
  let lastStatus: string | undefined = response.status;
  // biome-ignore lint/nursery/noUnnecessaryConditions: intentional polling loop.
  while (true) {
    const status = response.status ?? 'completed';
    // firstCycle toggles immediately; keep for clarity in logs.
    if (firstCycle) {
      firstCycle = false;
      log(chalk.dim(`API background response status=${status}. We'll keep retrying automatically.`));
    } else if (status !== lastStatus && status !== 'completed') {
      log(chalk.dim(`API background response status=${status}.`));
    }
    lastStatus = status;

    if (status === 'completed') {
      return response;
    }
    if (status !== 'in_progress' && status !== 'queued') {
      const detail = response.error?.message || response.incomplete_details?.reason || status;
      throw new OracleResponseError(`Response did not complete: ${detail}`, response);
    }
    if (now() - startMark >= maxWaitMs) {
      throw new OracleTransportError('client-timeout', 'Timed out waiting for API background response to finish.');
    }

    await wait(BACKGROUND_POLL_INTERVAL_MS);
    if (now() - startMark >= maxWaitMs) {
      throw new OracleTransportError('client-timeout', 'Timed out waiting for API background response to finish.');
    }
    const { response: nextResponse, reconnected } = await retrieveBackgroundResponseWithRetry({
      client,
      responseId,
      wait,
      now,
      maxWaitMs,
      startMark,
      log,
    });
    if (reconnected) {
      const nextStatus = nextResponse.status ?? 'in_progress';
      log(chalk.dim(`Reconnected to API background response (status=${nextStatus}). API is still working...`));
    }
    response = nextResponse;
  }
}

interface RetrieveRetryParams {
  client: ClientLike;
  responseId: string;
  wait: (ms: number) => Promise<void>;
  now: () => number;
  maxWaitMs: number;
  startMark: number;
  log: (message: string) => void;
}

async function retrieveBackgroundResponseWithRetry(
  params: RetrieveRetryParams,
): Promise<{ response: OracleResponse; reconnected: boolean }> {
  const { client, responseId, wait, now, maxWaitMs, startMark, log } = params;
  let retries = 0;
  // biome-ignore lint/nursery/noUnnecessaryConditions: intentional retry loop
  while (true) {
    try {
      const next = await client.responses.retrieve(responseId);
      return { response: next, reconnected: retries > 0 };
    } catch (error) {
      const transportError = asRetryableTransportError(error);
      if (!transportError) {
        throw error;
      }
      retries += 1;
      const delay = Math.min(BACKGROUND_RETRY_BASE_MS * 2 ** (retries - 1), BACKGROUND_RETRY_MAX_MS);
      log(chalk.yellow(`${describeTransportError(transportError, maxWaitMs)} Retrying in ${formatElapsed(delay)}...`));
      await wait(delay);
      if (now() - startMark >= maxWaitMs) {
        throw new OracleTransportError('client-timeout', 'Timed out waiting for API background response to finish.');
      }
    }
  }
}

function asRetryableTransportError(error: unknown): OracleTransportError | null {
  if (error instanceof OracleTransportError) {
    return error;
  }
  if (error instanceof APIConnectionError || error instanceof APIConnectionTimeoutError) {
    return toTransportError(error);
  }
  return null;
}


--- src/cli/browserConfig.ts ---
import fs from 'node:fs/promises';
import path from 'node:path';
import type { BrowserSessionConfig } from '../sessionStore.js';
import type { ModelName, ThinkingTimeLevel } from '../oracle.js';
import { CHATGPT_URL, DEFAULT_MODEL_STRATEGY, DEFAULT_MODEL_TARGET, isTemporaryChatUrl, normalizeChatgptUrl, parseDuration } from '../browserMode.js';
import { normalizeBrowserModelStrategy } from '../browser/modelStrategy.js';
import type { BrowserModelStrategy } from '../browser/types.js';
import type { CookieParam } from '../browser/types.js';
import { getOracleHomeDir } from '../oracleHome.js';

const DEFAULT_BROWSER_TIMEOUT_MS = 1_200_000;
const DEFAULT_BROWSER_INPUT_TIMEOUT_MS = 60_000;
const DEFAULT_CHROME_PROFILE = 'Default';

// Ordered array: most specific models first to ensure correct selection.
// The browser label is passed to the model picker which fuzzy-matches against ChatGPT's UI.
const BROWSER_MODEL_LABELS: [ModelName, string][] = [
  // Most specific first (e.g., "gpt-5.2-thinking" before "gpt-5.2")
  ['gpt-5.2-thinking', 'GPT-5.2 Thinking'],
  ['gpt-5.2-instant', 'GPT-5.2 Instant'],
  ['gpt-5.2-pro', 'GPT-5.2 Pro'],
  ['gpt-5.1-pro', 'GPT-5.2 Pro'],
  ['gpt-5-pro', 'GPT-5.2 Pro'],
  // Base models last (least specific)
  ['gpt-5.2', 'GPT-5.2'],       // Selects "Auto" in ChatGPT UI
  ['gpt-5.1', 'GPT-5.2'],       // Legacy alias → Auto
  ['gemini-3-pro', 'Gemini 3 Pro'],
];

export interface BrowserFlagOptions {
  browserChromeProfile?: string;
  browserChromePath?: string;
  browserCookiePath?: string;
  chatgptUrl?: string;
  browserUrl?: string;
  browserTimeout?: string;
  browserInputTimeout?: string;
  browserCookieWait?: string;
  browserNoCookieSync?: boolean;
  browserInlineCookiesFile?: string;
  browserCookieNames?: string;
  browserInlineCookies?: string;
  browserHeadless?: boolean;
  browserHideWindow?: boolean;
  browserKeepBrowser?: boolean;
  browserManualLogin?: boolean;
  browserManualLoginProfileDir?: string | null;
  /** Thinking time intensity: 'light', 'standard', 'extended', 'heavy' */
  browserThinkingTime?: ThinkingTimeLevel;
  browserModelLabel?: string;
  browserModelStrategy?: BrowserModelStrategy;
  browserAllowCookieErrors?: boolean;
  remoteChrome?: string;
  browserPort?: number;
  browserDebugPort?: number;
  model: ModelName;
  verbose?: boolean;
}

export function normalizeChatGptModelForBrowser(model: ModelName): ModelName {
  const normalized = model.toLowerCase() as ModelName;
  if (!normalized.startsWith('gpt-') || normalized.includes('codex')) {
    return model;
  }

  // Pro variants: always resolve to the latest Pro model in ChatGPT.
  if (normalized === 'gpt-5-pro' || normalized === 'gpt-5.1-pro' || normalized.endsWith('-pro')) {
    return 'gpt-5.2-pro';
  }

  // Explicit model variants: keep as-is (they have their own browser labels)
  if (normalized === 'gpt-5.2-thinking' || normalized === 'gpt-5.2-instant') {
    return normalized;
  }

  // Legacy aliases: map to base GPT-5.2 (Auto)
  if (normalized === 'gpt-5.1') {
    return 'gpt-5.2';
  }

  return model;
}

export async function buildBrowserConfig(options: BrowserFlagOptions): Promise<BrowserSessionConfig> {
  const desiredModelOverride = options.browserModelLabel?.trim();
  const normalizedOverride = desiredModelOverride?.toLowerCase() ?? '';
  const baseModel = options.model.toLowerCase();
  const isChatGptModel = baseModel.startsWith('gpt-') && !baseModel.includes('codex');
  const shouldUseOverride = !isChatGptModel && normalizedOverride.length > 0 && normalizedOverride !== baseModel;
  const modelStrategy =
    normalizeBrowserModelStrategy(options.browserModelStrategy) ?? DEFAULT_MODEL_STRATEGY;
  const cookieNames = parseCookieNames(options.browserCookieNames ?? process.env.ORACLE_BROWSER_COOKIE_NAMES);
  let inline = await resolveInlineCookies({
    inlineArg: options.browserInlineCookies,
    inlineFileArg: options.browserInlineCookiesFile,
    envPayload: process.env.ORACLE_BROWSER_COOKIES_JSON,
    envFile: process.env.ORACLE_BROWSER_COOKIES_FILE,
    cwd: process.cwd(),
  });
  if (inline?.source?.startsWith('home:') && options.browserNoCookieSync !== true) {
    inline = undefined;
  }

  let remoteChrome: { host: string; port: number } | undefined;
  if (options.remoteChrome) {
    remoteChrome = parseRemoteChromeTarget(options.remoteChrome);
  }
  const rawUrl = options.chatgptUrl ?? options.browserUrl;
  const url = rawUrl ? normalizeChatgptUrl(rawUrl, CHATGPT_URL) : undefined;

  const desiredModel = isChatGptModel
    ? mapModelToBrowserLabel(options.model)
    : shouldUseOverride
      ? desiredModelOverride
      : mapModelToBrowserLabel(options.model);

  if (modelStrategy === 'select' && url && isTemporaryChatUrl(url) && /\bpro\b/i.test(desiredModel ?? '')) {
    throw new Error(
      'Temporary Chat mode does not expose Pro models in the ChatGPT model picker. ' +
        'Remove "temporary-chat=true" from --chatgpt-url (or omit --chatgpt-url), or use a non-Pro model (e.g. --model gpt-5.2).',
    );
  }

  return {
    chromeProfile: options.browserChromeProfile ?? DEFAULT_CHROME_PROFILE,
    chromePath: options.browserChromePath ?? null,
    chromeCookiePath: options.browserCookiePath ?? null,
    url,
    debugPort: selectBrowserPort(options),
    timeoutMs: options.browserTimeout ? parseDuration(options.browserTimeout, DEFAULT_BROWSER_TIMEOUT_MS) : undefined,
    inputTimeoutMs: options.browserInputTimeout
      ? parseDuration(options.browserInputTimeout, DEFAULT_BROWSER_INPUT_TIMEOUT_MS)
      : undefined,
    cookieSyncWaitMs: options.browserCookieWait ? parseDuration(options.browserCookieWait, 0) : undefined,
    cookieSync: options.browserNoCookieSync ? false : undefined,
    cookieNames,
    inlineCookies: inline?.cookies,
    inlineCookiesSource: inline?.source ?? null,
    headless: undefined, // disable headless; Cloudflare blocks it
    keepBrowser: options.browserKeepBrowser ? true : undefined,
    manualLogin: options.browserManualLogin === undefined ? undefined : options.browserManualLogin,
    manualLoginProfileDir: options.browserManualLoginProfileDir ?? undefined,
    hideWindow: options.browserHideWindow ? true : undefined,
    desiredModel,
    modelStrategy,
    debug: options.verbose ? true : undefined,
    // Allow cookie failures by default so runs can continue without Chrome/Keychain secrets.
    allowCookieErrors: options.browserAllowCookieErrors ?? true,
    remoteChrome,
    thinkingTime: options.browserThinkingTime,
  };
}

function selectBrowserPort(options: BrowserFlagOptions): number | null {
  const candidate = options.browserPort ?? options.browserDebugPort;
  if (candidate === undefined || candidate === null) return null;
  if (!Number.isFinite(candidate) || candidate <= 0 || candidate > 65_535) {
    throw new Error(`Invalid browser port: ${candidate}. Expected a number between 1 and 65535.`);
  }
  return candidate;
}

export function mapModelToBrowserLabel(model: ModelName): string {
  const normalized = normalizeChatGptModelForBrowser(model);
  // Iterate ordered array to find first match (most specific first)
  for (const [key, label] of BROWSER_MODEL_LABELS) {
    if (key === normalized) {
      return label;
    }
  }
  return DEFAULT_MODEL_TARGET;
}

export function resolveBrowserModelLabel(input: string | undefined, model: ModelName): string {
  const trimmed = input?.trim?.() ?? '';
  if (!trimmed) {
    return mapModelToBrowserLabel(model);
  }
  const normalizedInput = trimmed.toLowerCase();
  if (normalizedInput === model.toLowerCase()) {
    return mapModelToBrowserLabel(model);
  }
  return trimmed;
}

function parseRemoteChromeTarget(raw: string): { host: string; port: number } {
  const target = raw.trim();
  if (!target) {
    throw new Error('Invalid remote-chrome value: expected host:port but received an empty string.');
  }

  const ipv6Match = target.match(/^\[(.+)]:(\d+)$/);
  let host: string | undefined;
  let portSegment: string | undefined;

  if (ipv6Match) {
    host = ipv6Match[1]?.trim();
    portSegment = ipv6Match[2]?.trim();
  } else {
    const lastColon = target.lastIndexOf(':');
    if (lastColon === -1) {
      throw new Error(
        `Invalid remote-chrome format: ${target}. Expected host:port (IPv6 must use [host]:port notation).`
      );
    }
    host = target.slice(0, lastColon).trim();
    portSegment = target.slice(lastColon + 1).trim();
    if (host.includes(':')) {
      throw new Error(
        `Invalid remote-chrome format: ${target}. Wrap IPv6 addresses in brackets, e.g. --remote-chrome "[2001:db8::1]:9222".`
      );
    }
  }

  if (!host) {
    throw new Error(
      `Invalid remote-chrome format: ${target}. Host portion is missing; expected host:port.`
    );
  }
  const port = Number.parseInt(portSegment ?? '', 10);
  if (!Number.isFinite(port) || port <= 0 || port > 65_535) {
    throw new Error(
      `Invalid remote-chrome port: "${portSegment ?? ''}". Expected a number between 1 and 65535.`
    );
  }
  return { host, port };
}

function parseCookieNames(raw?: string | null): string[] | undefined {
  if (!raw) return undefined;
  const names = raw
    .split(',')
    .map((entry) => entry.trim())
    .filter(Boolean);
  return names.length ? names : undefined;
}

async function resolveInlineCookies({
  inlineArg,
  inlineFileArg,
  envPayload,
  envFile,
  cwd,
}: {
  inlineArg?: string | null;
  inlineFileArg?: string | null;
  envPayload?: string | null;
  envFile?: string | null;
  cwd: string;
}): Promise<{ cookies: CookieParam[]; source: string } | undefined> {
  const tryLoad = async (source: string | undefined | null, allowPathResolution: boolean) => {
    if (!source) return undefined;
    const trimmed = source.trim();
    if (!trimmed) return undefined;
    if (allowPathResolution) {
      const resolved = path.isAbsolute(trimmed) ? trimmed : path.join(cwd, trimmed);
      try {
        const stat = await fs.stat(resolved);
        if (stat.isFile()) {
          const fileContent = await fs.readFile(resolved, 'utf8');
          const parsed = parseInlineCookiesPayload(fileContent);
          if (parsed) return parsed;
        }
      } catch {
        // not a file; treat as payload below
      }
    }
    return parseInlineCookiesPayload(trimmed);
  };

  const sources = [
    { value: inlineFileArg, allowPath: true, source: 'inline-file' },
    { value: inlineArg, allowPath: true, source: 'inline-arg' },
    { value: envFile, allowPath: true, source: 'env-file' },
    { value: envPayload, allowPath: false, source: 'env-payload' },
  ];

  for (const { value, allowPath, source } of sources) {
    const parsed = await tryLoad(value, allowPath);
    if (parsed) return { cookies: parsed, source };
  }

  // fallback: ~/.oracle/cookies.{json,base64}
  const oracleHome = getOracleHomeDir();
  const candidates = ['cookies.json', 'cookies.base64'];
  for (const file of candidates) {
    const fullPath = path.join(oracleHome, file);
    try {
      const stat = await fs.stat(fullPath);
      if (!stat.isFile()) continue;
      const content = await fs.readFile(fullPath, 'utf8');
      const parsed = parseInlineCookiesPayload(content);
      if (parsed) return { cookies: parsed, source: `home:${file}` };
    } catch {
      // ignore missing/invalid
    }
  }
  return undefined;
}

function parseInlineCookiesPayload(raw?: string | null): CookieParam[] | undefined {
  if (!raw) return undefined;
  const text = raw.trim();
  if (!text) return undefined;
  let jsonPayload = text;
  // Attempt base64 decode first; fall back to raw text on failure.
  try {
    const decoded = Buffer.from(text, 'base64').toString('utf8');
    if (decoded.trim().startsWith('[')) {
      jsonPayload = decoded;
    }
  } catch {
    // not base64; continue with raw text
  }
  try {
    const parsed = JSON.parse(jsonPayload) as unknown;
    if (Array.isArray(parsed)) {
      return parsed as CookieParam[];
    }
  } catch {
    // invalid json; skip silently to keep this hidden flag non-fatal
  }
  return undefined;
}


--- tests/config.test.ts ---
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { loadUserConfig } from '../src/config.js';
import { setOracleHomeDirOverrideForTest } from '../src/oracleHome.js';

describe('loadUserConfig', () => {
  let tempDir: string;

  beforeEach(async () => {
    tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'oracle-config-'));
    setOracleHomeDirOverrideForTest(tempDir);
  });

  it('parses JSON5 config with comments', async () => {
    const configPath = path.join(tempDir, 'config.json');
    await fs.writeFile(
      configPath,
      `// comment\n{
        engine: "browser",
        notify: { sound: true },
        heartbeatSeconds: 15,
        remote: { host: "host:1234", token: "abc" },
      }`,
      'utf8',
    );

    const result = await loadUserConfig();
    expect(result.loaded).toBe(true);
    expect(result.config.engine).toBe('browser');
    expect(result.config.notify?.sound).toBe(true);
    expect(result.config.heartbeatSeconds).toBe(15);
    expect(result.config.remote?.host).toBe('host:1234');
    expect(result.config.remote?.token).toBe('abc');
  });

  it('supports top-level remoteHost/remoteToken aliases', async () => {
    const configPath = path.join(tempDir, 'config.json');
    await fs.writeFile(
      configPath,
      `{
        remoteHost: "alias:9999",
        remoteToken: "secret"
      }`,
      'utf8',
    );

    const result = await loadUserConfig();
    expect(result.loaded).toBe(true);
    expect(result.config.remoteHost).toBe('alias:9999');
    expect(result.config.remoteToken).toBe('secret');
  });

  it('returns empty config when file is missing', async () => {
    const result = await loadUserConfig();
    expect(result.loaded).toBe(false);
    expect(result.config).toEqual({});
  });

  afterAll(() => {
    setOracleHomeDirOverrideForTest(null);
  });
});


--- tests/engine.test.ts ---
import { describe, expect, it } from 'vitest';
import { resolveEngine, defaultWaitPreference, type EngineMode } from '../src/cli/engine.js';

// biome-ignore lint/style/useNamingConvention: env var names are uppercase with underscores
const envWithKey = { ...process.env, OPENAI_API_KEY: 'sk-test' } as NodeJS.ProcessEnv;
const envWithoutKey = { ...process.env } as NodeJS.ProcessEnv;
delete envWithoutKey.OPENAI_API_KEY;

describe('resolveEngine', () => {
  it('prefers api when no flags and OPENAI_API_KEY is set', () => {
    const engine = resolveEngine({ engine: undefined, browserFlag: false, env: envWithKey });
    expect(engine).toBe<EngineMode>('api');
  });

  it('falls back to browser when no flags and no OPENAI_API_KEY', () => {
    const engine = resolveEngine({ engine: undefined, browserFlag: false, env: envWithoutKey });
    expect(engine).toBe<EngineMode>('browser');
  });

  it('respects explicit --engine api even without OPENAI_API_KEY', () => {
    const engine = resolveEngine({ engine: 'api', browserFlag: false, env: envWithoutKey });
    expect(engine).toBe<EngineMode>('api');
  });

  it('lets legacy --browser override everything', () => {
    const engine = resolveEngine({ engine: 'api', browserFlag: true, env: envWithKey });
    expect(engine).toBe<EngineMode>('browser');
  });
});

describe('defaultWaitPreference', () => {
  it('disables wait for pro API runs', () => {
    expect(defaultWaitPreference('gpt-5.2-pro', 'api')).toBe(false);
  });

  it('keeps wait enabled for Codex and browser models', () => {
    expect(defaultWaitPreference('gpt-5.1-codex', 'api')).toBe(true);
    expect(defaultWaitPreference('gpt-5.2-pro', 'browser')).toBe(true);
  });
});


--- tests/gemini.test.ts ---
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createGeminiClient, resolveGeminiModelId } from '../src/oracle/gemini.js';
import type { OracleRequestBody } from '../src/oracle.js';
import { GoogleGenAI } from '@google/genai';

const { mockGenerateContent, mockGenerateContentStream } = vi.hoisted(() => {
  const mockGenerateContent = vi.fn();
  const mockGenerateContentStream = vi.fn();
  return { mockGenerateContent, mockGenerateContentStream };
});

vi.mock('@google/genai', () => {
  const MOCK_GOOGLE_GENAI = vi.fn().mockImplementation(function GoogleGenAIMock() {
    return {
      models: {
        generateContent: mockGenerateContent,
        generateContentStream: mockGenerateContentStream,
      },
    };
  });

  return {
    // biome-ignore lint/style/useNamingConvention: keep SDK casing
    GoogleGenAI: MOCK_GOOGLE_GENAI,
    // biome-ignore lint/style/useNamingConvention: keep SDK casing
    HarmCategory: {
      // biome-ignore lint/style/useNamingConvention: keep SDK casing
      HARM_CATEGORY_HARASSMENT: 'HARM_CATEGORY_HARASSMENT',
      // biome-ignore lint/style/useNamingConvention: keep SDK casing
      HARM_CATEGORY_HATE_SPEECH: 'HARM_CATEGORY_HATE_SPEECH',
      // biome-ignore lint/style/useNamingConvention: keep SDK casing
      HARM_CATEGORY_SEXUALLY_EXPLICIT: 'HARM_CATEGORY_SEXUALLY_EXPLICIT',
      // biome-ignore lint/style/useNamingConvention: keep SDK casing
      HARM_CATEGORY_DANGEROUS_CONTENT: 'HARM_CATEGORY_DANGEROUS_CONTENT',
    },
    // biome-ignore lint/style/useNamingConvention: keep SDK casing
    HarmBlockThreshold: {
      // biome-ignore lint/style/useNamingConvention: keep SDK casing
      BLOCK_NONE: 'BLOCK_NONE',
    },
  };
});

describe('Gemini Client', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it('initializes with the correct model', () => {
    createGeminiClient('fake-key');
    expect(GoogleGenAI).toHaveBeenCalledWith({ apiKey: 'fake-key' });
    expect(mockGenerateContent).not.toHaveBeenCalled();
  });

  it('maps 3-pro through resolver', () => {
    expect(resolveGeminiModelId('gemini-3-pro')).toBe('gemini-3-pro-preview');
  });

  it('adapts create request correctly', async () => {
    const client = createGeminiClient('fake-key');
    const mockResponse = {
      candidates: [
        {
          content: {
            parts: [{ text: 'Gemini response' }],
          },
        },
      ],
      usageMetadata: {
        promptTokenCount: 10,
        candidatesTokenCount: 20,
      },
    };
    mockGenerateContent.mockResolvedValue(mockResponse);

    const requestBody: OracleRequestBody = {
      model: 'gemini-3-pro',
      instructions: 'System prompt',
      input: [
        {
          role: 'user',
          content: [{ type: 'input_text', text: 'User prompt' }],
        },
      ],
      max_output_tokens: 100,
    };

    const result = await client.responses.create(requestBody);

    expect(mockGenerateContent).toHaveBeenCalledWith({
      model: 'gemini-3-pro-preview',
      contents: [
        {
          role: 'user',
          parts: [{ text: 'User prompt' }],
        },
      ],
      config: {
        systemInstruction: { role: 'system', parts: [{ text: 'System prompt' }] },
        tools: undefined,
        maxOutputTokens: 100,
        safetySettings: expect.any(Array),
      },
    });

    expect(result).toEqual({
      id: expect.stringMatching(/^gemini-/),
      status: 'completed',
      output_text: ['Gemini response'],
      output: [{ type: 'text', text: 'Gemini response' }],
      usage: {
        input_tokens: 10,
        output_tokens: 20,
        total_tokens: 30,
      },
    });
  });

  it('adapts streaming request correctly', async () => {
    const client = createGeminiClient('fake-key');
    
    const mockStream = async function* () {
      yield { text: 'Chunk 1 A' };
      yield { text: 'Chunk 2', usageMetadata: { promptTokenCount: 5, candidatesTokenCount: 5 }, responseId: 'resp-123' };
    };

    mockGenerateContentStream.mockResolvedValue(mockStream());

    const requestBody: OracleRequestBody = {
      model: 'gemini-3-pro',
      instructions: 'System',
      input: [
        {
          role: 'user',
          content: [{ type: 'input_text', text: 'Stream me' }],
        },
      ],
      tools: [{ type: 'web_search_preview' }],
    };

    const stream = await client.responses.stream(requestBody);
    const chunks: string[] = [];

    for await (const event of stream) {
      if (event.type === 'chunk' && event.delta) {
        chunks.push(event.delta);
      }
    }

    expect(chunks).toEqual(['Chunk 1 A', 'Chunk 2']);

    expect(mockGenerateContentStream).toHaveBeenCalledWith(expect.objectContaining({
      config: expect.objectContaining({ tools: [{ googleSearch: {} }] }),
    }));
    
    const final = await stream.finalResponse();
    expect(final).toMatchObject({
      id: 'resp-123',
      usage: {
        input_tokens: 5,
        output_tokens: 5,
        total_tokens: 10,
      },
      output_text: ['Chunk 1 AChunk 2'],
    });
  });

  it('maps web_search_preview to googleSearch tool and keeps safety settings', async () => {
    const client = createGeminiClient('fake-key');
    mockGenerateContent.mockResolvedValue({
      candidates: [],
      usageMetadata: {},
    });

    const requestBody: OracleRequestBody = {
      model: 'gemini-3-pro',
      instructions: '',
      input: [
        {
          role: 'user',
          content: [{ type: 'input_text', text: 'search please' }],
        },
      ],
      tools: [{ type: 'web_search_preview' }],
    };

    await client.responses.create(requestBody);

    const call = mockGenerateContent.mock.calls[0]?.[0];
    expect(call?.config?.tools).toEqual([{ googleSearch: {} }]);
    expect(call?.config?.safetySettings).toEqual([
      { category: 'HARM_CATEGORY_HARASSMENT', threshold: 'BLOCK_NONE' },
      { category: 'HARM_CATEGORY_HATE_SPEECH', threshold: 'BLOCK_NONE' },
      { category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', threshold: 'BLOCK_NONE' },
      { category: 'HARM_CATEGORY_DANGEROUS_CONTENT', threshold: 'BLOCK_NONE' },
    ]);
  });

  it('prefers explicitly resolved model id when provided', async () => {
    const client = createGeminiClient('fake-key', 'gemini-3-pro', 'custom-model-id');
    mockGenerateContent.mockResolvedValue({ candidates: [], usageMetadata: {} });

    await client.responses.create({
      model: 'gemini-3-pro',
      instructions: '',
      input: [
        {
          role: 'user',
          content: [{ type: 'input_text', text: 'hi' }],
        },
      ],
    });

    expect(mockGenerateContent).toHaveBeenCalledWith(
      expect.objectContaining({ model: 'custom-model-id' }),
    );
  });

  it('returns finalResponse even when not iterated', async () => {
    const client = createGeminiClient('fake-key');

    const mockStream = async function* () {
      yield { text: 'Only chunk', responseId: 'resp-999', usageMetadata: { promptTokenCount: 2, candidatesTokenCount: 3 } };
    };

    mockGenerateContentStream.mockResolvedValue(mockStream());

    const requestBody: OracleRequestBody = {
      model: 'gemini-3-pro',
      instructions: '',
      input: [
        {
          role: 'user',
          content: [{ type: 'input_text', text: 'Ping' }],
        },
      ],
    };

    const stream = await client.responses.stream(requestBody);
    const final = await stream.finalResponse();

    expect(final).toEqual({
      id: 'resp-999',
      status: 'completed',
      output_text: ['Only chunk'],
      output: [{ type: 'text', text: 'Only chunk' }],
      usage: { input_tokens: 2, output_tokens: 3, total_tokens: 5 },
    });
  });

  it('includes system prompt even when empty tools array is provided', async () => {
    const client = createGeminiClient('fake-key');
    mockGenerateContent.mockResolvedValue({
      response: {
        candidates: [{ content: { parts: [{ text: 'ok' }] } }],
        usageMetadata: {},
      },
    });

    const requestBody: OracleRequestBody = {
      model: 'gemini-3-pro',
      instructions: 'Sys',
      input: [
        {
          role: 'user',
          content: [{ type: 'input_text', text: 'Ping' }],
        },
      ],
      tools: [],
    };

    await client.responses.create(requestBody);
    expect(mockGenerateContent.mock.calls[0]?.[0]).toMatchObject({
      config: { systemInstruction: { role: 'system', parts: [{ text: 'Sys' }] }, tools: [] },
    });
  });
});


--- tests/logging.test.ts ---
import { describe, expect, test } from 'vitest';
import { formatBaseUrlForLog, maskApiKey } from '../src/oracle/logging.js';

describe('maskApiKey', () => {
  test('masks long keys with first/last 4 chars', () => {
    expect(maskApiKey('sk-abcdef1234567890')).toBe('sk-a****7890');
  });

  test('handles short keys gracefully', () => {
    expect(maskApiKey('abc')).toBe('a***c');
    expect(maskApiKey(undefined)).toBeNull();
    expect(maskApiKey(null)).toBeNull();
  });
});

describe('formatBaseUrlForLog', () => {
  test('redacts credentials, deep paths, and query values', () => {
    const formatted = formatBaseUrlForLog(
      'https://user:pass@proxy.test/v1/deep/path?api-version=2024-10-01&token=secret',
    );
    expect(formatted).toBe('https://proxy.test/v1/...?api-version=***');
  });

  test('truncates unparseable strings', () => {
    const formatted = formatBaseUrlForLog('not a url but extremely long'.repeat(4));
    expect(formatted.startsWith('not a url but extremely longnot ')).toBe(true);
    expect(formatted.includes('…')).toBe(true);
    expect(formatted.endsWith('ely long')).toBe(true);
    expect(formatted.length).toBeLessThan(80);
  });
});


--- tests/mcp.integration.test.ts ---
import { beforeAll, afterAll, describe, expect, it } from 'vitest';
import { spawn } from 'node:child_process';
import path from 'node:path';
import { once } from 'node:events';

function startOracleMcp(): { proc: ReturnType<typeof spawn>; waitReady: () => Promise<void> } {
  const entry = path.join(process.cwd(), 'dist/bin/oracle-mcp.js');
  const proc = spawn(process.execPath, [entry], { stdio: ['pipe', 'pipe', 'pipe'] });
  const waitReady = async () => {
    // Give the stdio transport a moment to attach; MCP stdio has no explicit ready signal.
    await new Promise((resolve) => setTimeout(resolve, 200));
  };
  return { proc, waitReady };
}

describe('oracle-mcp stdio smoke', () => {
  let proc: ReturnType<typeof spawn>;

  beforeAll(async () => {
    // @ts-expect-error built artifact has no d.ts
    await import('../dist/bin/oracle-mcp.js'); // ensure built artifacts exist
    const started = startOracleMcp();
    proc = started.proc;
    await started.waitReady();
  }, 30_000);

  afterAll(async () => {
    if (proc) {
      const exitPromise = once(proc, 'exit');
      proc.kill('SIGTERM');
      await Promise.race([exitPromise, new Promise((resolve) => setTimeout(resolve, 1000))]);
    }
  });

  it('exposes stdio (process stays alive)', () => {
    expect(proc.killed).toBe(false);
    expect(proc.pid).toBeDefined();
  });
});


--- tests/mcp.resources.test.ts ---
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { spawn } from 'node:child_process';
import { once } from 'node:events';
import path from 'node:path';

async function callResource(proc: ReturnType<typeof spawn>, method: string, id: number, params: object) {
  if (!proc.stdin || !proc.stdout) {
    throw new Error('stdio unavailable');
  }
  // Simple one-request/one-response framing over stdio JSON-RPC.
  const req = `${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`;
  proc.stdin.write(req);
  const [data] = (await once(proc.stdout, 'data')) as [Buffer];
  const parsed = JSON.parse(data.toString());
  return parsed;
}

describe('oracle-session resources via stdio', () => {
  let proc: ReturnType<typeof spawn>;

  beforeAll(async () => {
    const entry = path.join(process.cwd(), 'dist/bin/oracle-mcp.js');
    proc = spawn(process.execPath, [entry], { stdio: ['pipe', 'pipe', 'pipe'] });
    await new Promise((resolve) => setTimeout(resolve, 200)); // give stdio transport time
  }, 10_000);

  afterAll(async () => {
    if (proc) {
      proc.kill('SIGTERM');
      await Promise.race([once(proc, 'exit'), new Promise((r) => setTimeout(r, 500))]);
    }
  });

  it('responds to resource/read (metadata)', async () => {
    const res = await callResource(proc, 'resource/read', 1, { uri: 'oracle-session://nonexistent/metadata' });
    expect(res.error?.message || res.result?.contents).toBeDefined();
  }, 15_000);
});


--- tests/mcp.schema.test.ts ---
import { beforeAll, afterAll, describe, expect, it } from 'vitest';
import path from 'node:path';
import type { ChildProcess } from 'node:child_process';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';

const entry = path.join(process.cwd(), 'dist/bin/oracle-mcp.js');

describe('oracle-mcp schemas', () => {
  let client: Client | null = null;
  let transport: StdioClientTransport | null = null;
  const stderrLog: string[] = [];
  const exitLog: string[] = [];
  const parsedMessages: unknown[] = [];
  const parseErrors: string[] = [];
  let stdoutRemainder = '';

  // Keep the daemon quiet and skip optional native deps in CI.
  process.env.ORACLE_DISABLE_KEYTAR = '1';

  const attachStderr = (proc: ChildProcess | undefined): void => {
    proc?.stderr?.on('data', (chunk) => stderrLog.push(String(chunk)));
    proc?.stdout?.on('data', (chunk) => {
      exitLog.push(`stdout: ${String(chunk)}`);
      stdoutRemainder += String(chunk);
      const lines = stdoutRemainder.split('\n');
      stdoutRemainder = lines.pop() ?? '';
      for (const line of lines) {
        if (!line.trim()) continue;
        try {
          const msg = JSON.parse(line);
          parsedMessages.push(msg);
        } catch (error) {
          parseErrors.push(`parse error for line "${line}": ${String(error)}`);
        }
      }
    });
    proc?.on('exit', (code, signal) => exitLog.push(`exit ${code ?? 'null'} signal ${signal ?? 'null'}`));
    proc?.on('error', (err) => exitLog.push(`error ${String(err)}`));
  };

  beforeAll(async () => {
    let lastError: unknown;
    for (let attempt = 0; attempt < 5; attempt += 1) {
      const candidateClient = new Client({ name: 'schema-smoke', version: '0.0.0' });
      const candidateTransport = new StdioClientTransport({
        command: process.execPath,
        args: [entry],
        stderr: 'pipe',
        cwd: path.dirname(entry),
        env: {
          ...process.env,
          // biome-ignore lint/style/useNamingConvention: environment variables stay upper snake case
          'ORACLE_DISABLE_KEYTAR': '1',
        },
      });
      try {
        await candidateClient.connect(candidateTransport);
        const proc = (candidateTransport as unknown as { proc?: ChildProcess }).proc;
        attachStderr(proc);
        client = candidateClient;
        transport = candidateTransport;
        return;
      } catch (error) {
        lastError = error;
        const proc = (candidateTransport as unknown as { proc?: ChildProcess }).proc;
        attachStderr(proc);
        proc?.kill?.('SIGKILL');
        await candidateClient.close().catch(() => {});
        await new Promise((resolve) => setTimeout(resolve, 500));
      }
    }
    const detail = [...stderrLog, ...exitLog].join('') || String(lastError);
    throw new Error(`oracle-mcp failed to start: ${detail}`);
  }, 20_000);

  afterAll(async () => {
    await client?.close().catch(() => {});
    const proc = (transport as unknown as { proc?: ChildProcess })?.proc;
    proc?.kill?.('SIGKILL');
  });

  it('exposes object schemas for tools', async () => {
    if (!client) throw new Error('MCP client not connected');
    const { tools } = await client.listTools({}, { timeout: 10_000 });
    expect(tools.length).toBeGreaterThan(0);
    for (const tool of tools) {
      for (const schema of [tool.inputSchema, tool.outputSchema]) {
        if (!schema) continue;
        expect(schema.type).toBe('object');
      }
    }
  });

  it('emits only JSON-RPC lines on stdout during handshake', async () => {
    // Give the transport a moment to flush handshake traffic.
    await new Promise((resolve) => setTimeout(resolve, 200));
    expect(parseErrors).toHaveLength(0);
    if (parsedMessages.length > 0) {
      for (const msg of parsedMessages) {
        expect((msg as { jsonrpc?: unknown }).jsonrpc).toBe('2.0');
      }
    }
    expect(stdoutRemainder.trim()).toBe('');
  });
});


--- tests/mcp.stdout.test.ts ---
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { spawn } from 'node:child_process';
import path from 'node:path';
import { once } from 'node:events';

describe('oracle-mcp stdout hygiene', () => {
  let proc: ReturnType<typeof spawn>;
  const stdoutChunks: string[] = [];

  beforeAll(async () => {
    const entry = path.join(process.cwd(), 'dist/bin/oracle-mcp.js');
    proc = spawn(process.execPath, [entry], { stdio: ['pipe', 'pipe', 'pipe'] });
    proc.stdout?.on('data', (chunk) => stdoutChunks.push(String(chunk)));
    // give the process time to start; we deliberately do not send any input
    await new Promise((resolve) => setTimeout(resolve, 300));
  }, 10_000);

  afterAll(async () => {
    if (proc) {
      proc.kill('SIGTERM');
      await Promise.race([once(proc, 'exit'), new Promise((r) => setTimeout(r, 500))]);
    }
  });

  it('does not emit non-JSON noise on startup', () => {
    const combined = stdoutChunks.join('').trim();
    expect(combined).toBe('');
  });
});


--- tests/mcp.test.ts ---
import { describe, it, expect } from 'vitest';
import { mapConsultToRunOptions } from '../src/mcp/utils.js';

describe('mcp utils', () => {
  it('maps api defaults', () => {
    const { runOptions, resolvedEngine } = mapConsultToRunOptions({ prompt: 'hi', files: [], model: 'gpt-5.2-pro', engine: 'api' });
    expect(resolvedEngine).toBe('api');
    expect(runOptions.model).toBe('gpt-5.2-pro');
  });

  it('infers browser labels', () => {
    const { runOptions, resolvedEngine } = mapConsultToRunOptions({ prompt: 'hi', files: [], model: '5.1 instant', engine: 'browser' });
    expect(resolvedEngine).toBe('browser');
    expect(runOptions.model).toBe('gpt-5.2');
  });
});


--- tests/notifier.test.ts ---
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { resolveNotificationSettings, testHelpers, sendSessionNotification } from '../src/cli/notifier.js';
import type { NotificationContent, NotificationSettings } from '../src/cli/notifier.js';

vi.mock('toasted-notifier', () => ({ default: { notify: vi.fn(async () => undefined) } }));
vi.mock('node:child_process', () => ({
  spawn: vi.fn(() => ({
    on: (event: string, cb: (exit?: number) => void) => {
      if (event === 'exit') cb(0);
    },
  })),
}));

const baseEnv = { ...process.env };

async function getMocks() {
  const notifier = (await import('toasted-notifier')).default as { notify: ReturnType<typeof vi.fn> };
  const { spawn } = await import('node:child_process');
  return { notifier, spawn: spawn as unknown as ReturnType<typeof vi.fn> };
}

beforeEach(async () => {
  const { notifier, spawn } = await getMocks();
  notifier.notify.mockClear();
  spawn.mockClear?.();
});

afterEach(() => {
  vi.restoreAllMocks();
  for (const key of Object.keys(process.env)) {
    if (!(key in baseEnv)) {
      delete process.env[key];
    }
  }
  Object.assign(process.env, baseEnv);
});

describe('resolveNotificationSettings', () => {
  it('defaults to enabled when not in CI or SSH', () => {
    const result = resolveNotificationSettings({ cliNotify: undefined, cliNotifySound: undefined, env: {} });
    expect(result.enabled).toBe(true);
    expect(result.sound).toBe(false);
  });

  it('disables by default in CI', () => {
    // biome-ignore lint/style/useNamingConvention: environment variable name
    const result = resolveNotificationSettings({ cliNotify: undefined, cliNotifySound: undefined, env: { CI: '1' } });
    expect(result.enabled).toBe(false);
  });

  it('honors explicit CLI override', () => {
    // biome-ignore lint/style/useNamingConvention: environment variable name
    const result = resolveNotificationSettings({ cliNotify: true, cliNotifySound: true, env: { CI: '1' } });
    expect(result.enabled).toBe(true);
    expect(result.sound).toBe(true);
  });

  it('parses env toggles', () => {
    // biome-ignore lint/style/useNamingConvention: environment variable name
    const result = resolveNotificationSettings({ cliNotify: undefined, cliNotifySound: undefined, env: { ORACLE_NOTIFY: 'off' } });
    expect(result.enabled).toBe(false);
  });

  it('sanitizes and truncates previews to 200 characters', () => {
    const longPreview = `\`code\` ${'a'.repeat(300)}`;
    const sanitized = testHelpers.sanitizePreview(longPreview);
    expect(sanitized).toBeDefined();
    expect(sanitized?.length).toBe(200);
    expect(sanitized?.includes('code')).toBe(true);
    expect(sanitized?.endsWith('…')).toBe(true);
  });

  it('sends notifications in non-test envs and sanitizes output', async () => {
    // Allow notifications by clearing test env markers.
    delete process.env.VITEST;
    delete process.env.VITEST_WORKER_ID;
    delete process.env.JEST_WORKER_ID;
    process.env.NODE_ENV = 'development';
    process.env.ORACLE_DISABLE_NOTIFICATIONS = '0';
    vi.spyOn(process, 'platform', 'get').mockReturnValue('linux' as NodeJS.Platform);

    const payload: NotificationContent = {
      sessionId: 'sess-1',
      sessionName: 'demo run',
      mode: 'api',
      model: 'gpt-5.1',
      usage: { inputTokens: 1000, outputTokens: 500 },
      characters: 1500,
    };
    const settings: NotificationSettings = { enabled: true, sound: false };
    const log = vi.fn();

    await sendSessionNotification(payload, settings, log, 'Preview **bold** with `code` and a [link](https://x.test)');

    const { notifier } = await getMocks();
    expect(notifier.notify).toHaveBeenCalledTimes(1);
    const call = notifier.notify.mock.calls[0]?.[0];
    expect(call?.title).toContain('Oracle');
    expect(call?.message).toContain('demo run');
    expect(call?.message).toContain('chars');
    expect(call?.message).not.toContain('**');
    expect(call?.message).not.toContain('`code`');
    expect(call?.sound).toBe(false);
    expect(log).not.toHaveBeenCalled();
  });
});


## Links discovered
- [link](https://x.test)

--- types/oracle.d.ts ---
declare module '../src/oracle.js' {
  export function buildPrompt(...args: unknown[]): string;
  export function runOracle(...args: unknown[]): Promise<any>;
  export function renderPromptMarkdown(...args: unknown[]): Promise<string>;
}


--- types/pty.d.ts ---
declare module '@cdktf/node-pty-prebuilt-multiarch';
declare module '@homebridge/node-pty-prebuilt-multiarch';


--- types/toasted-notifier.d.ts ---
declare module 'toasted-notifier' {
  export interface ToastOptions {
    title?: string;
    subtitle?: string;
    message: string;
    sound?: boolean | string;
    icon?: string;
    wait?: boolean;
    appID?: string;
    timeout?: number;
    closeLabel?: string;
    actions?: string[];
    reply?: boolean;
    open?: string;
    id?: string;
    suppressOSD?: boolean;
  }

  const notifier: {
    notify(options: ToastOptions): Promise<void> | void;
  };

  export default notifier;
}
