Compare commits

...

21 Commits

Author SHA1 Message Date
Nikhil Sonti
71cb158739 feat(eval): add parallel workers support to showcase generator
Add --workers/-w flag for parallel task execution. Each worker gets
its own isolated BrowserOS stack on offset ports, matching the eval
ParallelExecutor pattern. Also uses env-based defaults for model/
provider/baseUrl (SHOWCASE_MODEL, SHOWCASE_PROVIDER, SHOWCASE_BASE_URL)
and reads API key from OPENROUTER_API_KEY.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 17:16:04 -07:00
Nikhil Sonti
654a871841 fix(eval): improve crosshair visibility, fix page ID resolution, add prod tasks
- Crosshair: 2.5x larger (20px ring, 40px lines, 3px stroke), glow
  filter, semi-transparent fill circle, bigger label
- Page ID: add resolvePageId() helper that falls back to first
  available page when the agent's page reference is stale
- Add prod-tasks.jsonl with 20 real-world queries covering finance,
  legal, healthcare, tech, real estate, HR, and more

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 17:14:40 -07:00
Nikhil Sonti
ff167fb855 fix(eval): use openrouter defaults, fix page ID tracking, add showcase tasks
- Default provider/model/apiKey read from env vars (SHOWCASE_PROVIDER,
  SHOWCASE_MODEL, OPENROUTER_API_KEY) instead of hardcoded OpenAI
- Fix stale page ID bug: activePageId now updates when tool calls
  reference a different page
- Add showcase-tasks.jsonl with sample Amazon task
- Add showcase-output/ and .env to .gitignore

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 16:12:33 -07:00
Nikhil Sonti
3d4e493512 feat(eval): add showcase dataset generator with before/after screenshots
Standalone script that runs the BrowserOS agent on tasks and captures
rich execution traces for website showcase galleries:

- Before/after screenshots per tool call via AI SDK callbacks
- CSS crosshair overlays injected via browser.evaluate() for
  element-targeting tools (click, fill, hover, etc.)
- Accessibility snapshot capture at each step
- Per-task JSON manifests with execution metadata
- R2 upload with URL rewriting for cloud-hosted galleries

New files:
- apps/eval/scripts/showcase/ (generate, executor, crosshair,
  manifest, uploader, types)
- apps/eval/.env.example (R2 credentials template)

Also adds Browser.getElementCenter() public method for coordinate
resolution without triggering actions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 15:07:14 -07:00
Nikhil
2b53daf641 fix: prevent deleted scheduled tasks from reappearing after sync (#518)
* fix: prevent deleted scheduled tasks from reappearing after sync

When a scheduled task was deleted, the sync function would see the
remote job missing locally and re-add it, undoing the delete. Fix by
tracking pending deletions in storage so the sync function deletes
them from the backend instead of re-adding them locally.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use read-modify-write for pending deletions to prevent concurrent clobber

Re-read pendingDeletionStorage before write-back and only remove
resolved IDs, preserving any new entries added by concurrent
removeJob calls during the sync's network I/O.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 11:31:57 -07:00
Nikhil
3cc946ded8 fix(ci): report test pass/fail status on PRs (#520)
The test workflow captured exit codes but never failed the job, so PR
checks always showed green even when tests failed. Exit with the
captured code in the summarize step so each suite properly reports
pass/fail. Not a required check, so failures remain non-blocking.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 11:31:23 -07:00
shivammittal274
70be5c5c21 fix(eval): log agent errors in task progress for CI visibility (#523) 2026-03-21 23:33:19 +05:30
shivammittal274
0f9d93058f chore(eval): remove unused env vars from workflow (OPENROUTER, OPENAI) (#522) 2026-03-21 23:22:03 +05:30
shivammittal274
cafed57832 fix(eval): use CLAUDE_CODE_OAUTH_TOKEN for performance grader auth (#521) 2026-03-21 23:14:23 +05:30
shivammittal274
f157436e7d feat(eval): switch to Linux GitHub-hosted runner (#519)
* feat(eval): switch to ubuntu-latest runner, add OE-Clado config

- Switch workflow from self-hosted Mac Studio to ubuntu-latest
- Install BrowserOS Linux .deb in CI (no self-hosted runner needed)
- Add browseros-oe-clado-weekly.json config for orchestrator-executor
- Fix report chart to show date+time (not just date)
- Make BROWSEROS_BINARY configurable via env var

* feat(eval): add NopeCHA captcha solver extension to eval runs

- Auto-load NopeCHA extension in eval Chrome instances
- Works in incognito + headless mode
- CI workflow downloads NopeCHA before eval
- extensions/ directory gitignored (downloaded at runtime)

* feat(eval): per-config concurrency — different configs run in parallel

* feat(eval): remove concurrency limit — all runs execute in parallel
2026-03-21 23:04:45 +05:30
Nikhil
ba7892322b ci: run BrowserOS test suites on PRs (#514)
* ci: run browseros tests on pull requests

* refactor: rework 0320-github_action_for_tests based on feedback

* refactor: rework 0320-github_action_for_tests based on feedback

* chore: add CI artifacts to .gitignore

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove mikepenz/action-junit-report to fix check suite misattribution

The JUnit report action creates check runs that GitHub associates with the
CLA check suite instead of the Tests check suite, causing test reports to
appear under "CLA Assistant" in the PR checks UI.

Remove the action and rely on job status + step summary + artifact upload
for test result visibility.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 09:46:36 -07:00
shivammittal274
4e90b4561a feat(eval): weekly eval pipeline with R2 uploads and trend dashboard (#516)
* feat(eval): weekly eval pipeline with R2 uploads and trend dashboard

Add infrastructure for running weekly evaluations and tracking score
trends over time:

- Auto-generated output dirs: results/{config-name}/{timestamp}/
  Each eval run gets its own timestamped folder, nothing is overwritten.

- upload-run.ts: uploads eval results to Cloudflare R2. Supports
  uploading a specific run or all un-uploaded runs for a config.

- weekly-report.ts: generates an interactive HTML dashboard from R2
  data. Config dropdown, trend chart with hover tooltips, searchable
  runs table. Groups runs by config name.

- viewer.html: client-facing 3-column run viewer (task list,
  screenshots with autoplay, agent stream with messages.jsonl).
  Shows performance grader axis breakdown with per-axis scores.

- browseros-agent-weekly.json: weekly benchmark config (kimi-k2p5,
  webbench-2of4-50, 10 workers, performance grader, headless).

- eval-weekly.yml: GitHub Actions workflow with cron (Saturday 6am)
  and manual trigger. Runs on self-hosted Mac Studio runner.
  Concurrency group ensures only one eval runs at a time.

- Dashboard updates: load previous runs, messages.jsonl viewer,
  grade badges show percentages, async stream loading.

- Grader updates: timeout 30min, max turns 100, DOM content
  verification guidance for performance grader.

* fix(eval): address Greptile review — injection, nested dirs, escaping

- Fix script injection in eval-weekly.yml: pass github.event.inputs
  through env var instead of interpolating into shell
- Fix /api/runs to enumerate nested results/{config}/{timestamp}/ dirs
- Fix /api/load-run to allow single-slash run names (config/timestamp)
- Add HTML escaping for R2-sourced values in weekly-report.ts
- Escape axis names in viewer.html renderAxesBreakdown

* fix(eval): fix biome lint — non-null assertion, template literals

* fix(eval): fix biome errors — replace var with let, fix inner function declaration

* fix(eval): address Greptile P2 issues

- isRunDir: check all subdirs for metadata.json, not just first 3
- eval-runner: guard configPath for dashboard-driven runs (fallback to 'eval')
- load-run: default unknown termination_reason to 'failed' not 'completed'

* feat(eval): make BROWSEROS_BINARY configurable via env var
2026-03-21 22:12:52 +05:30
shivammittal274
86eed82350 fix: lazy OAuth callback server with cancel+retry (Codex CLI pattern) (#515)
The OAuth callback server on port 1455 was bound eagerly at startup,
crashing the server if another BrowserOS instance was already running.

Rewrite as a lazy class (OAuthCallbackServer) that:
- Only binds port 1455 when the user initiates a ChatGPT Pro login
- Sends GET /cancel to any existing server on the port first, then
  retries up to 5 times (follows Codex CLI's cancel+retry pattern)
- Exposes /cancel endpoint so other instances/tools can cancel us
- Releases the port after the OAuth callback arrives
- Device-code providers (GitHub Copilot, Qwen) never touch port 1455

This allows running eval, dev instances, and multiple BrowserOS
instances without port conflicts. OAuth login works on whichever
instance initiates it — the others continue without OAuth.
2026-03-21 16:44:03 +05:30
Nikhil
be6ed22af4 test: fix BrowserOS tool test harness regressions (#513)
* test: fix browseros tool test harness regressions

* test: align working directory naming in page action tests
2026-03-20 12:05:39 -07:00
Nikhil
149cde118d chore: bump server version, offset and patch for release (#512) 2026-03-20 11:45:12 -07:00
Nikhil
9bc5e666c4 feat: auto-discover server port via ~/.browseros/server.json (#504)
* feat: auto-discover server port via ~/.browseros/server.json

Server writes its port to ~/.browseros/server.json on startup so the CLI
can auto-discover the server URL without requiring `browseros-cli init`.

Discovery chain: BROWSEROS_URL env > config.yaml > server.json > error

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address review feedback for PR #504

- Use synchronous unlinkSync in stop() since process.exit() fires
  immediately after, abandoning any pending async operations
- Wrap writeServerConfig in try/catch so a write failure doesn't crash
  a healthy server for a convenience feature

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: type server discovery config and add version metadata

Add ServerDiscoveryConfig interface to @browseros/shared and enrich
server.json with server_version, browseros_version, and chromium_version.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: normalize URL from server.json for consistency

All other URL sources (env var, config.yaml) pass through
normalizeServerURL; apply the same to the server.json path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 11:37:00 -07:00
Nikhil
2271277b4d feat: add voice input to new tab search bar (#509)
* feat: add voice recording UI with waveform overlay to new tab search bar

Add a microphone button to the NewTab search bar that opens a fullscreen
recording overlay powered by react-voice-visualizer. The overlay shows a
real-time waveform visualization during recording, recording time, and a
stop button. On completion, the audio is transcribed via the existing
gateway endpoint and the transcript auto-navigates to inline chat.

Changes:
- Extract transcribeAudio() to shared lib/voice/transcribe-audio.ts
- Add VoiceRecordingOverlay component with react-voice-visualizer
- Add Mic button to NewTab search bar
- Track analytics via existing NEWTAB_VOICE_* events
- Handle cancel (backdrop click) vs submit (stop button) correctly

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address PR review comments for voice recording overlay

- Reset processingRef on transcription error to prevent stuck state
- Use stable callback refs to prevent useEffect re-runs from inline
  arrow function props (fixes timer reset and unnecessary re-processing)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: replace voice overlay with inline sidepanel-style voice UI

Remove react-voice-visualizer dependency and VoiceRecordingOverlay.
Instead use the same inline voice pattern as the sidepanel ChatInput:
- Waveform bars replace the search input during recording
- Mic/stop/loading button states in the search bar
- Transcript populates the search input on completion
- Voice error shown inline below the search bar

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 11:33:01 -07:00
Nikhil
f865d301a2 test: add build smoke test to catch compile failures (#511)
* test: add build smoke test to catch compile failures

Compiles the server binary (darwin-arm64) and verifies --version outputs
the correct version from package.json. Uses an empty resource manifest
and stub env vars so the test runs without R2 access or real secrets.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address review feedback for PR #511

- Derive build target from process.platform/arch for CI portability
- Include binary stderr in --version assertion for better diagnostics

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 11:16:57 -07:00
Nikhil
6f398f0b36 fix: replace sharp with jimp to fix compiled binary crash (#510)
sharp is a native C module (libvips) whose .node binaries can't be
embedded in Bun compiled executables. It was imported at the top level
in copilot-fetch.ts, crashing the entire server at startup.

Replace with jimp (pure JavaScript, zero native deps) which bundles
cleanly into compiled binaries. Same resize algorithm preserved.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 11:06:05 -07:00
shivammittal274
8548bcf50a feat: credit-based tracking for BrowserOS provider (#489)
* feat: add credit-based tracking for BrowserOS provider

Send X-BrowserOS-ID header on all LLM requests through the BrowserOS
gateway for per-installation credit tracking. Handle 429 CREDITS_EXHAUSTED
as non-retryable. Add GET/PUT /credits endpoints to check and manage
credit balance.

* docs: add credits tracking UI design

Design for showing credit balance in side panel chat header (color-coded
badge) and a dedicated Usage & Billing settings page. Credits refresh
after each completed message turn or on exhaustion error.

* docs: add credits tracking UI implementation plan

8-task plan covering useCredits hook, CreditBadge component, ChatHeader
integration, message completion refresh, ChatError CREDITS_EXHAUSTED
handling, Usage & Billing settings page, and route/sidebar registration.

* feat: add useCredits React Query hook

* feat: add CreditBadge component with color thresholds

* feat: show credit badge in chat header for BrowserOS provider

* feat: refresh credits after chat message completion and on error

* feat: handle CREDITS_EXHAUSTED error in chat

* feat: add Usage & Billing settings page

* feat: register usage page route and sidebar entry

* fix: lint and formatting fixes for credit tracking UI

* fix: separate credits exhausted from Kimi rate limit in ChatError, redesign Usage page

* chore: remove PUT /credits endpoint and setCredits function

* fix: extract shared credit colors, add error state to UsagePage, use dailyLimit from gateway

* fix: make dailyLimit required in CreditsInfo (gateway always returns it)

* feat: gate credits UI behind CREDITS_SUPPORT feature flag (server >= 0.0.78)
2026-03-20 22:49:00 +05:30
shivammittal274
e3601bfdc1 feat: gate Qwen Code behind server version 0.0.77 (#508) 2026-03-20 20:07:39 +05:30
83 changed files with 5705 additions and 252 deletions

89
.github/workflows/eval-weekly.yml vendored Normal file
View File

@@ -0,0 +1,89 @@
name: Weekly Eval
on:
schedule:
# Every Saturday at 06:00 UTC
- cron: '0 6 * * 6'
workflow_dispatch:
inputs:
config:
description: 'Eval config file (relative to apps/eval/)'
required: false
default: 'configs/browseros-agent-weekly.json'
permissions:
contents: read
jobs:
eval:
runs-on: ubuntu-latest
timeout-minutes: 360
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install BrowserOS
run: |
wget -q https://github.com/browseros-ai/BrowserOS/releases/download/v0.44.0.1/BrowserOS_v0.44.0.1_amd64.deb
sudo dpkg -i BrowserOS_v0.44.0.1_amd64.deb
browseros --version || echo "BrowserOS installed at $(which browseros)"
- name: Install Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
working-directory: packages/browseros-agent
run: bun install --ignore-scripts && bun run build:agent-sdk
- name: Install captcha solver extension
working-directory: packages/browseros-agent/apps/eval
run: |
mkdir -p extensions
curl -sL -o /tmp/nopecha.zip https://github.com/NopeCHALLC/nopecha-extension/releases/latest/download/chromium_automation.zip
unzip -qo /tmp/nopecha.zip -d extensions/nopecha
- name: Run eval
working-directory: packages/browseros-agent/apps/eval
env:
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
BROWSEROS_BINARY: /usr/bin/browseros
EVAL_CONFIG: ${{ github.event.inputs.config || 'configs/browseros-agent-weekly.json' }}
run: |
echo "Running eval with config: $EVAL_CONFIG"
bun run src/index.ts -c "$EVAL_CONFIG"
- name: Upload runs to R2
if: success()
working-directory: packages/browseros-agent/apps/eval
env:
EVAL_R2_ACCOUNT_ID: ${{ secrets.EVAL_R2_ACCOUNT_ID }}
EVAL_R2_ACCESS_KEY_ID: ${{ secrets.EVAL_R2_ACCESS_KEY_ID }}
EVAL_R2_SECRET_ACCESS_KEY: ${{ secrets.EVAL_R2_SECRET_ACCESS_KEY }}
EVAL_R2_BUCKET: ${{ secrets.EVAL_R2_BUCKET }}
EVAL_R2_CDN_BASE_URL: ${{ secrets.EVAL_R2_CDN_BASE_URL }}
EVAL_CONFIG: ${{ github.event.inputs.config || 'configs/browseros-agent-weekly.json' }}
run: |
CONFIG_NAME=$(basename "$EVAL_CONFIG" .json)
bun scripts/upload-run.ts "results/$CONFIG_NAME"
- name: Generate trend report
if: success()
working-directory: packages/browseros-agent
env:
EVAL_R2_ACCOUNT_ID: ${{ secrets.EVAL_R2_ACCOUNT_ID }}
EVAL_R2_ACCESS_KEY_ID: ${{ secrets.EVAL_R2_ACCESS_KEY_ID }}
EVAL_R2_SECRET_ACCESS_KEY: ${{ secrets.EVAL_R2_SECRET_ACCESS_KEY }}
EVAL_R2_BUCKET: ${{ secrets.EVAL_R2_BUCKET }}
EVAL_R2_CDN_BASE_URL: ${{ secrets.EVAL_R2_CDN_BASE_URL }}
run: bun apps/eval/scripts/weekly-report.ts /tmp/eval-report.html
- name: Upload report as artifact
if: success()
uses: actions/upload-artifact@v4
with:
name: eval-report-${{ github.run_id }}
path: /tmp/eval-report.html

View File

@@ -1,15 +1,44 @@
name: Tests
on: []
on:
pull_request:
types:
- opened
- synchronize
- reopened
- ready_for_review
paths:
- .github/workflows/test.yml
- packages/browseros-agent/**
workflow_dispatch:
permissions:
contents: read
env:
BROWSEROS_APPIMAGE_URL: https://files.browseros.com/download/BrowserOS.AppImage
jobs:
test:
name: Run Tests
runs-on: macos-latest
timeout-minutes: 10
name: Tests / ${{ matrix.suite }}
runs-on: ubuntu-latest
timeout-minutes: 20
defaults:
run:
working-directory: packages/browseros-agent
strategy:
fail-fast: false
matrix:
include:
- suite: tools
test_path: tests/tools
junit_path: test-results/tools.xml
- suite: integration
test_path: tests/server.integration.test.ts
junit_path: test-results/integration.xml
- suite: sdk
test_path: tests/sdk
junit_path: test-results/sdk.xml
steps:
- name: Checkout code
@@ -21,7 +50,92 @@ jobs:
- name: Install dependencies
run: bun ci
- name: Run all tests
run: bun test:all
- name: Resolve BrowserOS cache key
id: browseros-cache-key
run: |
set -euo pipefail
headers="$(curl -fsSI "$BROWSEROS_APPIMAGE_URL")"
etag="$(printf '%s\n' "$headers" | awk 'BEGIN{IGNORECASE=1} /^etag:/ {sub(/\r$/, "", $2); gsub(/"/, "", $2); print $2; exit}')"
last_modified="$(printf '%s\n' "$headers" | awk 'BEGIN{IGNORECASE=1} /^last-modified:/ {$1=""; sub(/^ /, ""); sub(/\r$/, ""); print; exit}')"
raw_key="${etag:-$last_modified}"
if [ -z "$raw_key" ]; then
raw_key="$BROWSEROS_APPIMAGE_URL"
fi
cache_key="$(printf '%s' "$raw_key" | shasum -a 256 | awk '{print $1}')"
echo "key=browseros-appimage-${{ runner.os }}-$cache_key" >> "$GITHUB_OUTPUT"
- name: Restore BrowserOS cache
id: browseros-cache
uses: actions/cache@v4
with:
path: packages/browseros-agent/.ci/bin/BrowserOS.AppImage
key: ${{ steps.browseros-cache-key.outputs.key }}
- name: Download BrowserOS
if: steps.browseros-cache.outputs.cache-hit != 'true'
run: |
mkdir -p .ci/bin
curl -fsSL "$BROWSEROS_APPIMAGE_URL" -o .ci/bin/BrowserOS.AppImage
chmod +x .ci/bin/BrowserOS.AppImage
- name: Prepare BrowserOS wrapper
run: |
mkdir -p .ci/bin
cat > .ci/bin/browseros <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
export APPIMAGE_EXTRACT_AND_RUN=1
exec "$(dirname "$0")/BrowserOS.AppImage" "$@"
EOF
chmod +x .ci/bin/browseros
- name: Create server env file
working-directory: packages/browseros-agent/apps/server
run: cp .env.example .env.development
- name: Run ${{ matrix.suite }} tests
id: test
env:
PUPPETEER_EXECUTABLE_PATH: /Applications/Google Chrome.app/Contents/MacOS/Google Chrome
BROWSEROS_BINARY: ${{ github.workspace }}/packages/browseros-agent/.ci/bin/browseros
BROWSEROS_TEST_HEADLESS: "true"
BROWSEROS_TEST_EXTRA_ARGS: --no-sandbox --disable-dev-shm-usage
run: |
set +e
mkdir -p test-results
cd apps/server
bun run test:cleanup
bun --env-file=.env.development test "${{ matrix.test_path }}" --reporter=junit --reporter-outfile="../../${{ matrix.junit_path }}"
exit_code=$?
cd ../..
if [ ! -f "${{ matrix.junit_path }}" ]; then
cat > "${{ matrix.junit_path }}" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<testsuites tests="1" failures="1">
<testsuite name="${{ matrix.suite }}" tests="1" failures="1">
<testcase classname="workflow" name="${{ matrix.suite }} setup">
<failure message="Test run failed before JUnit output was written">See workflow logs for details.</failure>
</testcase>
</testsuite>
</testsuites>
EOF
fi
echo "exit_code=$exit_code" >> "$GITHUB_OUTPUT"
- name: Upload JUnit XML
if: always()
uses: actions/upload-artifact@v4
with:
name: junit-${{ matrix.suite }}
path: packages/browseros-agent/${{ matrix.junit_path }}
- name: Summarize suite result
if: always()
run: |
if [ "${{ steps.test.outputs.exit_code }}" = "0" ]; then
echo "### :white_check_mark: ${{ matrix.suite }} suite passed" >> "$GITHUB_STEP_SUMMARY"
else
echo "### :x: ${{ matrix.suite }} suite failed (exit code ${{ steps.test.outputs.exit_code }})" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "See the uploaded \`junit-${{ matrix.suite }}\` artifact for details." >> "$GITHUB_STEP_SUMMARY"
exit 1
fi

4
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,4 @@
{
"terminal.integrated.tabs.title": "${sequence} ${process}",
"terminal.integrated.tabs.description": "${cwd}"
}

View File

@@ -187,6 +187,10 @@ log.txt
# Testing iteration temp files
tmp/
# CI artifacts
.ci/
test-results/
# Coding agent artifacts
.agent/
.llm/

View File

@@ -0,0 +1,26 @@
import { Coins } from 'lucide-react'
import type { FC } from 'react'
import { getCreditTextColor } from '@/lib/credits/credit-colors'
import { cn } from '@/lib/utils'
interface CreditBadgeProps {
credits: number
onClick?: () => void
}
export const CreditBadge: FC<CreditBadgeProps> = ({ credits, onClick }) => {
return (
<button
type="button"
onClick={onClick}
className={cn(
'inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 font-medium text-xs transition-colors hover:bg-muted/50',
getCreditTextColor(credits),
)}
title={`${credits} credits remaining`}
>
<Coins className="h-3.5 w-3.5" />
<span>{credits}</span>
</button>
)
}

View File

@@ -3,6 +3,7 @@ import {
BookOpen,
Bot,
Compass,
CreditCard,
GitBranch,
MessageSquare,
Palette,
@@ -79,6 +80,12 @@ const primarySettingsSections: NavSection[] = [
feature: Feature.CUSTOMIZATION_SUPPORT,
},
{ name: 'BrowserOS as MCP', to: '/settings/mcp', icon: Server },
{
name: 'Usage & Billing',
to: '/settings/usage',
icon: CreditCard,
feature: Feature.CREDITS_SUPPORT,
},
{
name: 'Workflows',
to: '/workflows',

View File

@@ -28,6 +28,7 @@ import { ScheduledTasksPage } from './scheduled-tasks/ScheduledTasksPage'
import { SearchProviderPage } from './search-provider/SearchProviderPage'
import { SkillsPage } from './skills/SkillsPage'
import { SoulPage } from './soul/SoulPage'
import { UsagePage } from './usage/UsagePage'
import { WorkflowsPageWrapper } from './workflows/WorkflowsPageWrapper'
function getSurveyParams(): { maxTurns?: number; experimentId?: string } {
@@ -103,6 +104,7 @@ export const App: FC = () => {
<Route path="customization" element={<CustomizationPage />} />
<Route path="search" element={<SearchProviderPage />} />
<Route path="survey" element={<SurveyPage {...surveyParams} />} />
<Route path="usage" element={<UsagePage />} />
</Route>
</Route>

View File

@@ -200,6 +200,7 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
return supports(Feature.CHATGPT_PRO_SUPPORT)
if (opt.value === 'github-copilot')
return supports(Feature.GITHUB_COPILOT_SUPPORT)
if (opt.value === 'qwen-code') return supports(Feature.QWEN_CODE_SUPPORT)
if (opt.value === 'moonshot')
return kimiLaunch || initialValues?.type === 'moonshot'
if (opt.value === 'openai-compatible') {

View File

@@ -30,6 +30,7 @@ export const ProviderTemplatesSection: FC<ProviderTemplatesSectionProps> = ({
return supports(Feature.CHATGPT_PRO_SUPPORT)
if (template.id === 'github-copilot')
return supports(Feature.GITHUB_COPILOT_SUPPORT)
if (template.id === 'qwen-code') return supports(Feature.QWEN_CODE_SUPPORT)
if (template.id === 'moonshot') return kimiLaunch
if (template.id === 'openai-compatible') {
return supports(Feature.OPENAI_COMPATIBLE_SUPPORT)

View File

@@ -22,9 +22,7 @@ import {
SCHEDULED_TASK_TOGGLED_EVENT,
SCHEDULED_TASK_VIEW_RESULTS_EVENT,
} from '@/lib/constants/analyticsEvents'
import { useGraphqlMutation } from '@/lib/graphql/useGraphqlMutation'
import { track } from '@/lib/metrics/track'
import { DeleteScheduledJobDocument } from '@/lib/schedules/graphql/syncSchedulesDocument'
import {
scheduledJobRunStorage,
useScheduledJobRuns,
@@ -46,8 +44,6 @@ export const ScheduledTasksPage: FC = () => {
useScheduledJobs()
const { jobRuns, cancelJobRun } = useScheduledJobRuns()
const deleteRemoteJobMutation = useGraphqlMutation(DeleteScheduledJobDocument)
const [activeTab, setActiveTab] = useState<string | null>(null)
const [isDialogOpen, setIsDialogOpen] = useState(false)
const [editingJob, setEditingJob] = useState<ScheduledJob | null>(null)
@@ -102,7 +98,6 @@ export const ScheduledTasksPage: FC = () => {
const confirmDelete = async () => {
if (deleteJobId) {
await removeJob(deleteJobId)
deleteRemoteJobMutation.mutate({ rowId: deleteJobId })
setDeleteJobId(null)
track(SCHEDULED_TASK_DELETED_EVENT)
}

View File

@@ -0,0 +1,125 @@
import { AlertCircle, Clock, Coins, CreditCard, Zap } from 'lucide-react'
import type { FC } from 'react'
import { Button } from '@/components/ui/button'
import {
getCreditBarColor,
getCreditTextColor,
} from '@/lib/credits/credit-colors'
import { useCredits } from '@/lib/credits/useCredits'
import { BrowserOSIcon } from '@/lib/llm-providers/providerIcons'
import { cn } from '@/lib/utils'
export const UsagePage: FC = () => {
const { data, isLoading, error } = useCredits()
if (isLoading) {
return (
<div className="flex items-center justify-center p-12 text-muted-foreground text-sm">
Loading usage data...
</div>
)
}
if (error) {
return (
<div className="space-y-6 p-6">
<div className="flex items-center gap-4 rounded-xl border p-5">
<BrowserOSIcon size={40} />
<div>
<h2 className="font-semibold text-lg">Usage & Billing</h2>
<p className="text-muted-foreground text-sm">
Monitor your BrowserOS AI credit usage
</p>
</div>
</div>
<div className="flex flex-col items-center gap-3 rounded-xl border border-destructive/30 bg-destructive/5 p-8">
<AlertCircle className="h-6 w-6 text-muted-foreground" />
<p className="text-muted-foreground text-sm">
Unable to load credit information
</p>
</div>
</div>
)
}
const credits = data?.credits ?? 0
const total = data?.dailyLimit ?? 100
const percentage = Math.min((credits / total) * 100, 100)
return (
<div className="space-y-6 p-6">
<div className="flex items-center gap-4 rounded-xl border p-5">
<BrowserOSIcon size={40} />
<div>
<h2 className="font-semibold text-lg">Usage & Billing</h2>
<p className="text-muted-foreground text-sm">
Monitor your BrowserOS AI credit usage
</p>
</div>
</div>
<div className="rounded-xl border p-5">
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<Coins className="h-5 w-5 text-muted-foreground" />
<span className="font-semibold text-sm">Daily Credits</span>
</div>
<span
className={cn('font-bold text-2xl', getCreditTextColor(credits))}
>
{credits}
<span className="ml-1 font-normal text-muted-foreground text-sm">
/ {total}
</span>
</span>
</div>
<div className="mb-5 h-2.5 w-full overflow-hidden rounded-full bg-muted">
<div
className={cn(
'h-full rounded-full transition-all duration-500',
getCreditBarColor(credits),
)}
style={{ width: `${percentage}%` }}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="flex items-center gap-2.5 rounded-lg bg-muted/50 px-3 py-2.5">
<Clock className="h-4 w-4 shrink-0 text-muted-foreground" />
<div>
<p className="font-medium text-xs">Resets daily</p>
<p className="text-muted-foreground text-xs">Midnight UTC</p>
</div>
</div>
<div className="flex items-center gap-2.5 rounded-lg bg-muted/50 px-3 py-2.5">
<Zap className="h-4 w-4 shrink-0 text-muted-foreground" />
<div>
<p className="font-medium text-xs">Credits used today</p>
<p className="text-muted-foreground text-xs">
{total - credits} of {total}
</p>
</div>
</div>
</div>
</div>
<div className="rounded-xl border p-5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<CreditCard className="h-5 w-5 text-muted-foreground" />
<div>
<p className="font-semibold text-sm">Need more credits?</p>
<p className="text-muted-foreground text-xs">
Additional credit packages coming soon
</p>
</div>
</div>
<Button variant="outline" size="sm" disabled className="opacity-50">
Add Credits
</Button>
</div>
</div>
</div>
)
}

View File

@@ -5,8 +5,11 @@ import {
Folder,
Globe,
Layers,
Loader2,
Mic,
PlugZap,
Search,
Square,
X,
} from 'lucide-react'
import { AnimatePresence, motion } from 'motion/react'
@@ -44,6 +47,10 @@ import {
NEWTAB_TAB_REMOVED_EVENT,
NEWTAB_TAB_TOGGLED_EVENT,
NEWTAB_TABS_OPENED_EVENT,
NEWTAB_VOICE_ERROR_EVENT,
NEWTAB_VOICE_RECORDING_STARTED_EVENT,
NEWTAB_VOICE_RECORDING_STOPPED_EVENT,
NEWTAB_VOICE_TRANSCRIPTION_COMPLETED_EVENT,
NEWTAB_WORKSPACE_OPENED_EVENT,
} from '@/lib/constants/analyticsEvents'
import { BrowserOSIcon, ProviderIcon } from '@/lib/llm-providers/providerIcons'
@@ -53,6 +60,7 @@ import { useSyncRemoteIntegrations } from '@/lib/mcp/useSyncRemoteIntegrations'
import { openSidePanelWithSearch } from '@/lib/messaging/sidepanel/openSidepanelWithSearch'
import { track } from '@/lib/metrics/track'
import { cn } from '@/lib/utils'
import { useVoiceInput } from '@/lib/voice/useVoiceInput'
import { useWorkspace } from '@/lib/workspace/use-workspace'
import { ImportDataHint } from './ImportDataHint'
import type { SuggestionItem } from './lib/suggestions/types'
@@ -100,6 +108,36 @@ export const NewTab = () => {
const { data: userMCPIntegrations } = useGetUserMCPIntegrations()
useSyncRemoteIntegrations()
const voice = useVoiceInput()
// Voice transcript → populate search input
// biome-ignore lint/correctness/useExhaustiveDependencies: only trigger on transcript/transcribing change
useEffect(() => {
if (voice.transcript && !voice.isTranscribing) {
setComboboxInputValue(voice.transcript)
track(NEWTAB_VOICE_TRANSCRIPTION_COMPLETED_EVENT)
voice.clearTranscript()
}
}, [voice.transcript, voice.isTranscribing])
useEffect(() => {
if (voice.error) {
track(NEWTAB_VOICE_ERROR_EVENT, { error: voice.error })
}
}, [voice.error])
const handleStartRecording = async () => {
const started = await voice.startRecording()
if (started) {
track(NEWTAB_VOICE_RECORDING_STARTED_EVENT)
}
}
const handleStopRecording = async () => {
await voice.stopRecording()
track(NEWTAB_VOICE_RECORDING_STOPPED_EVENT)
}
const connectedManagedServers = mcpServers.filter((s) => {
if (s.type !== 'managed' || !s.managedServerName) return false
return userMCPIntegrations?.integrations?.find(
@@ -430,32 +468,89 @@ export const NewTab = () => {
anchorRef={inputRef}
side="bottom"
/>
<input
type="text"
placeholder={searchPlaceholder}
className="flex-1 border-none bg-transparent text-base text-foreground outline-none placeholder:text-muted-foreground"
{...getInputProps({
ref: inputRef,
onChange: (e) => handleInputChange(e.currentTarget.value),
onKeyDown: (e) => {
if (!mentionStateRef.current.isOpen) return
if (e.key === 'Tab') {
e.preventDefault()
closeMention()
}
},
})}
/>
{voice.isRecording ? (
<div className="flex min-h-[40px] flex-1 items-center justify-center gap-1.5">
{voice.audioLevels.map((level, i) => (
<div
key={i.toString()}
className="w-1.5 rounded-full bg-red-500 transition-all duration-75"
style={{
height: `${Math.max(6, Math.min(28, level * 0.7))}px`,
}}
/>
))}
</div>
) : (
<input
type="text"
placeholder={
voice.isTranscribing ? 'Transcribing...' : searchPlaceholder
}
disabled={voice.isTranscribing}
className="flex-1 border-none bg-transparent text-base text-foreground outline-none placeholder:text-muted-foreground disabled:opacity-60"
{...getInputProps({
ref: inputRef,
onChange: (e) => handleInputChange(e.currentTarget.value),
onKeyDown: (e) => {
if (!mentionStateRef.current.isOpen) return
if (e.key === 'Tab') {
e.preventDefault()
closeMention()
}
},
})}
/>
)}
<Button
onClick={handleSend}
size="icon"
className="h-10 w-10 flex-shrink-0 rounded-xl bg-primary text-primary-foreground hover:bg-primary/90"
>
<ArrowRight className="h-5 w-5" />
</Button>
<div className="flex items-center gap-1.5">
{voice.isRecording ? (
<Button
type="button"
size="icon"
onClick={handleStopRecording}
className="h-10 w-10 flex-shrink-0 rounded-xl bg-red-600 text-white hover:bg-red-700"
>
<Square className="h-4 w-4" />
</Button>
) : voice.isTranscribing ? (
<Button
type="button"
variant="ghost"
size="icon"
disabled
className="h-10 w-10 flex-shrink-0 rounded-xl"
>
<Loader2 className="h-5 w-5 animate-spin" />
</Button>
) : (
<Button
type="button"
variant="ghost"
size="icon"
onClick={handleStartRecording}
className="h-10 w-10 flex-shrink-0 rounded-xl text-muted-foreground transition-colors hover:text-foreground"
title="Voice input"
>
<Mic className="h-5 w-5" />
</Button>
)}
<Button
onClick={handleSend}
size="icon"
disabled={voice.isRecording || voice.isTranscribing}
className="h-10 w-10 flex-shrink-0 rounded-xl bg-primary text-primary-foreground hover:bg-primary/90"
>
<ArrowRight className="h-5 w-5" />
</Button>
</div>
</div>
{voice.error && (
<div className="px-5 pb-2 text-destructive text-xs">
{voice.error}
</div>
)}
<AnimatePresence>
{selectedTabs.length > 0 && (
<motion.div

View File

@@ -30,6 +30,7 @@ function parseErrorMessage(message: string): {
text: string
url?: string
isRateLimit?: boolean
isCreditsExhausted?: boolean
isConnectionError?: boolean
} {
// Detect MCP server connection failures
@@ -44,6 +45,19 @@ function parseErrorMessage(message: string): {
}
}
// Detect credit exhaustion from gateway
if (
message.includes('CREDITS_EXHAUSTED') ||
message.includes('Daily credits exhausted')
) {
return {
text: 'Daily credits exhausted. Credits reset at midnight UTC.',
url: '/app.html#/settings/usage',
isRateLimit: true,
isCreditsExhausted: true,
}
}
// Detect BrowserOS rate limit (unique pattern, no provider uses this)
if (message.includes('BrowserOS LLM daily limit reached')) {
return {
@@ -70,9 +84,8 @@ function parseErrorMessage(message: string): {
}
export const ChatError: FC<ChatErrorProps> = ({ error, onRetry }) => {
const { text, url, isRateLimit, isConnectionError } = parseErrorMessage(
error.message,
)
const { text, url, isRateLimit, isCreditsExhausted, isConnectionError } =
parseErrorMessage(error.message)
// --- Commented out for Kimi partnership launch (restore after) ---
// const surveyUrl = useMemo(
@@ -128,7 +141,17 @@ export const ChatError: FC<ChatErrorProps> = ({ error, onRetry }) => {
</p>
)}
--- End commented out survey code --- */}
{isRateLimit && (
{isCreditsExhausted && url && (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground text-xs underline hover:text-foreground"
>
View Usage & Billing
</a>
)}
{isRateLimit && !isCreditsExhausted && (
<div className="flex flex-col items-center gap-1">
<p className="text-muted-foreground text-xs">
{/* biome-ignore lint/a11y/useValidAnchor: link with click tracking */}

View File

@@ -3,11 +3,27 @@ import type { FC } from 'react'
import { Link, useLocation, useNavigate } from 'react-router'
import { ChatProviderSelector } from '@/components/chat/ChatProviderSelector'
import type { Provider } from '@/components/chat/chatComponentTypes'
import { CreditBadge } from '@/components/credits/CreditBadge'
import { ThemeToggle } from '@/components/elements/theme-toggle'
import { Feature } from '@/lib/browseros/capabilities'
import { useCapabilities } from '@/lib/browseros/useCapabilities'
import { productRepositoryUrl } from '@/lib/constants/productUrls'
import { useCredits } from '@/lib/credits/useCredits'
import { BrowserOSIcon, ProviderIcon } from '@/lib/llm-providers/providerIcons'
import type { ProviderType } from '@/lib/llm-providers/types'
const CreditsBadgeWrapper: FC = () => {
const { supports } = useCapabilities()
const { data } = useCredits()
if (!supports(Feature.CREDITS_SUPPORT) || data === undefined) return null
return (
<CreditBadge
credits={data.credits}
onClick={() => window.open('/app.html#/settings/usage', '_blank')}
/>
)
}
interface ChatHeaderProps {
selectedProvider: Provider
providers: Provider[]
@@ -61,6 +77,7 @@ export const ChatHeader: FC<ChatHeaderProps> = ({
</span>
</button>
</ChatProviderSelector>
{selectedProvider.type === 'browseros' && <CreditsBadgeWrapper />}
</div>
<div className="flex items-center gap-1">

View File

@@ -21,6 +21,7 @@ import {
useConversations,
} from '@/lib/conversations/conversationStorage'
import { formatConversationHistory } from '@/lib/conversations/formatConversationHistory'
import { useInvalidateCredits } from '@/lib/credits/useCredits'
import { declinedAppsStorage } from '@/lib/declined-apps/storage'
import { useGraphqlQuery } from '@/lib/graphql/useGraphqlQuery'
import { createDefaultBrowserOSProvider } from '@/lib/llm-providers/storage'
@@ -86,6 +87,7 @@ export const useChatSession = (options?: ChatSessionOptions) => {
selectedLlmProvider,
isLoadingProviders,
} = useChatRefs()
const invalidateCredits = useInvalidateCredits()
const { providers: llmProviders, setDefaultProvider } = useLlmProviders()
@@ -481,8 +483,14 @@ export const useChatSession = (options?: ChatSessionOptions) => {
} else {
saveLocalConversation(conversationIdRef.current, messagesToSave)
}
invalidateCredits()
}, [status])
useEffect(() => {
if (chatError) invalidateCredits()
}, [chatError, invalidateCredits])
const isIntegrationsSynced = options?.isIntegrationsSynced ?? true
const isIntegrationsSyncedRef = useRef(isIntegrationsSynced)
const pendingMessageRef = useRef<{

View File

@@ -49,6 +49,10 @@ export enum Feature {
CHATGPT_PRO_SUPPORT = 'CHATGPT_PRO_SUPPORT',
// GitHub Copilot OAuth LLM provider
GITHUB_COPILOT_SUPPORT = 'GITHUB_COPILOT_SUPPORT',
// Qwen Code OAuth LLM provider
QWEN_CODE_SUPPORT = 'QWEN_CODE_SUPPORT',
// Credit-based usage tracking
CREDITS_SUPPORT = 'CREDITS_SUPPORT',
}
/**
@@ -78,6 +82,8 @@ const FEATURE_CONFIG: { [K in Feature]: FeatureConfig } = {
[Feature.SKILLS_SUPPORT]: { minBrowserOSVersion: '0.43.0.0' },
[Feature.CHATGPT_PRO_SUPPORT]: { minServerVersion: '0.0.77' },
[Feature.GITHUB_COPILOT_SUPPORT]: { minServerVersion: '0.0.77' },
[Feature.QWEN_CODE_SUPPORT]: { minServerVersion: '0.0.77' },
[Feature.CREDITS_SUPPORT]: { minServerVersion: '0.0.78' },
}
function parseVersion(version: string): number[] {

View File

@@ -0,0 +1,13 @@
const LOW_THRESHOLD = 30
export function getCreditTextColor(credits: number): string {
if (credits <= 0) return 'text-red-500'
if (credits <= LOW_THRESHOLD) return 'text-yellow-500'
return 'text-green-500'
}
export function getCreditBarColor(credits: number): string {
if (credits <= 0) return 'bg-red-500'
if (credits <= LOW_THRESHOLD) return 'bg-yellow-500'
return 'bg-green-500'
}

View File

@@ -0,0 +1,33 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { getAgentServerUrl } from '@/lib/browseros/helpers'
export interface CreditsInfo {
credits: number
dailyLimit: number
lastResetAt?: string
}
const CREDITS_QUERY_KEY = ['credits']
async function fetchCredits(): Promise<CreditsInfo> {
const baseUrl = await getAgentServerUrl()
const response = await fetch(`${baseUrl}/credits`)
if (!response.ok)
throw new Error(`Failed to fetch credits: ${response.status}`)
return response.json()
}
export function useCredits() {
return useQuery<CreditsInfo>({
queryKey: CREDITS_QUERY_KEY,
queryFn: fetchCredits,
refetchOnWindowFocus: true,
staleTime: 30_000,
retry: 1,
})
}
export function useInvalidateCredits() {
const queryClient = useQueryClient()
return () => queryClient.invalidateQueries({ queryKey: CREDITS_QUERY_KEY })
}

View File

@@ -22,6 +22,13 @@ export const scheduledJobRunStorage = storage.defineItem<ScheduledJobRun[]>(
},
)
export const pendingDeletionStorage = storage.defineItem<string[]>(
'local:scheduledJobsPendingDeletion',
{
fallback: [],
},
)
export function useScheduledJobs() {
const [jobs, setJobs] = useState<ScheduledJob[]>([])
@@ -54,6 +61,11 @@ export function useScheduledJobs() {
const removeJob = async (id: string) => {
await chrome.alarms.clear(getAlarmName(id))
const pending = (await pendingDeletionStorage.getValue()) ?? []
if (!pending.includes(id)) {
await pendingDeletionStorage.setValue([...pending, id])
}
const currentJobs = (await scheduledJobStorage.getValue()) ?? []
await scheduledJobStorage.setValue(currentJobs.filter((j) => j.id !== id))

View File

@@ -5,10 +5,11 @@ import { sentry } from '@/lib/sentry/sentry'
import { createAlarmFromJob } from './createAlarmFromJob'
import {
CreateScheduledJobDocument,
DeleteScheduledJobDocument,
GetScheduledJobsByProfileIdDocument,
UpdateScheduledJobDocument,
} from './graphql/syncSchedulesDocument'
import { scheduledJobStorage } from './scheduleStorage'
import { pendingDeletionStorage, scheduledJobStorage } from './scheduleStorage'
import type { ScheduledJob } from './scheduleTypes'
type RemoteScheduledJob = {
@@ -99,6 +100,32 @@ export async function syncSchedulesToBackend(
}
}
const pendingDeletions = new Set(
(await pendingDeletionStorage.getValue()) ?? [],
)
const resolvedDeletions = new Set<string>()
for (const rowId of pendingDeletions) {
if (remoteJobs.has(rowId)) {
try {
await execute(DeleteScheduledJobDocument, { rowId })
remoteJobs.delete(rowId)
resolvedDeletions.add(rowId)
} catch (error) {
sentry.captureException(error, {
extra: { jobId: rowId, context: 'sync-pending-deletion' },
})
}
} else {
resolvedDeletions.add(rowId)
}
}
const latestPending = (await pendingDeletionStorage.getValue()) ?? []
await pendingDeletionStorage.setValue(
latestPending.filter((id) => !resolvedDeletions.has(id)),
)
const localJobsMap = new Map(localJobs.map((j) => [j.id, j]))
const jobsToAddLocally: ScheduledJob[] = []
const jobsToUpdateLocally: ScheduledJob[] = []

View File

@@ -0,0 +1,29 @@
const GATEWAY_URL = 'https://llm.browseros.com'
interface TranscribeResponse {
text: string
}
export async function transcribeAudio(audioBlob: Blob): Promise<string> {
const formData = new FormData()
formData.append('file', audioBlob, 'recording.webm')
formData.append('response_format', 'json')
const response = await fetch(`${GATEWAY_URL}/api/transcribe`, {
method: 'POST',
body: formData,
signal: AbortSignal.timeout(30_000),
})
if (!response.ok) {
const errorBody: { error?: string } = await response
.json()
.catch(() => ({ error: 'Transcription failed' }))
throw new Error(
errorBody.error || `Transcription failed: ${response.status}`,
)
}
const result: TranscribeResponse = await response.json()
return result.text || ''
}

View File

@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react'
import { transcribeAudio } from './transcribe-audio'
const GATEWAY_URL = 'https://llm.browseros.com'
const WAVEFORM_BAND_COUNT = 5
export interface VoiceInputState {
@@ -26,34 +26,6 @@ export interface UseVoiceInputReturn {
const EMPTY_LEVELS = Array(WAVEFORM_BAND_COUNT).fill(0)
interface TranscribeResponse {
text: string
}
async function transcribeAudio(audioBlob: Blob): Promise<string> {
const formData = new FormData()
formData.append('file', audioBlob, 'recording.webm')
formData.append('response_format', 'json')
const response = await fetch(`${GATEWAY_URL}/api/transcribe`, {
method: 'POST',
body: formData,
signal: AbortSignal.timeout(30_000),
})
if (!response.ok) {
const errorBody: { error?: string } = await response
.json()
.catch(() => ({ error: 'Transcription failed' }))
throw new Error(
errorBody.error || `Transcription failed: ${response.status}`,
)
}
const result: TranscribeResponse = await response.json()
return result.text || ''
}
export function useVoiceInput(): UseVoiceInputReturn {
const [isRecording, setIsRecording] = useState(false)
const [isTranscribing, setIsTranscribing] = useState(false)

View File

@@ -1,8 +1,10 @@
package cmd
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
@@ -170,11 +172,44 @@ func defaultServerURL() string {
}
cfg, err := config.Load()
if err == nil {
if url := normalizeServerURL(cfg.ServerURL); url != "" {
return url
}
}
if url := loadBrowserosServerURL(); url != "" {
return url
}
return ""
}
type serverDiscoveryConfig struct {
ServerPort int `json:"server_port"`
URL string `json:"url"`
ServerVersion string `json:"server_version"`
BrowserOSVersion string `json:"browseros_version,omitempty"`
ChromiumVersion string `json:"chromium_version,omitempty"`
}
func loadBrowserosServerURL() string {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
return normalizeServerURL(cfg.ServerURL)
data, err := os.ReadFile(filepath.Join(home, ".browseros", "server.json"))
if err != nil {
return ""
}
var sc serverDiscoveryConfig
if err := json.Unmarshal(data, &sc); err != nil {
return ""
}
return normalizeServerURL(sc.URL)
}
func normalizeServerURL(raw string) string {

View File

@@ -0,0 +1,11 @@
# Showcase generator — LLM config
OPENROUTER_API_KEY=
SHOWCASE_MODEL=openai/gpt-4o
SHOWCASE_PROVIDER=openrouter
SHOWCASE_BASE_URL=https://openrouter.ai/api/v1
# R2 upload (for --upload flag)
R2_ACCOUNT_ID=
R2_ACCESS_KEY_ID=
R2_SECRET_ACCESS_KEY=
R2_BUCKET=rl-env

View File

@@ -1,2 +1,5 @@
data/raw/
results/
extensions/
showcase-output/
.env

View File

@@ -0,0 +1,26 @@
{
"agent": {
"type": "single",
"provider": "openai-compatible",
"model": "accounts/fireworks/models/kimi-k2p5",
"apiKey": "FIREWORKS_API_KEY",
"baseUrl": "https://api.fireworks.ai/inference/v1",
"supportsImages": true
},
"dataset": "../data/webbench-2of4-50.jsonl",
"num_workers": 10,
"restart_server_per_task": true,
"browseros": {
"server_url": "http://127.0.0.1:9110",
"base_cdp_port": 9010,
"base_server_port": 9110,
"base_extension_port": 9310,
"load_extensions": false,
"headless": true
},
"graders": ["performance_grader"],
"grader_api_key_env": "OPENROUTER_API_KEY",
"grader_base_url": "https://openrouter.ai/api/v1",
"grader_model": "openai/gpt-4.1",
"timeout_ms": 1800000
}

View File

@@ -0,0 +1,33 @@
{
"agent": {
"type": "orchestrator-executor",
"orchestrator": {
"provider": "openai-compatible",
"model": "accounts/fireworks/models/kimi-k2p5",
"apiKey": "FIREWORKS_API_KEY",
"baseUrl": "https://api.fireworks.ai/inference/v1"
},
"executor": {
"provider": "clado-action",
"model": "qwen3-vl-30b-a3b-instruct",
"apiKey": "",
"baseUrl": "https://clado-ai--clado-browseros-action-actionmodel-generate.modal.run"
}
},
"dataset": "../data/webbench-2of4-50.jsonl",
"num_workers": 10,
"restart_server_per_task": true,
"browseros": {
"server_url": "http://127.0.0.1:9110",
"base_cdp_port": 9010,
"base_server_port": 9110,
"base_extension_port": 9310,
"load_extensions": false,
"headless": true
},
"graders": ["performance_grader"],
"grader_api_key_env": "OPENROUTER_API_KEY",
"grader_base_url": "https://openrouter.ai/api/v1",
"grader_model": "openai/gpt-4.1",
"timeout_ms": 1800000
}

View File

@@ -0,0 +1,20 @@
{"query_id":"prod-financial-advisor-morningstar","dataset":"prod","query":"Look up the Morningstar rating and expense ratio for Vanguard Total Stock Market Index Fund (VTSAX) on morningstar.com","start_url":"https://www.morningstar.com","metadata":{"original_task_id":"prod-financial-advisor-morningstar","category":"finance"}}
{"query_id":"prod-lawyer-pacer-search","dataset":"prod","query":"Go to courtlistener.com and search for recent federal court opinions mentioning 'non-compete agreement' from 2025","start_url":"https://www.courtlistener.com","metadata":{"original_task_id":"prod-lawyer-pacer-search","category":"legal"}}
{"query_id":"prod-doctor-drug-interaction","dataset":"prod","query":"Check drug interactions between metformin and lisinopril on drugs.com","start_url":"https://www.drugs.com/drug_interactions.html","metadata":{"original_task_id":"prod-doctor-drug-interaction","category":"healthcare"}}
{"query_id":"prod-software-eng-github-trending","dataset":"prod","query":"Find the top trending Python repositories on GitHub this week and open the most starred one","start_url":"https://github.com/trending","metadata":{"original_task_id":"prod-software-eng-github-trending","category":"technology"}}
{"query_id":"prod-cfo-sec-filing","dataset":"prod","query":"Look up Apple's most recent 10-K filing on SEC EDGAR and find their total revenue for fiscal year 2024","start_url":"https://www.sec.gov/cgi-bin/browse-edgar?action=getcompany&company=apple&CIK=&type=10-K&dateb=&owner=include&count=10&search_text=&action=getcompany","metadata":{"original_task_id":"prod-cfo-sec-filing","category":"finance"}}
{"query_id":"prod-realtor-zillow-comp","dataset":"prod","query":"Search for recently sold homes in Palo Alto, CA on Zillow and filter for 3+ bedrooms sold in the last 30 days","start_url":"https://www.zillow.com","metadata":{"original_task_id":"prod-realtor-zillow-comp","category":"real_estate"}}
{"query_id":"prod-hr-manager-linkedin-job","dataset":"prod","query":"Post a job listing search on LinkedIn for a Senior Product Manager role in San Francisco with salary range $180k-$220k","start_url":"https://www.linkedin.com/jobs/","metadata":{"original_task_id":"prod-hr-manager-linkedin-job","category":"hr"}}
{"query_id":"prod-analyst-fred-data","dataset":"prod","query":"Go to FRED and pull up the US Consumer Price Index chart, change the time range to the last 5 years","start_url":"https://fred.stlouisfed.org","metadata":{"original_task_id":"prod-analyst-fred-data","category":"finance"}}
{"query_id":"prod-accountant-irs-form","dataset":"prod","query":"Find and download the latest IRS Form W-9 from irs.gov","start_url":"https://www.irs.gov","metadata":{"original_task_id":"prod-accountant-irs-form","category":"finance"}}
{"query_id":"prod-sales-manager-crm-research","dataset":"prod","query":"Go to g2.com and compare the top 3 CRM software platforms by user rating and pricing","start_url":"https://www.g2.com/categories/crm","metadata":{"original_task_id":"prod-sales-manager-crm-research","category":"sales"}}
{"query_id":"prod-engineer-stackoverflow","dataset":"prod","query":"Search Stack Overflow for how to implement retry logic with exponential backoff in Python and find the highest voted answer","start_url":"https://stackoverflow.com","metadata":{"original_task_id":"prod-engineer-stackoverflow","category":"technology"}}
{"query_id":"prod-pm-producthunt","dataset":"prod","query":"Browse today's top launches on Product Hunt and upvote the highest ranked AI product","start_url":"https://www.producthunt.com","metadata":{"original_task_id":"prod-pm-producthunt","category":"technology"}}
{"query_id":"prod-pharmacist-fda-recall","dataset":"prod","query":"Check the FDA website for any recent drug recalls in the last month","start_url":"https://www.fda.gov/safety/recalls-market-withdrawals-safety-alerts","metadata":{"original_task_id":"prod-pharmacist-fda-recall","category":"healthcare"}}
{"query_id":"prod-investment-analyst-yahoo-finance","dataset":"prod","query":"Look up NVIDIA stock on Yahoo Finance, check the P/E ratio, and add it to a watchlist","start_url":"https://finance.yahoo.com","metadata":{"original_task_id":"prod-investment-analyst-yahoo-finance","category":"finance"}}
{"query_id":"prod-compliance-officer-regulations","dataset":"prod","query":"Search for the latest GDPR enforcement actions on the European Data Protection Board website","start_url":"https://www.edpb.europa.eu/news/news_en","metadata":{"original_task_id":"prod-compliance-officer-regulations","category":"compliance"}}
{"query_id":"prod-management-consultant-mckinsey","dataset":"prod","query":"Go to McKinsey's insights page and find their latest article about generative AI's impact on productivity","start_url":"https://www.mckinsey.com/featured-insights","metadata":{"original_task_id":"prod-management-consultant-mckinsey","category":"consulting"}}
{"query_id":"prod-operations-manager-shipping","dataset":"prod","query":"Track a FedEx package with tracking number 123456789012 on fedex.com","start_url":"https://www.fedex.com/en-us/tracking.html","metadata":{"original_task_id":"prod-operations-manager-shipping","category":"operations"}}
{"query_id":"prod-market-researcher-statista","dataset":"prod","query":"Search Statista for the global AI market size forecast and find the projected value for 2026","start_url":"https://www.statista.com","metadata":{"original_task_id":"prod-market-researcher-statista","category":"research"}}
{"query_id":"prod-nurse-uptodate","dataset":"prod","query":"Search WebMD for the recommended dosing guidelines for adult acetaminophen and check the maximum daily dose","start_url":"https://www.webmd.com","metadata":{"original_task_id":"prod-nurse-uptodate","category":"healthcare"}}
{"query_id":"prod-executive-flights","dataset":"prod","query":"Search Google Flights for a business class round trip from SFO to JFK departing next Monday returning Friday","start_url":"https://www.google.com/travel/flights","metadata":{"original_task_id":"prod-executive-flights","category":"travel"}}

View File

@@ -0,0 +1 @@
{"query_id":"showcase-amazon-order","dataset":"showcase","query":"Open amazon.com and order Sensodyne toothpaste","start_url":"https://www.amazon.com","metadata":{"original_task_id":"showcase-amazon-order"}}

View File

@@ -9,12 +9,13 @@
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.63",
"@aws-sdk/client-s3": "^3.1014.0",
"@browseros/server": "workspace:*",
"@browseros/shared": "workspace:*",
"@google/gemini-cli-core": "^0.16.0",
"ai": "^6.0.94",
"@google/genai": "1.30.0",
"@modelcontextprotocol/sdk": "^1.25.2",
"ai": "^6.0.94",
"hono": "^4.6.0",
"openai": "^4.0.0",
"sharp": "^0.34.5",

View File

@@ -0,0 +1,52 @@
import type { Browser } from '@browseros/server/browser'
const CROSSHAIR_ID = '__browseros_showcase_crosshair__'
export async function injectCrosshair(
browser: Browser,
pageId: number,
coords: { x: number; y: number },
toolName: string,
): Promise<void> {
const x = Math.round(coords.x)
const y = Math.round(coords.y)
const label = toolName.replace(/_/g, ' ')
const labelWidth = Math.round(label.length * 9 + 24)
const labelX = x + 32
const labelY = y - 32
await browser.evaluate(
pageId,
`(() => {
const existing = document.getElementById('${CROSSHAIR_ID}');
if (existing) existing.remove();
const el = document.createElement('div');
el.id = '${CROSSHAIR_ID}';
el.style.cssText = 'position:fixed;top:0;left:0;width:100vw;height:100vh;pointer-events:none;z-index:2147483647';
el.innerHTML = '<svg style="position:absolute;top:0;left:0;width:100%;height:100%" xmlns="http://www.w3.org/2000/svg">'
+ '<defs><filter id="glow"><feGaussianBlur stdDeviation="3" result="blur"/><feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge></filter></defs>'
+ '<circle cx="${x}" cy="${y}" r="28" fill="rgba(255,59,48,0.12)" stroke="none"/>'
+ '<line x1="${x - 40}" y1="${y}" x2="${x - 12}" y2="${y}" stroke="#FF3B30" stroke-width="3" filter="url(#glow)"/>'
+ '<line x1="${x + 12}" y1="${y}" x2="${x + 40}" y2="${y}" stroke="#FF3B30" stroke-width="3" filter="url(#glow)"/>'
+ '<line x1="${x}" y1="${y - 40}" x2="${x}" y2="${y - 12}" stroke="#FF3B30" stroke-width="3" filter="url(#glow)"/>'
+ '<line x1="${x}" y1="${y + 12}" x2="${x}" y2="${y + 40}" stroke="#FF3B30" stroke-width="3" filter="url(#glow)"/>'
+ '<circle cx="${x}" cy="${y}" r="20" fill="none" stroke="#FF3B30" stroke-width="3" filter="url(#glow)"/>'
+ '<circle cx="${x}" cy="${y}" r="4" fill="#FF3B30"/>'
+ '<rect x="${labelX}" y="${labelY}" rx="6" ry="6" width="${labelWidth}" height="28" fill="rgba(0,0,0,0.85)"/>'
+ '<text x="${labelX + 12}" y="${labelY + 19}" font-family="system-ui,-apple-system,sans-serif" font-size="14" fill="white" font-weight="600">${label}</text>'
+ '</svg>';
document.body.appendChild(el);
})()`,
)
}
export async function removeCrosshair(
browser: Browser,
pageId: number,
): Promise<void> {
await browser.evaluate(
pageId,
`document.getElementById('${CROSSHAIR_ID}')?.remove()`,
)
}

View File

@@ -0,0 +1,288 @@
import { randomUUID } from 'node:crypto'
import { mkdir, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { AiSdkAgent } from '@browseros/server/agent/tool-loop'
import type { ResolvedAgentConfig } from '@browseros/server/agent/types'
import { Browser } from '@browseros/server/browser'
import { CdpBackend } from '@browseros/server/browser/backends/cdp'
import { registry } from '@browseros/server/tools/registry'
import type { Task } from '../../src/types'
import { injectCrosshair, removeCrosshair } from './crosshair'
import { buildTaskManifest, saveTaskManifest } from './manifest'
import type { ShowcaseStep, ShowcaseTaskManifest } from './types'
const ELEMENT_TOOLS = new Set([
'click',
'fill',
'hover',
'clear',
'select_option',
'drag',
'focus',
'check',
'uncheck',
])
const COORDINATE_TOOLS = new Set(['click_at', 'hover_at', 'type_at', 'drag_at'])
const CONTROLLER_STUB = {
start: async () => {},
stop: async () => {},
isConnected: () => false,
send: async () => ({}),
// biome-ignore lint/suspicious/noExplicitAny: ControllerBackend type not exported
} as any
async function resolvePageId(
browser: Browser,
requestedId: number,
): Promise<number> {
const pages = await browser.listPages()
if (pages.some((p) => p.pageId === requestedId)) return requestedId
if (pages.length > 0) return pages[0].pageId
return requestedId
}
export interface ExecuteTaskResult {
manifest: ShowcaseTaskManifest
status: 'completed' | 'timeout' | 'failed'
}
export async function executeShowcaseTask(
task: Task,
cdpPort: number,
outputDir: string,
agentConfig: {
model: string
provider: string
apiKey?: string
baseUrl?: string
},
timeoutMs: number,
): Promise<ExecuteTaskResult> {
const executionId = randomUUID()
const taskDir = join(outputDir, executionId)
const screenshotDir = join(taskDir, 'screenshots')
await mkdir(screenshotDir, { recursive: true })
const cdp = new CdpBackend({ port: cdpPort })
await cdp.connect()
const browser = new Browser(cdp, CONTROLLER_STUB)
const pages = await browser.listPages()
const activePage = pages[0]
let activePageId = activePage?.pageId ?? 1
// Navigate to start URL
if (task.start_url && task.start_url !== 'about:blank') {
await browser.goto(activePageId, task.start_url)
}
const conversationId = randomUUID()
const resolvedConfig: ResolvedAgentConfig = {
conversationId,
// biome-ignore lint/suspicious/noExplicitAny: LLMProvider type validated at runtime
provider: agentConfig.provider as any,
model: agentConfig.model,
apiKey: agentConfig.apiKey,
baseUrl: agentConfig.baseUrl,
workingDir: `/tmp/browseros-showcase-${conversationId}`,
evalMode: true,
supportsImages: true,
}
const browserContext = activePage
? {
activeTab: {
id: activePage.tabId,
pageId: activePage.pageId,
url: activePage.url,
title: activePage.title,
},
}
: undefined
let agent: AiSdkAgent | null = null
const steps: ShowcaseStep[] = []
let stepNum = 0
let finalText: string | null = null
let status: 'completed' | 'timeout' | 'failed' = 'completed'
const startTime = Date.now()
try {
agent = await AiSdkAgent.create({
resolvedConfig,
browser,
registry,
browserContext,
})
let pendingStep: Partial<ShowcaseStep> | null = null
const abortController = new AbortController()
const timeoutHandle = setTimeout(() => abortController.abort(), timeoutMs)
try {
const result = await agent.toolLoopAgent.generate({
prompt: task.query,
abortSignal: abortController.signal,
experimental_onToolCallStart: async ({ toolCall }) => {
try {
const input = (toolCall.input ?? {}) as Record<string, unknown>
if (typeof input.page === 'number') {
activePageId = input.page
}
const pageId = await resolvePageId(browser, activePageId)
activePageId = pageId
const beforeResult = await browser.screenshot(pageId, {
format: 'png',
fullPage: false,
})
const beforePath = join(screenshotDir, `${stepNum}_before.png`)
await writeFile(
beforePath,
Buffer.from(beforeResult.data, 'base64'),
)
let axTree = ''
try {
axTree = await browser.snapshot(pageId)
} catch {
// snapshot can fail on some pages
}
let coords: { x: number; y: number } | undefined
const elementId = input.element as number | undefined
if (
elementId !== undefined &&
ELEMENT_TOOLS.has(toolCall.toolName)
) {
try {
coords = await browser.getElementCenter(pageId, elementId)
} catch {
// element may have been removed
}
} else if (
COORDINATE_TOOLS.has(toolCall.toolName) &&
typeof input.x === 'number' &&
typeof input.y === 'number'
) {
coords = { x: input.x, y: input.y }
}
pendingStep = {
stepIndex: stepNum,
toolName: toolCall.toolName,
toolInput: input,
beforeScreenshot: beforePath,
accessibilitySnapshot: axTree,
elementCoordinates: coords,
timestamp: new Date().toISOString(),
}
if (coords) {
try {
await injectCrosshair(
browser,
pageId,
coords,
toolCall.toolName,
)
const annotatedResult = await browser.screenshot(pageId, {
format: 'png',
fullPage: false,
})
const annotatedPath = join(
screenshotDir,
`${stepNum}_annotated.png`,
)
await writeFile(
annotatedPath,
Buffer.from(annotatedResult.data, 'base64'),
)
pendingStep.annotatedScreenshot = annotatedPath
await removeCrosshair(browser, pageId)
} catch {
// annotation is best-effort
}
}
} catch (err) {
console.warn(
` Step ${stepNum} before-capture failed: ${err instanceof Error ? err.message : String(err)}`,
)
}
},
experimental_onToolCallFinish: async ({ toolResult }) => {
try {
const pageId = await resolvePageId(browser, activePageId)
activePageId = pageId
const afterResult = await browser.screenshot(pageId, {
format: 'png',
fullPage: false,
})
const afterPath = join(screenshotDir, `${stepNum}_after.png`)
await writeFile(afterPath, Buffer.from(afterResult.data, 'base64'))
if (pendingStep) {
pendingStep.afterScreenshot = afterPath
pendingStep.toolOutput = toolResult
steps.push(pendingStep as ShowcaseStep)
stepNum++
}
} catch (err) {
console.warn(
` Step ${stepNum} after-capture failed: ${err instanceof Error ? err.message : String(err)}`,
)
}
pendingStep = null
},
onStepFinish: async ({ text }) => {
if (text && steps.length > 0) {
const lastStep = steps[steps.length - 1]
lastStep.assistantText = text
}
},
})
finalText = result.text || null
} catch (err) {
if (abortController.signal.aborted) {
status = 'timeout'
console.log(` ${task.query_id}: timed out after ${timeoutMs / 1000}s`)
} else {
status = 'failed'
console.error(
` ${task.query_id}: failed — ${err instanceof Error ? err.message : String(err)}`,
)
}
} finally {
clearTimeout(timeoutHandle)
}
const totalDurationMs = Date.now() - startTime
const manifest = buildTaskManifest({
executionId,
taskId: task.query_id,
query: task.query,
startUrl: task.start_url ?? 'about:blank',
dataset: task.dataset,
steps,
finalAnswer: finalText,
model: agentConfig.model,
provider: agentConfig.provider,
totalDurationMs,
})
await saveTaskManifest(outputDir, executionId, manifest)
return { manifest, status }
} finally {
if (agent) await agent.dispose().catch(() => {})
await cdp.disconnect().catch(() => {})
}
}

View File

@@ -0,0 +1,235 @@
#!/usr/bin/env bun
import { mkdir } from 'node:fs/promises'
import { parseArgs } from 'node:util'
import { BrowserOSAppManager } from '../../src/runner/browseros-app-manager'
import { loadTasks } from '../../src/runner/task-loader'
import type { Task } from '../../src/types'
import { executeShowcaseTask } from './executor'
import { saveRunIndex } from './manifest'
import type { ShowcaseRunIndex } from './types'
import { uploadShowcase } from './uploader'
const BASE_PORTS = { cdp: 9010, server: 9110, extension: 9310 }
const { values } = parseArgs({
args: Bun.argv.slice(2),
options: {
tasks: { type: 'string', short: 't' },
output: { type: 'string', short: 'o', default: './showcase-output' },
model: { type: 'string', short: 'm' },
provider: { type: 'string', short: 'p' },
'base-url': { type: 'string' },
workers: { type: 'string', short: 'w', default: '1' },
'cdp-port': { type: 'string' },
timeout: { type: 'string', default: '300000' },
upload: { type: 'boolean', default: false },
help: { type: 'boolean', short: 'h' },
},
})
if (values.help || !values.tasks) {
console.log(`
Showcase Dataset Generator
Runs the BrowserOS agent on tasks and captures before/after screenshots
with crosshair annotations for element-targeting tool calls.
Usage:
bun scripts/showcase/generate.ts --tasks <path> [options]
Options:
-t, --tasks <path> JSONL task file (required)
-o, --output <dir> Output directory (default: ./showcase-output)
-m, --model <model> LLM model (env: SHOWCASE_MODEL, default: openai/gpt-4o)
-p, --provider <name> LLM provider (env: SHOWCASE_PROVIDER, default: openrouter)
--base-url <url> LLM base URL (env: SHOWCASE_BASE_URL)
-w, --workers <n> Parallel workers (default: 1)
--cdp-port <port> Connect to existing Chrome (single-worker only)
--timeout <ms> Per-task timeout in ms (default: 300000)
--upload Upload results to R2 after generation
-h, --help Show this help
`)
process.exit(values.help ? 0 : 1)
}
const config = {
tasks: values.tasks as string,
output: (values.output ?? './showcase-output') as string,
model: (values.model ??
process.env.SHOWCASE_MODEL ??
'openai/gpt-4o') as string,
provider: (values.provider ??
process.env.SHOWCASE_PROVIDER ??
'openrouter') as string,
baseUrl: (values['base-url'] ?? process.env.SHOWCASE_BASE_URL) as
| string
| undefined,
workers: Math.max(1, Number(values.workers ?? '1')),
cdpPort: values['cdp-port'] ? Number(values['cdp-port']) : undefined,
timeout: Number(values.timeout ?? '300000'),
upload: values.upload ?? false,
}
if (config.cdpPort && config.workers > 1) {
console.error('--cdp-port only works with a single worker (--workers 1)')
process.exit(1)
}
const apiKey = process.env.OPENROUTER_API_KEY ?? process.env.OPENAI_API_KEY
if (!apiKey) {
console.error(
'Missing API key: set OPENROUTER_API_KEY or OPENAI_API_KEY environment variable',
)
process.exit(1)
}
const { tasks } = await loadTasks({ type: 'file', path: config.tasks })
console.log(`Loaded ${tasks.length} task(s), ${config.workers} worker(s)`)
await mkdir(config.output, { recursive: true })
const runId = `${new Date().toISOString().slice(0, 10)}-${crypto.randomUUID().slice(0, 8)}`
const runIndex: ShowcaseRunIndex = {
runId,
createdAt: new Date().toISOString(),
agentConfig: { model: config.model, provider: config.provider },
tasks: [],
}
console.log(`\nRun ID: ${runId}`)
console.log(`Output: ${config.output}\n`)
// --- Task Queue ---
class TaskQueue {
private index = 0
private stopped = false
constructor(private tasks: Task[]) {}
next(): Task | null {
if (this.stopped || this.index >= this.tasks.length) return null
return this.tasks[this.index++]
}
stop(): void {
this.stopped = true
}
}
const queue = new TaskQueue(tasks)
let completedCount = 0
const appManagers: BrowserOSAppManager[] = []
// --- Signal handling ---
const onSignal = async () => {
console.log('\nShutting down workers...')
queue.stop()
await Promise.allSettled(appManagers.map((m) => m.killApp()))
process.exit(0)
}
process.on('SIGINT', onSignal)
process.on('SIGTERM', onSignal)
// --- Worker ---
async function runWorker(workerIndex: number): Promise<void> {
let appManager: BrowserOSAppManager | null = null
let cdpPort = config.cdpPort ?? BASE_PORTS.cdp + workerIndex
if (!config.cdpPort) {
appManager = new BrowserOSAppManager(workerIndex, BASE_PORTS)
appManagers.push(appManager)
console.log(` [W${workerIndex}] Starting BrowserOS...`)
await appManager.restart()
cdpPort = BASE_PORTS.cdp + workerIndex
}
const agentConfig = {
model: config.model,
provider: config.provider,
apiKey,
baseUrl: config.baseUrl,
}
try {
while (true) {
const task = queue.next()
if (!task) break
completedCount++
const tag = config.workers > 1 ? `[W${workerIndex}] ` : ''
console.log(
`${tag}[${completedCount}/${tasks.length}] ${task.query_id}: ${task.query}`,
)
// Restart browser between tasks for clean state
if (appManager) {
await appManager.restart()
}
try {
const { manifest, status } = await executeShowcaseTask(
task,
cdpPort,
config.output,
agentConfig,
config.timeout,
)
runIndex.tasks.push({
executionId: manifest.executionId,
taskId: task.query_id,
query: task.query,
stepCount: manifest.steps.length,
status,
manifestPath: `${manifest.executionId}/manifest.json`,
})
const duration = (manifest.totalDurationMs / 1000).toFixed(1)
console.log(
`${tag} ${status.toUpperCase()}${manifest.steps.length} steps, ${duration}s\n`,
)
} catch (err) {
console.error(
`${tag} FAILED — ${err instanceof Error ? err.message : String(err)}\n`,
)
runIndex.tasks.push({
executionId: 'unknown',
taskId: task.query_id,
query: task.query,
stepCount: 0,
status: 'failed',
manifestPath: '',
})
}
}
} finally {
if (appManager) await appManager.killApp()
}
}
// --- Run ---
try {
const workers = Array.from({ length: config.workers }, (_, i) => runWorker(i))
await Promise.all(workers)
await saveRunIndex(config.output, runIndex)
console.log(`\nResults saved to: ${config.output}`)
console.log(
`Tasks: ${runIndex.tasks.filter((t) => t.status === 'completed').length} completed, ` +
`${runIndex.tasks.filter((t) => t.status === 'failed').length} failed, ` +
`${runIndex.tasks.filter((t) => t.status === 'timeout').length} timed out`,
)
if (config.upload) {
console.log('\nUploading to R2...')
const baseUrl = await uploadShowcase(config.output, runId)
console.log(`Uploaded to: ${baseUrl}`)
}
} finally {
process.off('SIGINT', onSignal)
process.off('SIGTERM', onSignal)
}

View File

@@ -0,0 +1,52 @@
import { writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import type {
ShowcaseRunIndex,
ShowcaseStep,
ShowcaseTaskManifest,
} from './types'
export function buildTaskManifest(opts: {
executionId: string
taskId: string
query: string
startUrl: string
dataset: string
steps: ShowcaseStep[]
finalAnswer: string | null
model: string
provider: string
totalDurationMs: number
}): ShowcaseTaskManifest {
return {
executionId: opts.executionId,
taskId: opts.taskId,
query: opts.query,
startUrl: opts.startUrl,
dataset: opts.dataset,
steps: opts.steps,
finalAnswer: opts.finalAnswer,
agentConfig: { model: opts.model, provider: opts.provider },
totalDurationMs: opts.totalDurationMs,
createdAt: new Date().toISOString(),
}
}
export async function saveTaskManifest(
outputDir: string,
executionId: string,
manifest: ShowcaseTaskManifest,
): Promise<string> {
const manifestPath = join(outputDir, executionId, 'manifest.json')
await writeFile(manifestPath, JSON.stringify(manifest, null, 2))
return manifestPath
}
export async function saveRunIndex(
outputDir: string,
index: ShowcaseRunIndex,
): Promise<string> {
const indexPath = join(outputDir, 'index.json')
await writeFile(indexPath, JSON.stringify(index, null, 2))
return indexPath
}

View File

@@ -0,0 +1,53 @@
export interface ShowcaseConfig {
tasks: string
output: string
upload: boolean
model: string
provider: string
apiKeyEnv: string
cdpPort?: number
timeout: number
}
export interface ShowcaseStep {
stepIndex: number
toolName: string
toolInput: Record<string, unknown>
toolOutput: unknown
elementCoordinates?: { x: number; y: number }
beforeScreenshot: string
afterScreenshot: string
annotatedScreenshot?: string
accessibilitySnapshot: string
assistantText?: string
timestamp: string
}
export interface ShowcaseTaskManifest {
executionId: string
taskId: string
query: string
startUrl: string
dataset: string
steps: ShowcaseStep[]
finalAnswer: string | null
agentConfig: { model: string; provider: string }
totalDurationMs: number
createdAt: string
uploadedAt?: string
}
export interface ShowcaseRunIndex {
runId: string
createdAt: string
uploadedAt?: string
agentConfig: { model: string; provider: string }
tasks: Array<{
executionId: string
taskId: string
query: string
stepCount: number
status: 'completed' | 'timeout' | 'failed'
manifestPath: string
}>
}

View File

@@ -0,0 +1,155 @@
import { readdir, readFile, writeFile } from 'node:fs/promises'
import { extname, join, relative } from 'node:path'
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3'
import type { ShowcaseRunIndex, ShowcaseTaskManifest } from './types'
interface R2Config {
accountId: string
accessKeyId: string
secretAccessKey: string
bucket: string
}
function contentTypeFor(filePath: string): string {
const ext = extname(filePath).toLowerCase()
if (ext === '.png') return 'image/png'
if (ext === '.json') return 'application/json'
if (ext === '.jsonl') return 'application/jsonl'
return 'application/octet-stream'
}
function loadR2Config(): R2Config {
const accountId = process.env.R2_ACCOUNT_ID
const accessKeyId = process.env.R2_ACCESS_KEY_ID
const secretAccessKey = process.env.R2_SECRET_ACCESS_KEY
const bucket = process.env.R2_BUCKET ?? 'rl-env'
if (!accountId || !accessKeyId || !secretAccessKey) {
throw new Error(
'Missing R2 credentials. Set R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY',
)
}
return { accountId, accessKeyId, secretAccessKey, bucket }
}
function toR2Key(prefix: string, outputDir: string, filePath: string): string {
return `${prefix}/${relative(outputDir, filePath).replaceAll('\\', '/')}`
}
async function walkDir(dir: string): Promise<string[]> {
const files: string[] = []
const entries = await readdir(dir, { withFileTypes: true })
for (const entry of entries) {
const fullPath = join(dir, entry.name)
if (entry.isDirectory()) {
files.push(...(await walkDir(fullPath)))
} else {
files.push(fullPath)
}
}
return files
}
export async function uploadShowcase(
outputDir: string,
runId: string,
): Promise<string> {
const r2 = loadR2Config()
const client = new S3Client({
region: 'auto',
endpoint: `https://${r2.accountId}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: r2.accessKeyId,
secretAccessKey: r2.secretAccessKey,
},
})
const prefix = `showcase/${runId}`
const files = await walkDir(outputDir)
console.log(
`Uploading ${files.length} files to R2 (${r2.bucket}/${prefix})...`,
)
for (const filePath of files) {
const key = toR2Key(prefix, outputDir, filePath)
const data = await readFile(filePath)
await client.send(
new PutObjectCommand({
Bucket: r2.bucket,
Key: key,
Body: data,
ContentType: contentTypeFor(filePath),
}),
)
}
const baseUrl = `https://${r2.bucket}.${r2.accountId}.r2.cloudflarestorage.com/${prefix}`
console.log(`Upload complete. Base: ${baseUrl}`)
// Stamp uploadedAt on index.json
const indexPath = join(outputDir, 'index.json')
try {
const indexData = JSON.parse(
await readFile(indexPath, 'utf-8'),
) as ShowcaseRunIndex
indexData.uploadedAt = new Date().toISOString()
await writeFile(indexPath, JSON.stringify(indexData, null, 2))
} catch {
// index may not exist if run was partial
}
// Stamp uploadedAt on each task manifest
for (const file of files) {
if (file.endsWith('manifest.json') && file !== indexPath) {
try {
const manifestData = JSON.parse(
await readFile(file, 'utf-8'),
) as ShowcaseTaskManifest
manifestData.uploadedAt = new Date().toISOString()
// Rewrite screenshot paths to R2 keys
for (const step of manifestData.steps) {
step.beforeScreenshot = toR2Key(
prefix,
outputDir,
step.beforeScreenshot,
)
step.afterScreenshot = toR2Key(
prefix,
outputDir,
step.afterScreenshot,
)
if (step.annotatedScreenshot) {
step.annotatedScreenshot = toR2Key(
prefix,
outputDir,
step.annotatedScreenshot,
)
}
}
await writeFile(file, JSON.stringify(manifestData, null, 2))
} catch {
// skip malformed manifests
}
}
}
// Re-upload rewritten manifests + index
const jsonFiles = files.filter(
(f) => f.endsWith('.json') && !f.includes('node_modules'),
)
for (const filePath of jsonFiles) {
const key = toR2Key(prefix, outputDir, filePath)
const data = await readFile(filePath)
await client.send(
new PutObjectCommand({
Bucket: r2.bucket,
Key: key,
Body: data,
ContentType: 'application/json',
}),
)
}
return baseUrl
}

View File

@@ -0,0 +1,349 @@
/**
* Upload eval runs to R2.
*
* Two modes:
* bun scripts/upload-run.ts results/browseros-agent-weekly/2026-03-21-1730
* → uploads that specific run
*
* bun scripts/upload-run.ts results/browseros-agent-weekly
* → finds all timestamped subfolders, uploads any not yet in R2
*
* Env vars: EVAL_R2_ACCOUNT_ID, EVAL_R2_ACCESS_KEY_ID, EVAL_R2_SECRET_ACCESS_KEY
* EVAL_R2_BUCKET (default: browseros-eval)
* EVAL_R2_CDN_BASE_URL (default: https://eval.browseros.com)
*/
import { readdir, readFile, stat } from 'node:fs/promises'
import { basename, dirname, extname, join } from 'node:path'
import {
GetObjectCommand,
PutObjectCommand,
S3Client,
} from '@aws-sdk/client-s3'
const CONCURRENCY = 20
const CONTENT_TYPES: Record<string, string> = {
'.json': 'application/json',
'.jsonl': 'application/x-ndjson',
'.png': 'image/png',
}
interface R2Config {
accountId: string
accessKeyId: string
secretAccessKey: string
bucket: string
cdnBaseUrl: string
}
function loadConfig(): R2Config {
const accountId = process.env.EVAL_R2_ACCOUNT_ID
const accessKeyId = process.env.EVAL_R2_ACCESS_KEY_ID
const secretAccessKey = process.env.EVAL_R2_SECRET_ACCESS_KEY
if (!accountId || !accessKeyId || !secretAccessKey) {
console.error(
'Missing required env vars: EVAL_R2_ACCOUNT_ID, EVAL_R2_ACCESS_KEY_ID, EVAL_R2_SECRET_ACCESS_KEY',
)
process.exit(1)
}
return {
accountId,
accessKeyId,
secretAccessKey,
bucket: process.env.EVAL_R2_BUCKET || 'browseros-eval',
cdnBaseUrl: (
process.env.EVAL_R2_CDN_BASE_URL || 'https://eval.browseros.com'
).replace(/\/+$/, ''),
}
}
function createClient(config: R2Config): S3Client {
return new S3Client({
region: 'auto',
endpoint: `https://${config.accountId}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
},
})
}
async function upload(
client: S3Client,
bucket: string,
key: string,
body: Buffer,
contentType: string,
) {
await client.send(
new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: body,
ContentType: contentType,
}),
)
}
async function collectFiles(dir: string): Promise<string[]> {
const files: string[] = []
const entries = await readdir(dir, { withFileTypes: true })
for (const entry of entries) {
const full = join(dir, entry.name)
if (entry.isDirectory()) {
files.push(...(await collectFiles(full)))
} else {
files.push(full)
}
}
return files
}
async function runPool<T>(
items: T[],
concurrency: number,
fn: (item: T) => Promise<void>,
) {
let i = 0
const workers = Array.from({ length: concurrency }, async () => {
while (i < items.length) {
const idx = i++
await fn(items[idx])
}
})
await Promise.all(workers)
}
// Check if a run has already been uploaded to R2
async function isUploaded(
client: S3Client,
bucket: string,
runId: string,
): Promise<boolean> {
try {
await client.send(
new GetObjectCommand({
Bucket: bucket,
Key: `runs/${runId}/manifest.json`,
}),
)
return true
} catch {
return false
}
}
// Detect if a directory is a run dir (has task subdirs with metadata.json)
// vs a config dir (has timestamped subdirs like 2026-03-21-1730/)
async function isRunDir(dir: string): Promise<boolean> {
const entries = await readdir(dir, { withFileTypes: true })
const subdirs = entries.filter((e) => e.isDirectory())
for (const subdir of subdirs) {
const metaPath = join(dir, subdir.name, 'metadata.json')
const metaStat = await stat(metaPath).catch(() => null)
if (metaStat?.isFile()) return true
}
return false
}
async function uploadSingleRun(
runDir: string,
runId: string,
r2Config: R2Config,
client: S3Client,
): Promise<void> {
const taskDirs = await readdir(runDir, { withFileTypes: true })
const taskEntries = taskDirs.filter((d) => d.isDirectory())
if (taskEntries.length === 0) {
console.warn(` No task subdirectories in ${runId}, skipping`)
return
}
const manifestTasks: Record<string, unknown>[] = []
const jobs: { key: string; filePath: string; contentType: string }[] = []
// Extract agent config from first task
let agentConfig: Record<string, unknown> | undefined
let dataset: string | undefined
for (const taskDir of taskEntries) {
const taskId = taskDir.name
const taskPath = join(runDir, taskId)
const metaPath = join(taskPath, 'metadata.json')
let meta: Record<string, unknown> = {}
try {
meta = JSON.parse(await readFile(metaPath, 'utf-8'))
} catch {
continue
}
if (!agentConfig && meta.agent_config)
agentConfig = meta.agent_config as Record<string, unknown>
if (!dataset && meta.dataset) dataset = meta.dataset as string
const files = await collectFiles(taskPath)
let screenshotCount = 0
for (const file of files) {
const relative = file.slice(taskPath.length + 1)
const ext = extname(file)
if (relative.startsWith('screenshots/') && ext === '.png')
screenshotCount++
jobs.push({
key: `runs/${runId}/${taskId}/${relative}`,
filePath: file,
contentType: CONTENT_TYPES[ext] || 'application/octet-stream',
})
}
manifestTasks.push({
queryId: meta.query_id || taskId,
query: meta.query || '',
startUrl: meta.start_url || '',
status:
meta.termination_reason === 'completed'
? 'completed'
: meta.termination_reason || 'unknown',
durationMs: meta.total_duration_ms || 0,
screenshotCount: (meta.screenshot_count as number) || screenshotCount,
graderResults: meta.grader_results || {},
})
}
if (manifestTasks.length === 0) {
console.warn(` No completed tasks in ${runId}, skipping`)
return
}
console.log(
` Uploading ${jobs.length} files across ${manifestTasks.length} tasks...`,
)
let uploaded = 0
await runPool(jobs, CONCURRENCY, async (job) => {
const body = await readFile(job.filePath)
await upload(client, r2Config.bucket, job.key, body, job.contentType)
uploaded++
if (uploaded % 50 === 0 || uploaded === jobs.length) {
console.log(` ${uploaded}/${jobs.length}`)
}
})
// Read summary.json if it exists
let summaryData: Record<string, unknown> | undefined
try {
summaryData = JSON.parse(
await readFile(join(runDir, 'summary.json'), 'utf-8'),
)
} catch {}
// Upload manifest
const manifest = {
runId,
uploadedAt: new Date().toISOString(),
agentConfig,
dataset,
summary: summaryData
? {
passRate: summaryData.passRate,
avgDurationMs: summaryData.avgDurationMs,
}
: undefined,
tasks: manifestTasks,
}
const manifestBody = Buffer.from(JSON.stringify(manifest, null, 2))
await upload(
client,
r2Config.bucket,
`runs/${runId}/manifest.json`,
manifestBody,
'application/json',
)
// Upload viewer.html to bucket root
const viewerPath = join(
import.meta.dir,
'..',
'src',
'dashboard',
'viewer.html',
)
const viewerBody = await readFile(viewerPath)
await upload(client, r2Config.bucket, 'viewer.html', viewerBody, 'text/html')
console.log(` Uploaded ${uploaded + 2} files`)
console.log(` ${r2Config.cdnBaseUrl}/viewer.html?run=${runId}`)
}
async function main() {
const inputDir = process.argv[2]
if (!inputDir) {
console.error(
'Usage:\n' +
' bun scripts/upload-run.ts results/config-name/2026-03-21-1730 (specific run)\n' +
' bun scripts/upload-run.ts results/config-name (all un-uploaded runs)',
)
process.exit(1)
}
const dirStat = await stat(inputDir).catch(() => null)
if (!dirStat?.isDirectory()) {
console.error(`Not a directory: ${inputDir}`)
process.exit(1)
}
const r2Config = loadConfig()
const client = createClient(r2Config)
if (await isRunDir(inputDir)) {
// Single run: results/config-name/2026-03-21-1730
const timestamp = basename(inputDir)
const configName = basename(dirname(inputDir))
const runId = `${configName}-${timestamp}`
console.log(`Uploading run: ${runId}`)
await uploadSingleRun(inputDir, runId, r2Config, client)
} else {
// Config dir: results/config-name/ — upload all un-uploaded runs
const configName = basename(inputDir)
const entries = await readdir(inputDir, { withFileTypes: true })
const runDirs = entries
.filter((e) => e.isDirectory())
.map((e) => e.name)
.sort()
if (runDirs.length === 0) {
console.error('No run subdirectories found')
process.exit(1)
}
console.log(
`Found ${runDirs.length} runs for config "${configName}", checking R2...`,
)
let uploadedCount = 0
for (const dir of runDirs) {
const runId = `${configName}-${dir}`
const alreadyUploaded = await isUploaded(client, r2Config.bucket, runId)
if (alreadyUploaded) {
console.log(` ${runId}: already uploaded, skipping`)
continue
}
console.log(` ${runId}: uploading...`)
await uploadSingleRun(join(inputDir, dir), runId, r2Config, client)
uploadedCount++
}
console.log(
`\nDone. Uploaded ${uploadedCount} new run(s), ${runDirs.length - uploadedCount} already in R2.`,
)
}
}
main()

View File

@@ -0,0 +1,590 @@
/**
* Weekly Report Generator
*
* Reads all uploaded eval runs from R2, builds cumulative score history,
* and generates an HTML dashboard with:
* - Config selector dropdown (groups runs by config/runId pattern)
* - Config details card (architecture, model, dataset, grader)
* - Interactive trend chart (filtered by selected config)
* - Stat cards (latest, trend, best, duration)
* - Searchable table of all runs
*
* Usage:
* bun apps/eval/scripts/weekly-report.ts [local-output-path]
*
* Env vars required:
* EVAL_R2_ACCOUNT_ID, EVAL_R2_ACCESS_KEY_ID, EVAL_R2_SECRET_ACCESS_KEY
* EVAL_R2_BUCKET (default: browseros-eval)
*/
import { writeFile } from 'node:fs/promises'
import {
GetObjectCommand,
ListObjectsV2Command,
PutObjectCommand,
S3Client,
} from '@aws-sdk/client-s3'
interface ManifestTask {
queryId: string
query: string
status: string
durationMs: number
screenshotCount: number
graderResults: Record<string, { pass: boolean; score: number }>
}
interface Manifest {
runId: string
uploadedAt: string
agentConfig?: { type?: string; model?: string }
dataset?: string
summary?: { passRate?: number; avgDurationMs?: number }
tasks: ManifestTask[]
}
interface RunSummary {
runId: string
configName: string
date: string
passRate: number
total: number
completed: number
failed: number
timeout: number
avgDurationMs: number
model: string
dataset: string
agentType: string
}
const PASS_FAIL_GRADER_ORDER = [
'performance_grader',
'webvoyager_grader',
'fara_combined',
'fara_grader',
]
function requireEnv(name: string): string {
const value = process.env[name]
if (!value) {
console.error(`Missing required env var: ${name}`)
process.exit(1)
}
return value
}
const accountId = requireEnv('EVAL_R2_ACCOUNT_ID')
const accessKeyId = requireEnv('EVAL_R2_ACCESS_KEY_ID')
const secretAccessKey = requireEnv('EVAL_R2_SECRET_ACCESS_KEY')
const bucket = process.env.EVAL_R2_BUCKET || 'browseros-eval'
const client = new S3Client({
region: 'auto',
endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
credentials: { accessKeyId, secretAccessKey },
})
// Step 1: List all manifest.json files in runs/
console.log('Scanning R2 for eval runs...')
const manifests: Manifest[] = []
let continuationToken: string | undefined
do {
const listRes = await client.send(
new ListObjectsV2Command({
Bucket: bucket,
Prefix: 'runs/',
ContinuationToken: continuationToken,
}),
)
const manifestKeys =
listRes.Contents?.filter((obj) => obj.Key?.endsWith('/manifest.json')).map(
(obj) => obj.Key as string,
) ?? []
for (const key of manifestKeys) {
try {
const res = await client.send(
new GetObjectCommand({ Bucket: bucket, Key: key }),
)
const body = await res.Body?.transformToString()
if (body) manifests.push(JSON.parse(body))
} catch {
console.warn(` Failed to read ${key}, skipping`)
}
}
continuationToken = listRes.NextContinuationToken
} while (continuationToken)
console.log(`Found ${manifests.length} runs`)
if (manifests.length === 0) {
console.log('No runs found. Nothing to report.')
process.exit(0)
}
// Step 2: Build run summaries
const runs: RunSummary[] = manifests
.map((m) => {
const total = m.tasks.length
const completed = m.tasks.filter((t) => t.status === 'completed').length
const failed = m.tasks.filter((t) => t.status === 'failed').length
const timeout = m.tasks.filter((t) => t.status === 'timeout').length
let graded = 0
let passed = 0
for (const task of m.tasks) {
if (!task.graderResults) continue
for (const name of PASS_FAIL_GRADER_ORDER) {
if (task.graderResults[name]) {
graded++
if (task.graderResults[name].pass) passed++
break
}
}
}
const passRate = graded > 0 ? passed / graded : 0
const durations = m.tasks
.filter((t) => t.durationMs > 0)
.map((t) => t.durationMs)
const avgDurationMs =
durations.length > 0
? durations.reduce((a, b) => a + b, 0) / durations.length
: 0
const date = m.uploadedAt
? `${m.uploadedAt.split('T')[0]} ${m.uploadedAt.split('T')[1]?.slice(0, 5) || ''}`
: m.runId.slice(0, 15)
const model = m.agentConfig?.model || 'unknown'
const dataset = m.dataset || m.runId
const agentType = m.agentConfig?.type || 'unknown'
const configName = extractConfigName(m.runId)
return {
runId: m.runId,
configName,
date,
passRate,
total,
completed,
failed,
timeout,
avgDurationMs,
model,
dataset,
agentType,
}
})
.sort((a, b) => a.date.localeCompare(b.date))
// Step 3: Identify unique config groups
// runId can be "ci-weekly" (old) or "ci-weekly-2026-03-21-1730" (timestamped)
// Extract config name by stripping the date-time suffix pattern
function escHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function extractConfigName(runId: string): string {
// "browseros-agent-weekly-2026-03-21-1730" → "browseros-agent-weekly"
// "ci-weekly" → "ci-weekly" (no timestamp, old format)
return runId.replace(/-\d{4}-\d{2}-\d{2}-\d{4}$/, '')
}
const configGroups = [...new Set(runs.map((r) => r.configName))]
const defaultConfig = configGroups.includes('ci-weekly')
? 'ci-weekly'
: configGroups[0]
// Step 4: Generate HTML report
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BrowserOS Eval Dashboard</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; background: #0d1117; color: #e6edf3; padding: 2rem; max-width: 1400px; margin: 0 auto; }
/* Header */
.page-header { display: flex; align-items: center; gap: 16px; margin-bottom: 2rem; flex-wrap: wrap; }
.page-header h1 { font-size: 1.5rem; }
.page-header h1 span { color: #58a6ff; }
.page-header .gen-date { color: #6e7681; font-size: 12px; margin-left: auto; }
/* Config selector */
.config-bar { display: flex; align-items: center; gap: 16px; margin-bottom: 1.5rem; flex-wrap: wrap; }
.config-bar label { font-size: 13px; color: #8b949e; font-weight: 600; }
.config-bar select { background: #161b22; border: 1px solid #30363d; color: #e6edf3; padding: 8px 12px; border-radius: 6px; font-size: 13px; font-family: 'SF Mono', Consolas, monospace; cursor: pointer; min-width: 200px; }
.config-bar select:focus { outline: none; border-color: #58a6ff; }
/* Config details card */
.config-details { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px 20px; margin-bottom: 1.5rem; display: flex; gap: 32px; flex-wrap: wrap; }
.config-detail { display: flex; flex-direction: column; gap: 2px; }
.config-detail .cd-label { font-size: 10px; font-weight: 600; color: #6e7681; text-transform: uppercase; letter-spacing: 0.04em; }
.config-detail .cd-value { font-size: 13px; color: #e6edf3; font-family: 'SF Mono', Consolas, monospace; }
/* Stat cards */
.stats { display: flex; gap: 1rem; margin-bottom: 1.5rem; flex-wrap: wrap; }
.stat-card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 1.25rem; flex: 1; min-width: 140px; }
.stat-label { color: #8b949e; font-size: 0.8rem; margin-bottom: 0.25rem; }
.stat-value { font-size: 1.4rem; font-weight: 600; }
.stat-value.big { font-size: 2.5rem; font-weight: 700; }
.pass { color: #3fb950; }
.fail { color: #f85149; }
.neutral { color: #8b949e; }
.trend-up { color: #3fb950; }
.trend-down { color: #f85149; }
.trend-flat { color: #8b949e; }
/* Chart */
.chart-container { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 1.5rem; margin-bottom: 2rem; position: relative; }
canvas { width: 100%; height: 300px; }
#tooltip { display: none; position: absolute; background: #1c2128; border: 1px solid #30363d; border-radius: 6px; padding: 8px 12px; pointer-events: none; font-size: 12px; z-index: 10; box-shadow: 0 4px 12px rgba(0,0,0,0.4); }
/* Section headers */
.section-header { display: flex; align-items: center; gap: 12px; margin-bottom: 1rem; }
.section-header h2 { font-size: 1rem; font-weight: 600; }
.section-header .search-input { margin-left: auto; background: #0d1117; border: 1px solid #30363d; color: #e6edf3; padding: 6px 12px; border-radius: 6px; font-size: 12px; font-family: inherit; width: 220px; }
.section-header .search-input:focus { outline: none; border-color: #58a6ff; }
.section-header .search-input::placeholder { color: #484f58; }
/* Table */
table { width: 100%; border-collapse: collapse; background: #161b22; border: 1px solid #30363d; border-radius: 8px; overflow: hidden; }
th, td { padding: 0.65rem 1rem; text-align: left; border-bottom: 1px solid #21262d; }
th { background: #1c2128; color: #8b949e; font-weight: 600; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.03em; }
td { font-size: 0.85rem; }
td.mono { font-family: 'SF Mono', Consolas, monospace; font-size: 0.8rem; }
a.view-link { color: #58a6ff; text-decoration: none; font-weight: 500; }
a.view-link:hover { text-decoration: underline; }
tr.hidden { display: none; }
</style>
</head>
<body>
<div class="page-header">
<h1>BrowserOS <span>Eval Dashboard</span></h1>
<span class="gen-date">Generated ${new Date().toISOString().split('T')[0]}</span>
</div>
<!-- Config selector -->
<div class="config-bar">
<label>Config:</label>
<select id="config-select">
${configGroups.map((c) => `<option value="${escHtml(c)}"${c === defaultConfig ? ' selected' : ''}>${escHtml(c)}</option>`).join('\n ')}
</select>
</div>
<!-- Config details (populated by JS) -->
<div class="config-details" id="config-details"></div>
<!-- Stat cards (populated by JS) -->
<div class="stats" id="stat-cards"></div>
<!-- Chart -->
<div class="chart-container">
<canvas id="chart"></canvas>
<div id="tooltip">
<div id="tt-date" style="color:#8b949e;margin-bottom:2px;"></div>
<div id="tt-score" style="font-size:16px;font-weight:700;"></div>
<div id="tt-detail" style="color:#8b949e;margin-top:2px;font-size:11px;"></div>
</div>
</div>
<!-- Recent runs table -->
<div class="section-header">
<h2>All Runs</h2>
<input type="text" class="search-input" id="table-search" placeholder="Search runs..." autocomplete="off" spellcheck="false" />
</div>
<table id="runs-table">
<thead>
<tr>
<th>Date</th>
<th>Config</th>
<th>Model</th>
<th>Dataset</th>
<th>Architecture</th>
<th>Pass Rate</th>
<th>Tasks</th>
<th>Timeout</th>
<th>Avg Duration</th>
<th>View</th>
</tr>
</thead>
<tbody>
${runs
.slice()
.reverse()
.map((r) => {
const viewerUrl = `viewer.html?run=${encodeURIComponent(r.runId)}`
const passed = Math.round(r.passRate * r.total)
const archLabel =
r.agentType === 'orchestrator-executor'
? 'Orch-Exec'
: r.agentType === 'single'
? 'Tool Loop'
: r.agentType === 'gemini-computer-use'
? 'Gemini CU'
: r.agentType || '—'
return `<tr data-config="${escHtml(r.runId)}" data-search="${escHtml(`${r.date} ${r.runId} ${r.model} ${r.dataset} ${archLabel}`)}">
<td>${escHtml(r.date)}</td>
<td class="mono">${escHtml(r.runId)}</td>
<td class="mono" style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${escHtml(r.model)}">${escHtml(r.model)}</td>
<td>${escHtml(r.dataset)}</td>
<td>${escHtml(archLabel)}</td>
<td class="${r.passRate >= 0.7 ? 'pass' : r.passRate >= 0.4 ? 'neutral' : 'fail'}">${(r.passRate * 100).toFixed(1)}% <span style="color:#6e7681;font-size:11px;">(${passed}/${r.total})</span></td>
<td>${r.total}</td>
<td class="${r.timeout > 0 ? 'neutral' : ''}">${r.timeout}</td>
<td>${(r.avgDurationMs / 1000).toFixed(0)}s</td>
<td><a href="${viewerUrl}" class="view-link">View &rarr;</a></td>
</tr>`
})
.join('\n')}
</tbody>
</table>
<script>
(function() {
'use strict';
var allRuns = ${JSON.stringify(runs)};
var configSelect = document.getElementById('config-select');
var canvas = document.getElementById('chart');
var ctx = canvas.getContext('2d');
var tooltip = document.getElementById('tooltip');
var dpr = window.devicePixelRatio || 1;
var dotPositions = [];
function getFilteredRuns() {
var cfg = configSelect.value;
return allRuns.filter(function(r) { return r.configName === cfg; });
}
function updateDashboard() {
var runs = getFilteredRuns();
renderConfigDetails(runs);
renderStatCards(runs);
drawChart(runs);
}
// Config details card
function renderConfigDetails(runs) {
var el = document.getElementById('config-details');
if (runs.length === 0) { el.innerHTML = '<span style="color:#6e7681;">No runs found for this config.</span>'; return; }
var latest = runs[runs.length - 1];
var archLabel = latest.agentType === 'orchestrator-executor' ? 'Orchestrator-Executor'
: latest.agentType === 'single' ? 'Single Agent (Tool Loop)'
: latest.agentType === 'gemini-computer-use' ? 'Gemini Computer Use'
: latest.agentType || 'Unknown';
el.innerHTML =
'<div class="config-detail"><span class="cd-label">Architecture</span><span class="cd-value">' + archLabel + '</span></div>' +
'<div class="config-detail"><span class="cd-label">Model</span><span class="cd-value">' + (latest.model || 'unknown') + '</span></div>' +
'<div class="config-detail"><span class="cd-label">Dataset</span><span class="cd-value">' + (latest.dataset || 'unknown') + '</span></div>' +
'<div class="config-detail"><span class="cd-label">Tasks</span><span class="cd-value">' + latest.total + '</span></div>' +
'<div class="config-detail"><span class="cd-label">Runs</span><span class="cd-value">' + runs.length + '</span></div>';
}
// Stat cards
function renderStatCards(runs) {
var el = document.getElementById('stat-cards');
if (runs.length === 0) { el.innerHTML = ''; return; }
var latest = runs[runs.length - 1];
var prev = runs.length >= 2 ? runs[runs.length - 2] : null;
var best = Math.max.apply(null, runs.map(function(r) { return r.passRate; }));
var delta = prev ? latest.passRate - prev.passRate : 0;
var sign = delta > 0 ? '+' : '';
var trendCls = delta > 0 ? 'trend-up' : delta < 0 ? 'trend-down' : 'trend-flat';
el.innerHTML =
'<div class="stat-card"><div class="stat-label">Latest Pass Rate</div><div class="stat-value big ' + (latest.passRate >= 0.7 ? 'pass' : 'fail') + '">' + (latest.passRate * 100).toFixed(1) + '%</div></div>' +
'<div class="stat-card"><div class="stat-label">Trend</div><div class="stat-value ' + trendCls + '">' + (prev ? sign + (delta * 100).toFixed(1) + ' pp' : 'N/A') + '</div></div>' +
'<div class="stat-card"><div class="stat-label">Best Score</div><div class="stat-value pass">' + (best * 100).toFixed(1) + '%</div></div>' +
'<div class="stat-card"><div class="stat-label">Avg Duration</div><div class="stat-value">' + (latest.avgDurationMs / 1000).toFixed(0) + 's</div></div>' +
'<div class="stat-card"><div class="stat-label">Runs</div><div class="stat-value">' + runs.length + '</div></div>';
}
// Chart
function drawChart(runs) {
var rect = canvas.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
var W = rect.width, H = rect.height;
var pad = { top: 20, right: 20, bottom: 50, left: 50 };
var plotW = W - pad.left - pad.right;
var plotH = H - pad.top - pad.bottom;
dotPositions = [];
ctx.clearRect(0, 0, W, H);
if (runs.length === 0) {
ctx.fillStyle = '#8b949e';
ctx.font = '14px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('No data for this config', W / 2, H / 2);
return;
}
var scores = runs.map(function(r) { return r.passRate * 100; });
var minY = Math.max(0, Math.floor(Math.min.apply(null, scores) / 10) * 10 - 10);
var maxY = Math.min(100, Math.ceil(Math.max.apply(null, scores) / 10) * 10 + 10);
if (minY === maxY) { minY = Math.max(0, minY - 10); maxY = Math.min(100, maxY + 10); }
// Grid
ctx.strokeStyle = '#21262d';
ctx.lineWidth = 1;
for (var y = minY; y <= maxY; y += 10) {
var py = pad.top + plotH - ((y - minY) / (maxY - minY)) * plotH;
ctx.beginPath(); ctx.moveTo(pad.left, py); ctx.lineTo(pad.left + plotW, py); ctx.stroke();
ctx.fillStyle = '#8b949e'; ctx.font = '11px sans-serif'; ctx.textAlign = 'right';
ctx.fillText(y + '%', pad.left - 8, py + 4);
}
// X labels
ctx.fillStyle = '#8b949e'; ctx.font = '11px sans-serif'; ctx.textAlign = 'center';
runs.forEach(function(r, i) {
var px = pad.left + (runs.length === 1 ? plotW / 2 : (i / (runs.length - 1)) * plotW);
ctx.save(); ctx.translate(px, pad.top + plotH + 15); ctx.rotate(-Math.PI / 6);
ctx.fillText(r.date, 0, 0); ctx.restore();
});
// Line
ctx.strokeStyle = '#58a6ff'; ctx.lineWidth = 2; ctx.beginPath();
runs.forEach(function(r, i) {
var px = pad.left + (runs.length === 1 ? plotW / 2 : (i / (runs.length - 1)) * plotW);
var py2 = pad.top + plotH - ((r.passRate * 100 - minY) / (maxY - minY)) * plotH;
if (i === 0) ctx.moveTo(px, py2); else ctx.lineTo(px, py2);
});
ctx.stroke();
// Dots
runs.forEach(function(r, i) {
var px = pad.left + (runs.length === 1 ? plotW / 2 : (i / (runs.length - 1)) * plotW);
var py2 = pad.top + plotH - ((r.passRate * 100 - minY) / (maxY - minY)) * plotH;
dotPositions.push({ x: px, y: py2, run: r });
ctx.beginPath(); ctx.arc(px, py2, 4, 0, Math.PI * 2);
ctx.fillStyle = r.passRate >= 0.7 ? '#3fb950' : '#f85149';
ctx.fill(); ctx.strokeStyle = '#0d1117'; ctx.lineWidth = 2; ctx.stroke();
});
}
// Tooltip
canvas.addEventListener('mousemove', function(e) {
var rect = canvas.getBoundingClientRect();
var mx = e.clientX - rect.left, my = e.clientY - rect.top;
var closest = null, closestDist = Infinity;
dotPositions.forEach(function(dot) {
var d = Math.sqrt(Math.pow(mx - dot.x, 2) + Math.pow(my - dot.y, 2));
if (d < closestDist) { closestDist = d; closest = dot; }
});
if (closest && closestDist < 40) {
var r = closest.run;
var passed = Math.round(r.passRate * r.total);
document.getElementById('tt-date').textContent = r.date;
document.getElementById('tt-score').textContent = (r.passRate * 100).toFixed(1) + '%';
document.getElementById('tt-score').style.color = r.passRate >= 0.7 ? '#3fb950' : '#f85149';
document.getElementById('tt-detail').textContent = passed + '/' + r.total + ' pass \\u00B7 ' + (r.avgDurationMs / 1000).toFixed(0) + 's avg \\u00B7 ' + r.model;
tooltip.style.display = 'block';
var tx = closest.x + 12, ty = closest.y - 50;
if (tx + 200 > rect.width) tx = closest.x - 210;
if (ty < 0) ty = closest.y + 12;
tooltip.style.left = tx + 'px'; tooltip.style.top = ty + 'px';
// Highlight dot
drawChart(getFilteredRuns());
ctx.beginPath(); ctx.arc(closest.x, closest.y, 7, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(88, 166, 255, 0.3)'; ctx.fill();
ctx.beginPath(); ctx.arc(closest.x, closest.y, 5, 0, Math.PI * 2);
ctx.fillStyle = r.passRate >= 0.7 ? '#3fb950' : '#f85149'; ctx.fill();
ctx.strokeStyle = '#e6edf3'; ctx.lineWidth = 2; ctx.stroke();
canvas.style.cursor = 'pointer';
} else {
tooltip.style.display = 'none';
canvas.style.cursor = 'default';
}
});
canvas.addEventListener('mouseleave', function() {
tooltip.style.display = 'none';
drawChart(getFilteredRuns());
});
canvas.addEventListener('click', function(e) {
var rect = canvas.getBoundingClientRect();
var mx = e.clientX - rect.left, my = e.clientY - rect.top;
dotPositions.forEach(function(dot) {
if (Math.sqrt(Math.pow(mx - dot.x, 2) + Math.pow(my - dot.y, 2)) < 20) {
window.open('viewer.html?run=' + encodeURIComponent(dot.run.runId), '_blank');
}
});
});
// Config selector change
configSelect.addEventListener('change', function() {
tooltip.style.display = 'none';
updateDashboard();
});
// Table search
document.getElementById('table-search').addEventListener('input', function(e) {
var q = e.target.value.toLowerCase();
var rows = document.querySelectorAll('#runs-table tbody tr');
rows.forEach(function(row) {
var searchText = row.getAttribute('data-search') || '';
row.classList.toggle('hidden', q && searchText.toLowerCase().indexOf(q) === -1);
});
});
// Resize
window.addEventListener('resize', function() { tooltip.style.display = 'none'; drawChart(getFilteredRuns()); });
// Init
updateDashboard();
})();
</script>
</body>
</html>`
// Step 5: Save locally and upload to R2
const localPath = process.argv[2] || '/tmp/eval-report.html'
await writeFile(localPath, html)
console.log(`Report saved locally: ${localPath}`)
await client.send(
new PutObjectCommand({
Bucket: bucket,
Key: 'report.html',
Body: html,
ContentType: 'text/html',
CacheControl: 'public, max-age=300',
}),
)
const cdnBaseUrl = (
process.env.EVAL_R2_CDN_BASE_URL || 'https://eval.browseros.com'
).replace(/\/+$/, '')
console.log(`Report uploaded to R2: ${bucket}/report.html`)
console.log(` View at: ${cdnBaseUrl}/report.html`)
// Print summary
console.log('\nScore trend:')
for (const run of runs.slice(-10)) {
const bar = '\u2588'.repeat(Math.round(run.passRate * 20))
const pct = (run.passRate * 100).toFixed(0).padStart(3)
console.log(` ${run.date} ${pct}% ${bar}`)
}

View File

@@ -2,7 +2,7 @@
* Eval-specific constants shared across agents, runners, and capture modules.
*/
export const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000 // 15 minutes
export const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000 // 30 minutes
export const SCREENSHOT_TIMEOUT_MS = 65_000 // 65s — ensures we get extension's error (60s)
export const MAX_ACTIONS_PER_DELEGATION = 15
export const CLADO_REQUEST_TIMEOUT_MS = 120_000

View File

@@ -354,7 +354,7 @@
</div>
<div class="config-field">
<label>Timeout (ms)</label>
<input type="number" id="cfg-timeout" value="600000" min="30000" max="3600000">
<input type="number" id="cfg-timeout" value="1800000" min="30000" max="3600000">
</div>
</div>
<div class="config-row" style="gap: 16px;">
@@ -454,6 +454,17 @@
<button class="btn-run" id="btn-run" onclick="submitConfig()">Run Eval</button>
</div>
<!-- Load previous run -->
<div class="config-actions">
<div class="load-config">
<label>Load run:</label>
<select id="cfg-run-select">
<option value="">-- select --</option>
</select>
</div>
<button class="btn-secondary" onclick="loadPreviousRun()">Load Run</button>
</div>
</div>
</div>
</div>
@@ -529,6 +540,7 @@ async function init() {
// Load saved configs into dropdown
loadConfigList();
loadRunList();
if (mode.configMode) {
// Config mode — show panel expanded
@@ -656,6 +668,53 @@ async function loadConfigList() {
} catch {}
}
async function loadRunList() {
try {
const res = await fetch('/api/runs');
const runs = await res.json();
const sel = document.getElementById('cfg-run-select');
sel.innerHTML = '<option value="">-- select --</option>';
runs.forEach(r => {
const opt = document.createElement('option');
opt.value = r;
opt.textContent = r;
sel.appendChild(opt);
});
} catch {}
}
async function loadPreviousRun() {
const runName = document.getElementById('cfg-run-select').value;
if (!runName) return;
const errEl = document.getElementById('config-error');
errEl.textContent = '';
try {
const res = await fetch('/api/load-run', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ runName }),
});
const result = await res.json();
if (!res.ok) {
errEl.textContent = result.error || 'Failed to load run';
return;
}
const stateRes = await fetch('/api/state');
const state = await stateRes.json();
document.getElementById('config-name').textContent =
state.configName ? `${state.configName} \u00B7 ${state.agentType}` : '';
tasks = state.tasks;
setConfigPanelOpen(false);
updateConfigSummary(state.configName, state.agentType);
selectedTaskId = null;
renderTaskList();
updateProgress();
if (tasks.length > 0) selectTask(tasks[0].queryId);
} catch (e) {
errEl.textContent = `Network error: ${e.message}`;
}
}
async function loadSavedConfig(name) {
if (!name) return;
try {
@@ -1055,7 +1114,8 @@ function renderTaskList() {
if (t.graderResults) {
const primary = getPrimaryGrader(t.graderResults);
if (primary) {
graderBadge = `<span class="grade-badge ${primary.pass ? 'pass' : 'fail'}">${primary.pass ? 'PASS' : 'FAIL'}</span>`;
const pct = typeof primary.score === 'number' ? `${(primary.score * 100).toFixed(0)}%` : (primary.pass ? 'PASS' : 'FAIL');
graderBadge = `<span class="grade-badge ${primary.pass ? 'pass' : 'fail'}">${pct}</span>`;
}
}
@@ -1144,12 +1204,35 @@ function toggleAutoplay() {
// ============================================================================
// Agent Stream
// ============================================================================
function renderStreamForTask(taskId) {
async function renderStreamForTask(taskId) {
const body = document.getElementById('stream-body');
body.innerHTML = '';
const events = streamEvents[taskId] || [];
events.forEach(e => appendStreamEntry(e, false));
body.scrollTop = body.scrollHeight;
if (events.length > 0) {
events.forEach(e => appendStreamEntry(e, false));
body.scrollTop = body.scrollHeight;
return;
}
const task = tasks.find(t => t.queryId === taskId);
if (!task || task.status === 'pending' || task.status === 'running') return;
body.innerHTML = '<div class="empty-state">Loading events...</div>';
try {
const res = await fetch(`/api/messages/${taskId}`);
if (!res.ok) {
body.innerHTML = '<div class="empty-state">No event log available</div>';
return;
}
const text = await res.text();
const parsed = text.trim().split('\n').filter(Boolean).map(line => {
try { return JSON.parse(line); } catch { return null; }
}).filter(Boolean);
streamEvents[taskId] = parsed;
body.innerHTML = '';
parsed.forEach(e => appendStreamEntry(e, false));
body.scrollTop = body.scrollHeight;
} catch {
body.innerHTML = '<div class="empty-state">Failed to load events</div>';
}
}
function appendStreamEntry(event, scroll = true) {

View File

@@ -1,4 +1,4 @@
import { mkdir, readdir, readFile } from 'node:fs/promises'
import { mkdir, readdir, readFile, stat } from 'node:fs/promises'
import { join, resolve } from 'node:path'
import { Hono } from 'hono'
import { streamSSE } from 'hono/streaming'
@@ -199,6 +199,133 @@ app.get('/api/screenshots/:taskId/:index', async (c) => {
}
})
app.get('/api/messages/:taskId', async (c) => {
const { taskId } = c.req.param()
if (taskId.includes('..') || taskId.includes('/')) {
return c.json({ error: 'Invalid parameters' }, 400)
}
const filepath = join(dashboardState.outputDir, taskId, 'messages.jsonl')
const resolved = resolve(filepath)
if (!resolved.startsWith(resolve(dashboardState.outputDir))) {
return c.json({ error: 'Invalid path' }, 400)
}
try {
const file = Bun.file(filepath)
if (!(await file.exists())) return c.notFound()
const data = await file.arrayBuffer()
return c.body(data, 200, {
'Content-Type': 'application/x-ndjson',
'Cache-Control': 'no-cache',
})
} catch {
return c.notFound()
}
})
const resultsDir = join(import.meta.dir, '..', '..', 'results')
app.get('/api/runs', async (c) => {
try {
const runs: string[] = []
const entries = await readdir(resultsDir, { withFileTypes: true })
for (const entry of entries.filter((e) => e.isDirectory())) {
const subEntries = await readdir(join(resultsDir, entry.name), {
withFileTypes: true,
}).catch(() => [] as import('node:fs').Dirent[])
const hasTimestampDirs = subEntries.some(
(s) => s.isDirectory() && /^\d{4}-\d{2}-\d{2}-\d{4}$/.test(s.name),
)
if (hasTimestampDirs) {
for (const sub of subEntries.filter((s) => s.isDirectory())) {
runs.push(`${entry.name}/${sub.name}`)
}
} else {
runs.push(entry.name)
}
}
runs.sort().reverse()
return c.json(runs)
} catch {
return c.json([])
}
})
app.post('/api/load-run', async (c) => {
if (evalRunning)
return c.json({ error: 'Cannot load while eval is running' }, 409)
let body: { runName: string }
try {
body = await c.req.json()
} catch {
return c.json({ error: 'Invalid JSON body' }, 400)
}
const runName = body.runName
if (!runName || runName.includes('..')) {
return c.json({ error: 'Invalid run name' }, 400)
}
if ((runName.match(/\//g) || []).length > 1) {
return c.json({ error: 'Invalid run name' }, 400)
}
const outputDir = resolve(resultsDir, runName)
if (!outputDir.startsWith(resolve(resultsDir))) {
return c.json({ error: 'Invalid path' }, 400)
}
const dirStat = await stat(outputDir).catch(() => null)
if (!dirStat?.isDirectory()) {
return c.json({ error: 'Run directory not found' }, 404)
}
const entries = await readdir(outputDir, { withFileTypes: true })
const taskDirs = entries.filter((e) => e.isDirectory())
const loadedTasks: DashboardTask[] = []
let agentType = ''
for (const taskDir of taskDirs) {
const metaPath = join(outputDir, taskDir.name, 'metadata.json')
try {
const raw = JSON.parse(await readFile(metaPath, 'utf-8'))
if (!agentType && raw.agent_config?.type) {
agentType = raw.agent_config.type
}
const screenshotDir = join(outputDir, taskDir.name, 'screenshots')
let screenshotCount = raw.screenshot_count ?? 0
if (!screenshotCount) {
try {
const files = await readdir(screenshotDir)
screenshotCount = files.filter((f: string) =>
f.endsWith('.png'),
).length
} catch {}
}
loadedTasks.push({
queryId: raw.query_id || taskDir.name,
query: raw.query || '',
startUrl: raw.start_url,
status:
raw.termination_reason === 'completed'
? 'completed'
: raw.termination_reason === 'timeout'
? 'timeout'
: 'failed',
durationMs: raw.total_duration_ms,
graderResults: raw.grader_results,
screenshotCount,
})
} catch {}
}
if (loadedTasks.length === 0) {
return c.json({ error: 'No completed tasks found in this run' }, 404)
}
dashboardState.configName = runName
dashboardState.agentType = agentType
dashboardState.outputDir = outputDir
dashboardState.tasks = loadedTasks
return c.json({
status: 'loaded',
configName: runName,
agentType,
taskCount: loadedTasks.length,
})
})
// ============================================================================
// Config & Run API
// ============================================================================

File diff suppressed because it is too large Load Diff

View File

@@ -53,8 +53,8 @@ The raw event stream — one JSON object per line with a "type" field.
- "tool-output-error" / "tool-input-error" — Tool call failed. Fields: toolCallId, error.
- "text-delta" — Agent's reasoning text. Field: delta (small text chunk).
**Event types to AVOID reading:**
- "tool-output-available" — Tool output. The "output" field contains FULL PAGE DOM CONTENT — hundreds of interactive elements, entire page text, etc. These lines are 5-50KB each. NEVER read them. The tool-input-available lines already tell you what the agent did. Screenshots show you the visual result.
**Event types to handle carefully:**
- "tool-output-available" — Tool output. The "output" field contains FULL PAGE DOM CONTENT — hundreds of interactive elements, entire page text, etc. These lines are 5-50KB each. NEVER read them in bulk. However, you CAN and SHOULD use Grep to search within these lines for specific keywords when screenshots alone can't verify a claim. For example, if the task asks "find the price of X" and the screenshot is unclear, grep messages.jsonl for the product name or price value to confirm the agent actually saw it in the DOM.
### 2. screenshots/ directory
Numbered PNG screenshots (1.png, 2.png, ...) captured after each tool execution.
@@ -95,6 +95,13 @@ Grep for "tool-output-error" or "tool-input-error". If none found, zero errors.
**Step 3: Sample reasoning (only if needed for reasoning_quality)**
Grep for "text-delta" but LIMIT to the first 10 and last 10 results. Don't read all reasoning text.
**Step 4: Verify claims from DOM content (critical for task_completion)**
When the agent's final answer contains specific data (prices, names, dates, counts, etc.) that you can't confirm from screenshots alone, use Grep to search messages.jsonl for those specific values or keywords. This searches the tool-output-available lines which contain DOM content the agent actually saw. For example:
- Task asks "find cheapest flight price" → grep for the dollar amount from the final answer
- Task asks "list the top 3 articles" → grep for the article titles mentioned in the answer
- Task asks "extract the email address" → grep for the email pattern
This is the most reliable way to verify whether the agent actually found the data it claims, since screenshots may be blurry, truncated, or missing the relevant section.
## How to View Screenshots
You have {screenshot_count} screenshots. View 3-5 strategically:

View File

@@ -16,8 +16,8 @@ import {
type PerformanceGraderOptions,
} from './types'
export const DEFAULT_MAX_TURNS = 15
export const DEFAULT_MAX_BUDGET_USD = 0.5
export const DEFAULT_MAX_TURNS = 100
export const DEFAULT_MAX_BUDGET_USD = 100
export const DEFAULT_PASS_THRESHOLD = 75
const DEFAULT_MODEL = 'claude-opus-4-5-20251101'
const GRADER_TIMEOUT_MS = 300_000

View File

@@ -30,9 +30,15 @@ const MONOREPO_ROOT = join(
'../../../..',
)
const BROWSEROS_BINARY = '/Applications/BrowserOS.app/Contents/MacOS/BrowserOS'
const BROWSEROS_BINARY =
process.env.BROWSEROS_BINARY ||
'/Applications/BrowserOS.app/Contents/MacOS/BrowserOS'
const CONTROLLER_EXT_DIR = join(MONOREPO_ROOT, 'apps/controller-ext/dist')
const CAPTCHA_EXT_DIR = join(
dirname(fileURLToPath(import.meta.url)),
'../../../extensions/nopecha',
)
export class BrowserOSAppManager {
private ports: EvalPorts
@@ -152,8 +158,15 @@ export class BrowserOSAppManager {
`--user-data-dir=${this.tempDir}`,
]
const extensions: string[] = []
if (this.loadExtensions && existsSync(CONTROLLER_EXT_DIR)) {
chromeArgs.push(`--load-extension=${CONTROLLER_EXT_DIR}`)
extensions.push(CONTROLLER_EXT_DIR)
}
if (existsSync(CAPTCHA_EXT_DIR)) {
extensions.push(CAPTCHA_EXT_DIR)
}
if (extensions.length > 0) {
chromeArgs.push(`--load-extension=${extensions.join(',')}`)
}
chromeArgs.push('about:blank')

View File

@@ -1,5 +1,5 @@
import { mkdir, writeFile } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { basename, dirname, join, resolve } from 'node:path'
import {
dashboardState,
setActiveExecutor,
@@ -120,18 +120,31 @@ function resolvePaths(
? config.dataset
: resolve(configDir, config.dataset)
// Resolve output directory: use options.outputDir if provided, otherwise resolve from config
const outputDir = options.outputDir
? options.outputDir
: config.output_dir
? config.output_dir.startsWith('/')
? config.output_dir
: resolve(configDir, config.output_dir)
: resolve(configDir, 'results')
// Resolve output directory: results/{config-name}/{timestamp}/
// Config name derived from config filename (e.g., "browseros-agent-weekly.json" → "browseros-agent-weekly")
const configName = options.configPath
? basename(resolve(options.configPath), '.json')
: 'eval'
const timestamp = formatTimestamp(new Date())
const resultsBase = config.output_dir
? config.output_dir.startsWith('/')
? config.output_dir
: resolve(configDir, config.output_dir)
: resolve(configDir, '..', 'results')
const outputDir = join(resultsBase, configName, timestamp)
return { dataPath, outputDir }
}
function formatTimestamp(date: Date): string {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
const h = String(date.getHours()).padStart(2, '0')
const min = String(date.getMinutes()).padStart(2, '0')
return `${y}-${m}-${d}-${h}${min}`
}
// ============================================================================
// Task Loading
// ============================================================================
@@ -229,6 +242,12 @@ function printTaskProgress(
if (result.status === 'failed') {
console.log(` ERROR: ${result.error.message}`)
} else if (isSuccessfulResult(result)) {
// Log agent errors (e.g., LLM API failures) even if task "completed"
if (result.agentResult.metadata.errors?.length) {
for (const err of result.agentResult.metadata.errors) {
console.log(` ERROR [${err.source}]: ${err.message}`)
}
}
for (const [name, gr] of Object.entries(result.graderResults)) {
const icon = gr.pass ? 'PASS' : 'FAIL'
console.log(` ${name}: ${icon}`)

View File

@@ -1,6 +1,6 @@
{
"name": "@browseros/server",
"version": "0.0.77",
"version": "0.0.79",
"description": "BrowserOS server",
"type": "module",
"main": "./src/index.ts",
@@ -89,11 +89,11 @@
"fuse.js": "^7.1.0",
"gray-matter": "^4.0.3",
"hono": "^4.12.3",
"jimp": "^1.6.0",
"klavis": "^2.15.0",
"pino": "^9.6.0",
"posthog-node": "^4.17.0",
"puppeteer-core": "24.23.0",
"sharp": "^0.34.5",
"ws": "^8.18.0",
"zod": "^3.24.2",
"zod-from-json-schema": "^0.1.0"

View File

@@ -8,6 +8,7 @@ import { EXTERNAL_URLS } from '@browseros/shared/constants/urls'
import { LLM_PROVIDERS } from '@browseros/shared/schemas/llm'
import { createOpenRouter } from '@openrouter/ai-sdk-provider'
import type { LanguageModel } from 'ai'
import { createBrowserOSFetch } from '../lib/browseros-fetch'
import { createCodexFetch } from '../lib/clients/oauth/codex-fetch'
import { createCopilotFetch } from '../lib/clients/oauth/copilot-fetch'
import { logger } from '../lib/logger'
@@ -104,26 +105,38 @@ function createBrowserOSFactory(
config: ResolvedAgentConfig,
): (modelId: string) => unknown {
if (!config.baseUrl) throw new Error('BrowserOS provider requires baseUrl')
const { baseUrl, apiKey, upstreamProvider } = config
const { baseUrl, apiKey, upstreamProvider, browserosId } = config
const browserosFetch = browserosId
? createBrowserOSFetch(browserosId)
: createOpenRouterCompatibleFetch()
if (upstreamProvider === LLM_PROVIDERS.OPENROUTER) {
return createOpenRouter({
baseURL: baseUrl,
...(apiKey && { apiKey }),
fetch: createOpenRouterCompatibleFetch(),
fetch: browserosFetch,
})
}
if (upstreamProvider === LLM_PROVIDERS.ANTHROPIC) {
return createAnthropic({ baseURL: baseUrl, ...(apiKey && { apiKey }) })
return createAnthropic({
baseURL: baseUrl,
...(apiKey && { apiKey }),
fetch: browserosFetch,
})
}
if (upstreamProvider === LLM_PROVIDERS.AZURE) {
return createAzure({ baseURL: baseUrl, ...(apiKey && { apiKey }) })
return createAzure({
baseURL: baseUrl,
...(apiKey && { apiKey }),
fetch: browserosFetch,
})
}
logger.info('creating openai-compatible')
logger.debug('Creating OpenAI-compatible provider for BrowserOS')
return createOpenAICompatible({
name: 'browseros',
baseURL: baseUrl,
...(apiKey && { apiKey }),
fetch: browserosFetch,
})
}

View File

@@ -46,4 +46,6 @@ export interface ResolvedAgentConfig {
isScheduledTask?: boolean
/** Apps the user previously declined to connect via MCP (chose "do it manually"). */
declinedApps?: string[]
/** BrowserOS installation ID for credit-based tracking. */
browserosId?: string
}

View File

@@ -0,0 +1,36 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { Hono } from 'hono'
import { fetchCredits } from '../../lib/clients/gateway'
import { logger } from '../../lib/logger'
interface CreditsDeps {
browserosId?: string
gatewayBaseUrl?: string
}
export function createCreditsRoutes(deps: CreditsDeps) {
const { browserosId, gatewayBaseUrl } = deps
if (!browserosId || !gatewayBaseUrl) {
return new Hono().all('/*', (c) =>
c.json({ error: 'Credits not configured' }, 503),
)
}
return new Hono().get('/', async (c) => {
try {
const credits = await fetchCredits(gatewayBaseUrl, browserosId)
return c.json(credits)
} catch (error) {
logger.error('Failed to fetch credits', {
error: error instanceof Error ? error.message : String(error),
})
return c.json({ error: 'Failed to fetch credits' }, 502)
}
})
}

View File

@@ -50,11 +50,19 @@ export function createOAuthRoutes(deps: OAuthRouteDeps) {
provider: providerId,
error: error instanceof Error ? error.message : String(error),
})
const message =
error instanceof Error
? error.message
: 'Failed to start authentication. Please try again.'
return c.json({ error: message }, 500)
: 'Failed to start authentication.'
// Port conflict — clear actionable message
const status =
error instanceof Error && error.message.includes('callback port')
? 503
: 500
return c.json({ error: message }, status)
}
})

View File

@@ -14,11 +14,13 @@ import { Hono } from 'hono'
import { cors } from 'hono/cors'
import type { ContentfulStatusCode } from 'hono/utils/http-status'
import { HttpAgentError } from '../agent/errors'
import { INLINED_ENV } from '../env'
import { KlavisClient } from '../lib/clients/klavis/klavis-client'
import { initializeOAuth } from '../lib/clients/oauth'
import { getDb } from '../lib/db'
import { logger } from '../lib/logger'
import { createChatRoutes } from './routes/chat'
import { createCreditsRoutes } from './routes/credits'
import { createGraphRoutes } from './routes/graph'
import { createHealthRoute } from './routes/health'
import { createKlavisRoutes } from './routes/klavis'
@@ -78,7 +80,7 @@ export async function createHttpServer(config: HttpServerConfig) {
const { onShutdown } = config
// Initialize OAuth token manager + callback server (port released on process exit)
// Initialize OAuth token manager (callback server binds lazily on first PKCE login)
const tokenManager = browserosId
? initializeOAuth(getDb(), browserosId)
: null
@@ -108,6 +110,7 @@ export async function createHttpServer(config: HttpServerConfig) {
'/shutdown',
createShutdownRoute({
onShutdown: () => {
tokenManager?.stopCallbackServer()
klavisProxy?.close().catch((err) =>
logger.warn('Failed to close Klavis proxy transport', {
error: err instanceof Error ? err.message : String(err),
@@ -132,6 +135,15 @@ export async function createHttpServer(config: HttpServerConfig) {
),
)
.route('/klavis', createKlavisRoutes({ browserosId: browserosId || '' }))
.route(
'/credits',
createCreditsRoutes({
browserosId,
gatewayBaseUrl: INLINED_ENV.BROWSEROS_CONFIG_URL
? new URL(INLINED_ENV.BROWSEROS_CONFIG_URL).origin
: undefined,
}),
)
.route(
'/mcp',
createMcpRoutes({

View File

@@ -64,6 +64,7 @@ export class ChatService {
chatMode: request.mode === 'chat',
isScheduledTask: request.isScheduledTask,
declinedApps: request.declinedApps,
browserosId: this.deps.browserosId,
}
let session = sessionStore.get(request.conversationId)

View File

@@ -701,6 +701,17 @@ export class Browser {
}
}
// --- Element Geometry ---
async getElementCenter(
page: number,
element: number,
): Promise<{ x: number; y: number }> {
const session = await this.resolveSession(page)
await elements.scrollIntoView(session, element)
return elements.getElementCenter(session, element)
}
// --- Input ---
async click(

View File

@@ -1,7 +1,9 @@
import { mkdir, readdir, rm, stat } from 'node:fs/promises'
import { unlinkSync } from 'node:fs'
import { mkdir, readdir, rm, stat, writeFile } from 'node:fs/promises'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { PATHS } from '@browseros/shared/constants/paths'
import type { ServerDiscoveryConfig } from '@browseros/shared/types/server-config'
import { logger } from './logger'
export function getBrowserosDir(): string {
@@ -32,6 +34,24 @@ export function getBuiltinSkillsDir(): string {
return join(getSkillsDir(), PATHS.BUILTIN_DIR_NAME)
}
export function getServerConfigPath(): string {
return join(getBrowserosDir(), PATHS.SERVER_CONFIG_FILE_NAME)
}
export async function writeServerConfig(
config: ServerDiscoveryConfig,
): Promise<void> {
await writeFile(getServerConfigPath(), `${JSON.stringify(config, null, 2)}\n`)
}
export function removeServerConfigSync(): void {
try {
unlinkSync(getServerConfigPath())
} catch {
// File may not exist or already be removed
}
}
export async function ensureBrowserosDir(): Promise<void> {
await mkdir(getMemoryDir(), { recursive: true })
await mkdir(getSkillsDir(), { recursive: true })

View File

@@ -0,0 +1,82 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* Custom fetch for BrowserOS gateway requests.
* Adds X-BrowserOS-ID header for credit tracking,
* handles CREDITS_EXHAUSTED (429), and extracts OpenRouter-style error details.
*/
import { APICallError } from '@ai-sdk/provider'
import { logger } from './logger'
function resolveUrl(url: RequestInfo | URL): string {
return typeof url === 'string' ? url : url.toString()
}
function parseErrorBody(
body: string,
): { message?: string; code?: string; metadata?: { raw?: unknown } } | null {
try {
const parsed = JSON.parse(body)
return parsed.error ?? null
} catch {
return null
}
}
function buildErrorMessage(
statusCode: number,
statusText: string,
error: NonNullable<ReturnType<typeof parseErrorBody>>,
): string {
if (!error.message) return `HTTP ${statusCode}: ${statusText}`
let msg = error.message
if (error.code) msg = `[${error.code}] ${msg}`
if (error.metadata?.raw) msg += ` (${JSON.stringify(error.metadata.raw)})`
return msg
}
export function createBrowserOSFetch(browserosId: string): typeof fetch {
return (async (url: RequestInfo | URL, options?: RequestInit) => {
const headers = new Headers(options?.headers)
headers.set('X-BrowserOS-ID', browserosId)
const response = await globalThis.fetch(url, { ...options, headers })
const creditsRemaining = response.headers.get('X-Credits-Remaining')
if (creditsRemaining !== null) {
logger.debug('Credits remaining', { creditsRemaining })
}
if (!response.ok) {
const statusCode = response.status
const responseBody = await response.text()
const error = parseErrorBody(responseBody)
if (statusCode === 429 && error?.code === 'CREDITS_EXHAUSTED') {
throw new APICallError({
message: error.message ?? 'Daily credits exhausted',
url: resolveUrl(url),
requestBodyValues: {},
statusCode,
responseBody,
isRetryable: false,
})
}
throw new APICallError({
message: error
? buildErrorMessage(statusCode, response.statusText, error)
: `HTTP ${statusCode}: ${response.statusText}`,
url: resolveUrl(url),
requestBodyValues: {},
statusCode,
responseBody,
})
}
return response
}) as typeof fetch
}

View File

@@ -11,9 +11,18 @@ export interface Provider {
apiKey: string
baseUrl?: string
dailyRateLimit?: number
dailyCredits?: number
creditCostPerRequest?: number
resetInterval?: string
providerType?: string // LLMProvider value from ai-gateway: "openrouter" | "azure" | "anthropic"
}
export interface CreditsInfo {
credits: number
dailyLimit: number
lastResetAt?: string
}
export interface BrowserOSConfig {
providers: Provider[]
}
@@ -109,3 +118,20 @@ export function getLLMConfigFromProvider(
providerType: provider.providerType,
}
}
export async function fetchCredits(
gatewayBaseUrl: string,
browserosId: string,
): Promise<CreditsInfo> {
const url = new URL(`/credits/${browserosId}`, gatewayBaseUrl).href
const response = await fetch(url)
if (!response.ok) {
const errorText = await response.text()
throw new Error(
`Failed to fetch credits: ${response.status} ${response.statusText} - ${errorText}`,
)
}
const result = (await response.json()) as CreditsInfo
logger.debug('Credits fetched', { credits: result.credits })
return result
}

View File

@@ -119,5 +119,6 @@ async function resolveBrowserOSConfig(
apiKey: llmConfig.apiKey,
baseUrl: llmConfig.baseUrl,
upstreamProvider: llmConfig.providerType,
browserosId,
}
}

View File

@@ -16,6 +16,7 @@ import { EXTERNAL_URLS } from '@browseros/shared/constants/urls'
import { LLM_PROVIDERS } from '@browseros/shared/schemas/llm'
import { createOpenRouter } from '@openrouter/ai-sdk-provider'
import type { LanguageModel } from 'ai'
import { createBrowserOSFetch } from '../../browseros-fetch'
import { logger } from '../../logger'
import { createOpenRouterCompatibleFetch } from '../../openrouter-fetch'
import { createCodexFetch } from '../oauth/codex-fetch'
@@ -92,28 +93,38 @@ function createBedrockModel(config: ResolvedLLMConfig): LanguageModel {
function createBrowserOSModel(config: ResolvedLLMConfig): LanguageModel {
if (!config.baseUrl) throw new Error('BrowserOS provider requires baseUrl')
const { baseUrl, apiKey, model, upstreamProvider } = config
const { baseUrl, apiKey, model, upstreamProvider, browserosId } = config
const browserosFetch = browserosId
? createBrowserOSFetch(browserosId)
: createOpenRouterCompatibleFetch()
if (upstreamProvider === LLM_PROVIDERS.OPENROUTER) {
return createOpenRouter({
baseURL: baseUrl,
...(apiKey && { apiKey }),
fetch: createOpenRouterCompatibleFetch(),
fetch: browserosFetch,
})(model)
}
if (upstreamProvider === LLM_PROVIDERS.ANTHROPIC) {
return createAnthropic({ baseURL: baseUrl, ...(apiKey && { apiKey }) })(
model,
)
return createAnthropic({
baseURL: baseUrl,
...(apiKey && { apiKey }),
fetch: browserosFetch,
})(model)
}
if (upstreamProvider === LLM_PROVIDERS.AZURE) {
return createAzure({ baseURL: baseUrl, ...(apiKey && { apiKey }) })(model)
return createAzure({
baseURL: baseUrl,
...(apiKey && { apiKey }),
fetch: browserosFetch,
})(model)
}
logger.debug('Creating OpenAI-compatible provider for BrowserOS')
return createOpenAICompatible({
name: 'browseros',
baseURL: baseUrl,
...(apiKey && { apiKey }),
fetch: browserosFetch,
})(model)
}

View File

@@ -11,5 +11,6 @@ import type { LLMConfig } from '@browseros/shared/schemas/llm'
export interface ResolvedLLMConfig extends LLMConfig {
model: string
upstreamProvider?: string
browserosId?: string
accountId?: string
}

View File

@@ -3,68 +3,164 @@
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* Temporary HTTP server on port 1455 for OAuth callbacks.
* OpenAI's OAuth requires redirect_uri to use this specific port
* (matching the Codex CLI client ID registration).
* Lazy OAuth callback server on port 1455.
*
* Port 1455 is required by OpenAI's Codex CLI OAuth client registration
* (redirect_uri must be http://localhost:1455/auth/callback).
*
* Unlike the old implementation that bound the port at startup, this class:
* - Only binds when the user initiates a PKCE login flow
* - Sends GET /cancel to any existing server on the port first (Codex CLI pattern)
* - Exposes /cancel so other instances can cancel us
* - Releases the port after the callback arrives and no flows are pending
*/
import { OAUTH_CALLBACK_PORT } from '@browseros/shared/constants/ports'
import { logger } from '../../logger'
import type { OAuthTokenManager } from './token-manager'
export function startOAuthCallbackServer(tokenManager: OAuthTokenManager): {
stop: () => void
} {
const server = Bun.serve({
port: OAUTH_CALLBACK_PORT,
hostname: '127.0.0.1',
fetch: async (req) => {
const url = new URL(req.url)
if (url.pathname !== '/auth/callback') {
return new Response('Not found', { status: 404 })
}
const MAX_BIND_ATTEMPTS = 5
const RETRY_DELAY_MS = 300
const code = url.searchParams.get('code')
const state = url.searchParams.get('state')
const error = url.searchParams.get('error')
export class OAuthCallbackServer {
private server: ReturnType<typeof Bun.serve> | null = null
private tokenManager: OAuthTokenManager | null = null
if (error) {
const description = url.searchParams.get('error_description') || error
logger.warn('OAuth callback received error', { error, description })
return htmlResponse(errorPage(description))
}
if (!code || !state) {
return htmlResponse(errorPage('Missing authorization code or state'))
}
try {
await tokenManager.handleCallback(code, state)
// Always show success page — chrome-extension:// redirects are blocked by Chromium.
// The extension polls /oauth/:provider/status and detects auth automatically.
return htmlResponse(successPage())
} catch (err) {
logger.error('OAuth callback failed', {
error: err instanceof Error ? err.message : String(err),
})
return htmlResponse(
errorPage(
err instanceof Error ? err.message : 'Authentication failed',
),
)
}
},
})
logger.info('OAuth callback server started', { port: OAUTH_CALLBACK_PORT })
return {
stop: () => {
server.stop()
logger.info('OAuth callback server stopped')
},
setTokenManager(manager: OAuthTokenManager): void {
this.tokenManager = manager
}
isRunning(): boolean {
return this.server !== null
}
/**
* Ensure the callback server is running on port 1455.
* If the port is already held by another process, sends GET /cancel
* to ask it to release, then retries.
*/
async ensureRunning(): Promise<void> {
if (this.server) return
if (!this.tokenManager) {
throw new Error('OAuth callback server not initialized')
}
let cancelSent = false
for (let attempt = 1; attempt <= MAX_BIND_ATTEMPTS; attempt++) {
try {
this.bind()
return
} catch {
if (!cancelSent) {
cancelSent = true
await this.sendCancel()
}
if (attempt < MAX_BIND_ATTEMPTS) {
await sleep(RETRY_DELAY_MS)
}
}
}
throw new Error(
`OAuth callback port ${OAUTH_CALLBACK_PORT} is in use by another process. ` +
'Close other BrowserOS instances or CLI tools and try again.',
)
}
/**
* Stop the server and release port 1455.
*/
stop(): void {
if (this.server) {
this.server.stop()
this.server = null
logger.info('OAuth callback server stopped', {
port: OAUTH_CALLBACK_PORT,
})
}
}
private bind(): void {
const tokenManager = this.tokenManager!
this.server = Bun.serve({
port: OAUTH_CALLBACK_PORT,
hostname: '127.0.0.1',
fetch: async (req) => {
const url = new URL(req.url)
// /cancel — let other instances ask us to release the port
if (url.pathname === '/cancel') {
logger.info('OAuth callback server received cancel request')
// Schedule stop after responding
queueMicrotask(() => this.stop())
return new Response('Login cancelled', { status: 200 })
}
if (url.pathname !== '/auth/callback') {
return new Response('Not found', { status: 404 })
}
const code = url.searchParams.get('code')
const state = url.searchParams.get('state')
const error = url.searchParams.get('error')
if (error) {
const description = url.searchParams.get('error_description') || error
logger.warn('OAuth callback received error', {
error,
description,
})
return htmlResponse(errorPage(description))
}
if (!code || !state) {
return htmlResponse(errorPage('Missing authorization code or state'))
}
try {
await tokenManager.handleCallback(code, state)
return htmlResponse(successPage())
} catch (err) {
logger.error('OAuth callback failed', {
error: err instanceof Error ? err.message : String(err),
})
return htmlResponse(
errorPage(
err instanceof Error ? err.message : 'Authentication failed',
),
)
}
},
})
logger.info('OAuth callback server started', {
port: OAUTH_CALLBACK_PORT,
})
}
/**
* Send GET /cancel to any existing server on port 1455.
* This politely asks the other process to release the port.
* Follows the Codex CLI pattern (codex-rs/login/src/server.rs).
*/
private async sendCancel(): Promise<void> {
try {
await fetch(`http://127.0.0.1:${OAUTH_CALLBACK_PORT}/cancel`, {
signal: AbortSignal.timeout(2000),
})
logger.info('Sent cancel to existing OAuth callback server')
} catch {
// Server might not support /cancel or might not be running
}
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function htmlResponse(html: string): Response {

View File

@@ -8,7 +8,7 @@
* VS Code's algorithm (max 2048px longest side, 768px shortest side).
*/
import sharp from 'sharp'
import { Jimp } from 'jimp'
import { logger } from '../../logger'
const MAX_LONG_SIDE = 2048
@@ -81,11 +81,13 @@ async function resizeDataUrl(dataUrl: string): Promise<string | null> {
const base64Data = dataUrl.substring(commaIdx + 1)
const buffer = Buffer.from(base64Data, 'base64')
const image = sharp(buffer)
const metadata = await image.metadata()
if (!metadata.width || !metadata.height) return null
const image = await Jimp.fromBuffer(buffer)
const origWidth = image.width
const origHeight = image.height
if (!origWidth || !origHeight) return null
let { width, height } = metadata
let width = origWidth
let height = origHeight
// Skip if already within both limits (no resize step will fire)
if (
@@ -110,24 +112,20 @@ async function resizeDataUrl(dataUrl: string): Promise<string | null> {
height = Math.round(height * scale)
}
// Preserve PNG for images with alpha, use JPEG otherwise
const hasAlpha = metadata.channels === 4 || metadata.hasAlpha
const resizedBuffer = hasAlpha
? await sharp(buffer)
.resize(width, height, { fit: 'inside' })
.png()
.toBuffer()
: await sharp(buffer)
.resize(width, height, { fit: 'inside' })
.jpeg({ quality: 75 })
.toBuffer()
image.resize({ w: width, h: height })
// Jimp always outputs with alpha; use PNG for alpha sources, JPEG otherwise
const hasAlpha = image.hasAlpha()
const mime = hasAlpha ? 'image/png' : 'image/jpeg'
const resizedBuffer = hasAlpha
? await image.getBuffer('image/png')
: await image.getBuffer('image/jpeg', { quality: 75 })
const originalKB = Math.round(base64Data.length / 1024)
const resizedB64 = resizedBuffer.toString('base64')
const resizedKB = Math.round(resizedB64.length / 1024)
logger.debug('Resized image for Copilot', {
original: `${metadata.width}x${metadata.height} (${originalKB}KB)`,
original: `${origWidth}x${origHeight} (${originalKB}KB)`,
resized: `${width}x${height} (${resizedKB}KB)`,
})

View File

@@ -5,7 +5,7 @@
*/
import type { Database } from 'bun:sqlite'
import { startOAuthCallbackServer } from './callback-server'
import { OAuthCallbackServer } from './callback-server'
import { OAuthTokenManager } from './token-manager'
import { OAuthTokenStore } from './token-store'
@@ -16,8 +16,9 @@ export function initializeOAuth(
browserosId: string,
): OAuthTokenManager {
const store = new OAuthTokenStore(db)
tokenManager = new OAuthTokenManager(store, browserosId)
startOAuthCallbackServer(tokenManager)
const callbackServer = new OAuthCallbackServer()
tokenManager = new OAuthTokenManager(store, browserosId, callbackServer)
callbackServer.setTokenManager(tokenManager)
return tokenManager
}

View File

@@ -7,6 +7,7 @@
import { OAUTH_CALLBACK_PORT } from '@browseros/shared/constants/ports'
import { TIMEOUTS } from '@browseros/shared/constants/timeouts'
import { logger } from '../../logger'
import type { OAuthCallbackServer } from './callback-server'
import { getOAuthProvider, type OAuthProviderConfig } from './providers'
import type { OAuthTokenStore, StoredOAuthTokens } from './token-store'
@@ -58,6 +59,7 @@ export class OAuthTokenManager {
constructor(
private readonly store: OAuthTokenStore,
private readonly browserosId: string,
private readonly callbackServer: OAuthCallbackServer,
) {}
// --- PKCE flow (ChatGPT Plus/Pro) ---
@@ -66,6 +68,9 @@ export class OAuthTokenManager {
providerId: string,
redirectBackUrl?: string,
): Promise<string> {
// Lazily start callback server — only needed for PKCE flow
await this.callbackServer.ensureRunning()
const provider = getOAuthProvider(providerId)
if (!provider) throw new Error(`Unknown OAuth provider: ${providerId}`)
@@ -154,6 +159,7 @@ export class OAuthTokenManager {
this.store.upsertTokens(this.browserosId, flow.provider, tokens)
this.pendingFlows.delete(state)
this.stopCallbackIfIdle()
logger.info('OAuth authentication successful', {
provider: flow.provider,
@@ -444,6 +450,17 @@ export class OAuthTokenManager {
this.store.deleteTokens(this.browserosId, provider)
}
stopCallbackServer(): void {
this.callbackServer.stop()
}
private stopCallbackIfIdle(): void {
const hasPkceFlows = [...this.pendingFlows.values()].some(() => true)
if (!hasPkceFlows) {
this.callbackServer.stop()
}
}
private cleanExpiredFlows(): void {
const now = Date.now()
for (const [state, flow] of this.pendingFlows) {

View File

@@ -18,7 +18,12 @@ import { ControllerBackend } from './browser/backends/controller'
import { Browser } from './browser/browser'
import type { ServerConfig } from './config'
import { INLINED_ENV } from './env'
import { cleanOldSessions, ensureBrowserosDir } from './lib/browseros-dir'
import {
cleanOldSessions,
ensureBrowserosDir,
removeServerConfigSync,
writeServerConfig,
} from './lib/browseros-dir'
import { initializeDb } from './lib/db'
import { identity } from './lib/identity'
import { logger } from './lib/logger'
@@ -109,6 +114,20 @@ export class Application {
this.handleStartupError('HTTP server', this.config.serverPort, error)
}
try {
await writeServerConfig({
server_port: this.config.serverPort,
url: `http://127.0.0.1:${this.config.serverPort}`,
server_version: VERSION,
browseros_version: this.config.instanceBrowserosVersion,
chromium_version: this.config.instanceChromiumVersion,
})
} catch (error) {
logger.warn('Failed to write server config for auto-discovery', {
error: error instanceof Error ? error.message : String(error),
})
}
logger.info(
`HTTP server listening on http://127.0.0.1:${this.config.serverPort}`,
)
@@ -125,6 +144,7 @@ export class Application {
stop(reason?: string): void {
logger.info('Shutting down server...', { reason })
stopSkillSync()
removeServerConfigSync()
// Immediate exit without graceful shutdown. Chromium may kill us on update/restart,
// and we need to free the port instantly so the HTTP port doesn't keep switching.

View File

@@ -16,6 +16,7 @@ export interface BrowserConfig {
binaryPath: string
userDataDir: string
headless: boolean
extraArgs: string[]
}
interface BrowserState {
@@ -26,6 +27,12 @@ interface BrowserState {
let browserState: BrowserState | null = null
function shouldLogBrowserOutput(): boolean {
return (
process.env.CI === 'true' || process.env.BROWSEROS_TEST_DEBUG === 'true'
)
}
export async function isBrowserRunning(cdpPort: number): Promise<boolean> {
try {
const response = await fetch(`http://127.0.0.1:${cdpPort}/json/version`, {
@@ -74,6 +81,7 @@ export async function spawnBrowser(
'--show-component-extension-options',
'--enable-logging=stderr',
...(config.headless ? ['--headless=new'] : []),
...config.extraArgs,
`--user-data-dir=${config.userDataDir}`,
// TODO: replace with --browseros-cdp-port once we fix the browseros bug
`--remote-debugging-port=${config.cdpPort}`,
@@ -86,14 +94,18 @@ export async function spawnBrowser(
},
)
browserProcess.stdout?.on('data', (_data) => {
// Uncomment for debugging
// console.log(`[BROWSER] ${_data.toString().trim()}`)
browserProcess.stdout?.on('data', (data) => {
if (!shouldLogBrowserOutput()) {
return
}
console.log(`[BROWSER] ${data.toString().trim()}`)
})
browserProcess.stderr?.on('data', (_data) => {
// Uncomment for debugging
// console.log(`[BROWSER] ${_data.toString().trim()}`)
browserProcess.stderr?.on('data', (data) => {
if (!shouldLogBrowserOutput()) {
return
}
console.error(`[BROWSER] ${data.toString().trim()}`)
})
browserProcess.on('error', (error) => {

View File

@@ -132,6 +132,7 @@ export async function ensureBrowserOS(
binaryPath: runtimePlan.binaryPath,
userDataDir: runtimePlan.userDataDir,
headless: runtimePlan.headless,
extraArgs: runtimePlan.extraArgs,
}
await spawnBrowser(browserConfig)

View File

@@ -20,9 +20,20 @@ export interface TestRuntimePlan {
userDataDir: string
binaryPath: string
headless: boolean
extraArgs: string[]
usesFixedPorts: boolean
}
function parseExtraArgs(value: string | undefined): string[] {
if (!value) {
return []
}
return value
.split(/\s+/)
.map((part) => part.trim())
.filter((part) => part.length > 0)
}
function parsePort(
value: string | undefined,
envName: string,
@@ -137,12 +148,14 @@ export async function createTestRuntimePlan(): Promise<TestRuntimePlan> {
const resolvedPorts = await resolveRuntimePorts()
const userDataDir = mkdtempSync(join(tmpdir(), 'browseros-test-'))
const headless = process.env.BROWSEROS_TEST_HEADLESS === 'true'
const extraArgs = parseExtraArgs(process.env.BROWSEROS_TEST_EXTRA_ARGS)
return {
ports: resolvedPorts.ports,
userDataDir,
binaryPath: DEFAULT_BINARY_PATH,
headless,
extraArgs,
usesFixedPorts: resolvedPorts.usesFixedPorts,
}
}

View File

@@ -46,6 +46,7 @@ async function getOrCreateBrowser(): Promise<Browser> {
binaryPath: runtimePlan.binaryPath,
userDataDir: runtimePlan.userDataDir,
headless: runtimePlan.headless,
extraArgs: runtimePlan.extraArgs,
}
await spawnBrowser(config)

View File

@@ -0,0 +1,178 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { describe, it } from 'bun:test'
import assert from 'node:assert'
import { Jimp } from 'jimp'
import { createCopilotFetch } from '../../src/lib/clients/oauth/copilot-fetch'
function makeImageBody(dataUrls: string[]) {
return JSON.stringify({
messages: [
{
role: 'user',
content: dataUrls.map((url) => ({
type: 'image_url',
image_url: { url },
})),
},
],
})
}
async function createTestImage(
width: number,
height: number,
hasAlpha = false,
): Promise<string> {
const image = new Jimp({ width, height, color: 0xff0000ff })
if (hasAlpha) {
// Set some pixels to transparent so hasAlpha() returns true
for (let x = 0; x < Math.min(width, 10); x++) {
image.setPixelColor(0xff000080, x, 0)
}
}
const mime = hasAlpha ? 'image/png' : 'image/jpeg'
const buffer = await image.getBuffer(mime)
return `data:${mime};base64,${buffer.toString('base64')}`
}
function parseDataUrl(dataUrl: string) {
const [header, b64] = dataUrl.split(',')
const mime = header.match(/data:([^;]+)/)?.[1]
return { mime, buffer: Buffer.from(b64, 'base64') }
}
describe('createCopilotFetch', () => {
it('sets Copilot headers on every request', async () => {
const copilotFetch = createCopilotFetch()
const calls: { input: RequestInfo | URL; init?: RequestInit }[] = []
const originalFetch = globalThis.fetch
globalThis.fetch = (async (
input: RequestInfo | URL,
init?: RequestInit,
) => {
calls.push({ input, init })
return new Response('ok')
}) as typeof fetch
try {
await copilotFetch('https://api.example.com', {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: [{ role: 'user', content: 'hi' }] }),
})
assert.strictEqual(calls.length, 1)
const headers = new Headers(calls[0].init?.headers as HeadersInit)
assert.strictEqual(headers.get('Openai-Intent'), 'conversation-edits')
assert.strictEqual(headers.get('x-initiator'), 'user')
} finally {
globalThis.fetch = originalFetch
}
})
it('sets Copilot-Vision-Request header when images present', async () => {
const copilotFetch = createCopilotFetch()
const calls: { input: RequestInfo | URL; init?: RequestInit }[] = []
const originalFetch = globalThis.fetch
globalThis.fetch = (async (
input: RequestInfo | URL,
init?: RequestInit,
) => {
calls.push({ input, init })
return new Response('ok')
}) as typeof fetch
try {
const dataUrl = await createTestImage(100, 100)
await copilotFetch('https://api.example.com', {
body: makeImageBody([dataUrl]),
})
const headers = new Headers(calls[0].init?.headers as HeadersInit)
assert.strictEqual(headers.get('Copilot-Vision-Request'), 'true')
} finally {
globalThis.fetch = originalFetch
}
})
})
describe('image resizing', () => {
async function resizeViaFetch(dataUrl: string): Promise<string> {
let capturedBody = ''
const originalFetch = globalThis.fetch
globalThis.fetch = (async (_: RequestInfo | URL, init?: RequestInit) => {
capturedBody = init?.body as string
return new Response('ok')
}) as typeof fetch
try {
const copilotFetch = createCopilotFetch()
await copilotFetch('https://api.example.com', {
body: makeImageBody([dataUrl]),
})
const parsed = JSON.parse(capturedBody)
return parsed.messages[0].content[0].image_url.url
} finally {
globalThis.fetch = originalFetch
}
}
it('does not resize images already within limits', async () => {
const dataUrl = await createTestImage(800, 600)
const result = await resizeViaFetch(dataUrl)
assert.strictEqual(result, dataUrl)
})
it('scales down when longest side exceeds 2048', async () => {
const dataUrl = await createTestImage(4096, 2048)
const result = await resizeViaFetch(dataUrl)
assert.notStrictEqual(result, dataUrl)
const { buffer } = parseDataUrl(result)
const resized = await Jimp.fromBuffer(buffer)
assert.ok(resized.width <= 2048)
assert.ok(resized.height <= 1024)
})
it('scales down when shortest side exceeds 768', async () => {
const dataUrl = await createTestImage(2000, 1500)
const result = await resizeViaFetch(dataUrl)
assert.notStrictEqual(result, dataUrl)
const { buffer } = parseDataUrl(result)
const resized = await Jimp.fromBuffer(buffer)
assert.ok(Math.min(resized.width, resized.height) <= 768)
})
it('outputs JPEG for opaque images', async () => {
const dataUrl = await createTestImage(4096, 3072, false)
const result = await resizeViaFetch(dataUrl)
const { mime } = parseDataUrl(result)
assert.strictEqual(mime, 'image/jpeg')
})
it('outputs PNG for images with alpha', async () => {
const dataUrl = await createTestImage(4096, 3072, true)
const result = await resizeViaFetch(dataUrl)
const { mime } = parseDataUrl(result)
assert.strictEqual(mime, 'image/png')
})
it('applies both scaling steps (long side then short side)', async () => {
// 4000x3000: step 1 scales to 2048x1536, step 2 scales 1536→768 so 1024x768
const dataUrl = await createTestImage(4000, 3000)
const result = await resizeViaFetch(dataUrl)
const { buffer } = parseDataUrl(result)
const resized = await Jimp.fromBuffer(buffer)
assert.ok(resized.width <= 2048)
assert.ok(Math.min(resized.width, resized.height) <= 768)
})
})

View File

@@ -0,0 +1,100 @@
/**
* @license
* Copyright 2025 BrowserOS
*
* Build smoke test — compiles the server binary and verifies --version output.
* Catches compile failures, broken imports, and version injection bugs.
*/
import { afterAll, describe, it } from 'bun:test'
import assert from 'node:assert'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
// Derive the build target from the current platform so the test is portable
function getNativeTarget(): { id: string; ext: string } {
const os =
process.platform === 'darwin'
? 'darwin'
: process.platform === 'win32'
? 'windows'
: 'linux'
const cpu = process.arch === 'arm64' ? 'arm64' : 'x64'
return { id: `${os}-${cpu}`, ext: process.platform === 'win32' ? '.exe' : '' }
}
// Stub values so the build config validation passes without real secrets
const BUILD_ENV_STUBS: Record<string, string> = {
BROWSEROS_CONFIG_URL: 'https://stub.test/config',
CODEGEN_SERVICE_URL: 'https://stub.test/codegen',
POSTHOG_API_KEY: 'phc_test_stub',
SENTRY_DSN: 'https://stub@sentry.test/0',
R2_ACCOUNT_ID: 'test',
R2_ACCESS_KEY_ID: 'test',
R2_SECRET_ACCESS_KEY: 'test',
R2_BUCKET: 'test',
}
describe('server build', () => {
const rootDir = resolve(import.meta.dir, '../../..')
const serverPkgPath = resolve(rootDir, 'apps/server/package.json')
const buildScript = resolve(rootDir, 'scripts/build/server.ts')
const target = getNativeTarget()
const binaryPath = resolve(
rootDir,
`dist/prod/server/.tmp/binaries/browseros-server-${target.id}${target.ext}`,
)
// Empty manifest so the build skips R2 resource downloads
const tempDir = mkdtempSync(join(tmpdir(), 'browseros-build-test-'))
const emptyManifestPath = join(tempDir, 'empty-manifest.json')
writeFileSync(emptyManifestPath, JSON.stringify({ resources: [] }))
afterAll(() => {
rmSync(tempDir, { recursive: true, force: true })
})
it('compiles and --version outputs correct version', async () => {
const pkg = await Bun.file(serverPkgPath).json()
const expectedVersion: string = pkg.version
const build = Bun.spawn(
[
'bun',
buildScript,
`--target=${target.id}`,
'--no-upload',
`--manifest=${emptyManifestPath}`,
],
{
cwd: rootDir,
stdout: 'pipe',
stderr: 'pipe',
env: { ...process.env, ...BUILD_ENV_STUBS },
},
)
const buildExit = await build.exited
if (buildExit !== 0) {
const stderr = await new Response(build.stderr).text()
assert.fail(`Build failed (exit ${buildExit}):\n${stderr}`)
}
const proc = Bun.spawn([binaryPath, '--version'], {
stdout: 'pipe',
stderr: 'pipe',
})
const [versionOutput, versionStderr] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
])
const versionExit = await proc.exited
assert.strictEqual(
versionExit,
0,
`Binary --version exited non-zero:\n${versionStderr}`,
)
assert.strictEqual(versionOutput.trim(), expectedVersion)
}, 300_000)
})

View File

@@ -55,7 +55,7 @@ describe('filesystem_bash', () => {
})
it('times out long-running commands', async () => {
const result = await exec({ command: 'sleep 30', timeout: 1 })
const result = await exec({ command: 'exec sleep 30', timeout: 1 })
expect(result.isError).toBe(true)
expect(result.text).toContain('timed out')
}, 10_000)

View File

@@ -1,5 +1,7 @@
import { describe, it } from 'bun:test'
import assert from 'node:assert'
import type { Browser } from '../../src/browser/browser'
import { executeTool, type ToolContext } from '../../src/tools/framework'
import {
check,
click,
@@ -320,37 +322,48 @@ describe('input tools', () => {
}, 60_000)
it('scroll dispatches without error', async () => {
await withBrowser(async ({ execute }) => {
const newResult = await execute(new_page, {
url: FORM_PAGE,
})
const pageId = pageIdOf(newResult)
const calls: Array<{
page: number
direction: string
amount: number
element?: number
}> = []
const browser = {
getTabIdForPage: () => undefined,
scroll: async (
page: number,
direction: string,
amount: number,
element?: number,
) => {
calls.push({ page, direction, amount, element })
},
} as unknown as Browser
const ctx: ToolContext = {
browser,
directories: { workingDir: process.cwd() },
}
const before = await execute(evaluate_script, {
page: pageId,
expression: 'window.scrollY',
})
const result = await executeTool(
scroll,
{ page: 7, direction: 'down', amount: 5 },
ctx,
AbortSignal.timeout(1_000),
)
const scrollResult = await execute(scroll, {
page: pageId,
direction: 'down',
amount: 5,
})
assert.ok(!scrollResult.isError, textOf(scrollResult))
assert.ok(textOf(scrollResult).includes('Scrolled down'))
const after = await execute(evaluate_script, {
page: pageId,
expression: 'window.scrollY',
})
assert.ok(
Number(textOf(after)) > Number(textOf(before)),
`Expected scrollY to increase, before=${textOf(before)} after=${textOf(after)}`,
)
await execute(close_page, { page: pageId })
assert.ok(!result.isError, textOf(result))
assert.ok(textOf(result).includes('Scrolled down'))
assert.deepStrictEqual(calls, [
{ page: 7, direction: 'down', amount: 5, element: undefined },
])
assert.deepStrictEqual(structuredOf(result), {
action: 'scroll',
page: 7,
direction: 'down',
amount: 5,
element: undefined,
})
}, 60_000)
})
it('hover moves cursor over element', async () => {
await withBrowser(async ({ execute }) => {

View File

@@ -203,7 +203,8 @@ describe('observation tools', () => {
savedPath = data.path
assert.strictEqual(data.writtenToFile, true)
assert.ok(textOf(contentResult).includes('Saved page content'))
assert.ok(textOf(contentResult).includes('Content truncated'))
assert.ok(textOf(contentResult).includes(savedPath))
assert.ok(existsSync(savedPath), 'Saved page content file should exist')
assert.ok(
dirname(savedPath).startsWith(

View File

@@ -30,13 +30,13 @@ function structuredOf<T>(result: { structuredContent?: unknown }): T {
function createToolContext(
browser: Browser,
executionDir: string,
workingDir: string,
resourcesDir?: string,
): ToolContext {
return {
browser,
directories: {
executionDir,
workingDir,
resourcesDir,
},
}
@@ -50,10 +50,8 @@ function createBrowserStub(methods: Record<string, unknown>): Browser {
}
describe('page action tools', () => {
it('save_pdf resolves relative paths against the execution directory by default', async () => {
const executionDir = await mkdtemp(
join(tmpdir(), 'browseros-page-actions-'),
)
it('save_pdf resolves relative paths against the working directory by default', async () => {
const workingDir = await mkdtemp(join(tmpdir(), 'browseros-page-actions-'))
const browser = createBrowserStub({
printToPDF: async () => ({
data: Buffer.from('pdf-data').toString('base64'),
@@ -64,26 +62,24 @@ describe('page action tools', () => {
const result = await executeTool(
save_pdf,
{ page: 1, path: 'report.pdf' },
createToolContext(browser, executionDir),
createToolContext(browser, workingDir),
AbortSignal.timeout(1_000),
)
assert.ok(!result.isError, textOf(result))
const outputPath = join(executionDir, 'report.pdf')
const outputPath = join(workingDir, 'report.pdf')
assert.strictEqual(
structuredOf<{ path: string }>(result).path,
outputPath,
)
assert.ok(existsSync(outputPath), 'PDF file should exist in executionDir')
assert.ok(existsSync(outputPath), 'PDF file should exist in workingDir')
} finally {
await rm(executionDir, { recursive: true, force: true })
await rm(workingDir, { recursive: true, force: true })
}
})
it('save_screenshot still honors an explicit cwd override', async () => {
const executionDir = await mkdtemp(
join(tmpdir(), 'browseros-page-actions-'),
)
const workingDir = await mkdtemp(join(tmpdir(), 'browseros-page-actions-'))
const overrideDir = await mkdtemp(join(tmpdir(), 'browseros-page-actions-'))
const browser = createBrowserStub({
screenshot: async () => ({
@@ -95,7 +91,7 @@ describe('page action tools', () => {
const result = await executeTool(
save_screenshot,
{ page: 1, path: 'capture.png', cwd: overrideDir },
createToolContext(browser, executionDir),
createToolContext(browser, workingDir),
AbortSignal.timeout(1_000),
)
@@ -110,18 +106,18 @@ describe('page action tools', () => {
'Screenshot should exist in overrideDir',
)
assert.ok(
!existsSync(join(executionDir, 'capture.png')),
'Execution directory should not be used when cwd is provided',
!existsSync(join(workingDir, 'capture.png')),
'Working directory should not be used when cwd is provided',
)
} finally {
await rm(executionDir, { recursive: true, force: true })
await rm(workingDir, { recursive: true, force: true })
await rm(overrideDir, { recursive: true, force: true })
}
})
it('download_file resolves relative directories against the execution directory by default', async () => {
it('download_file resolves relative directories against the working directory by default', async () => {
const baseDir = await mkdtemp(join(tmpdir(), 'browseros-page-actions-'))
const executionDir = join(baseDir, 'execution')
const workingDir = join(baseDir, 'working')
let stagingDir: string | undefined
const browser = createBrowserStub({
downloadViaClick: async (
@@ -143,23 +139,23 @@ describe('page action tools', () => {
const result = await executeTool(
download_file,
{ page: 1, element: 7, path: '.' },
createToolContext(browser, executionDir),
createToolContext(browser, workingDir),
AbortSignal.timeout(1_000),
)
assert.ok(!result.isError, textOf(result))
const outputPath = join(executionDir, 'download.txt')
const outputPath = join(workingDir, 'download.txt')
const structured = structuredOf<{
directory: string
destinationPath: string
}>(result)
assert.strictEqual(structured.directory, executionDir)
assert.strictEqual(structured.directory, workingDir)
assert.strictEqual(structured.destinationPath, outputPath)
assert.ok(existsSync(outputPath), 'Download should land in executionDir')
assert.ok(existsSync(outputPath), 'Download should land in workingDir')
assert.ok(stagingDir, 'Download should use a staging directory')
assert.ok(
stagingDir.startsWith(join(executionDir, 'browseros-dl-')),
'Staging directory should be created inside executionDir',
stagingDir.startsWith(join(workingDir, 'browseros-dl-')),
'Staging directory should be created inside workingDir',
)
assert.ok(
!existsSync(stagingDir),

View File

@@ -149,6 +149,7 @@
"version": "0.1.0",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.63",
"@aws-sdk/client-s3": "^3.1014.0",
"@browseros/server": "workspace:*",
"@browseros/shared": "workspace:*",
"@google/gemini-cli-core": "^0.16.0",
@@ -169,7 +170,7 @@
},
"apps/server": {
"name": "@browseros/server",
"version": "0.0.77",
"version": "0.0.79",
"bin": {
"browseros-server": "./src/index.ts",
},
@@ -203,11 +204,11 @@
"fuse.js": "^7.1.0",
"gray-matter": "^4.0.3",
"hono": "^4.12.3",
"jimp": "^1.6.0",
"klavis": "^2.15.0",
"pino": "^9.6.0",
"posthog-node": "^4.17.0",
"puppeteer-core": "24.23.0",
"sharp": "^0.34.5",
"ws": "^8.18.0",
"zod": "^3.24.2",
"zod-from-json-schema": "^0.1.0",
@@ -895,6 +896,62 @@
"@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
"@jimp/core": ["@jimp/core@1.6.0", "", { "dependencies": { "@jimp/file-ops": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "await-to-js": "^3.0.0", "exif-parser": "^0.1.12", "file-type": "^16.0.0", "mime": "3" } }, "sha512-EQQlKU3s9QfdJqiSrZWNTxBs3rKXgO2W+GxNXDtwchF3a4IqxDheFX1ti+Env9hdJXDiYLp2jTRjlxhPthsk8w=="],
"@jimp/diff": ["@jimp/diff@1.6.0", "", { "dependencies": { "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "pixelmatch": "^5.3.0" } }, "sha512-+yUAQ5gvRC5D1WHYxjBHZI7JBRusGGSLf8AmPRPCenTzh4PA+wZ1xv2+cYqQwTfQHU5tXYOhA0xDytfHUf1Zyw=="],
"@jimp/file-ops": ["@jimp/file-ops@1.6.0", "", {}, "sha512-Dx/bVDmgnRe1AlniRpCKrGRm5YvGmUwbDzt+MAkgmLGf+jvBT75hmMEZ003n9HQI/aPnm/YKnXjg/hOpzNCpHQ=="],
"@jimp/js-bmp": ["@jimp/js-bmp@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "bmp-ts": "^1.0.9" } }, "sha512-FU6Q5PC/e3yzLyBDXupR3SnL3htU7S3KEs4e6rjDP6gNEOXRFsWs6YD3hXuXd50jd8ummy+q2WSwuGkr8wi+Gw=="],
"@jimp/js-gif": ["@jimp/js-gif@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "gifwrap": "^0.10.1", "omggif": "^1.0.10" } }, "sha512-N9CZPHOrJTsAUoWkWZstLPpwT5AwJ0wge+47+ix3++SdSL/H2QzyMqxbcDYNFe4MoI5MIhATfb0/dl/wmX221g=="],
"@jimp/js-jpeg": ["@jimp/js-jpeg@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "jpeg-js": "^0.4.4" } }, "sha512-6vgFDqeusblf5Pok6B2DUiMXplH8RhIKAryj1yn+007SIAQ0khM1Uptxmpku/0MfbClx2r7pnJv9gWpAEJdMVA=="],
"@jimp/js-png": ["@jimp/js-png@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "pngjs": "^7.0.0" } }, "sha512-AbQHScy3hDDgMRNfG0tPjL88AV6qKAILGReIa3ATpW5QFjBKpisvUaOqhzJ7Reic1oawx3Riyv152gaPfqsBVg=="],
"@jimp/js-tiff": ["@jimp/js-tiff@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "utif2": "^4.1.0" } }, "sha512-zhReR8/7KO+adijj3h0ZQUOiun3mXUv79zYEAKvE0O+rP7EhgtKvWJOZfRzdZSNv0Pu1rKtgM72qgtwe2tFvyw=="],
"@jimp/plugin-blit": ["@jimp/plugin-blit@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-M+uRWl1csi7qilnSK8uxK4RJMSuVeBiO1AY0+7APnfUbQNZm6hCe0CCFv1Iyw1D/Dhb8ph8fQgm5mwM0eSxgVA=="],
"@jimp/plugin-blur": ["@jimp/plugin-blur@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/utils": "1.6.0" } }, "sha512-zrM7iic1OTwUCb0g/rN5y+UnmdEsT3IfuCXCJJNs8SZzP0MkZ1eTvuwK9ZidCuMo4+J3xkzCidRwYXB5CyGZTw=="],
"@jimp/plugin-circle": ["@jimp/plugin-circle@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-xt1Gp+LtdMKAXfDp3HNaG30SPZW6AQ7dtAtTnoRKorRi+5yCJjKqXRgkewS5bvj8DEh87Ko1ydJfzqS3P2tdWw=="],
"@jimp/plugin-color": ["@jimp/plugin-color@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "tinycolor2": "^1.6.0", "zod": "^3.23.8" } }, "sha512-J5q8IVCpkBsxIXM+45XOXTrsyfblyMZg3a9eAo0P7VPH4+CrvyNQwaYatbAIamSIN1YzxmO3DkIZXzRjFSz1SA=="],
"@jimp/plugin-contain": ["@jimp/plugin-contain@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-oN/n+Vdq/Qg9bB4yOBOxtY9IPAtEfES8J1n9Ddx+XhGBYT1/QTU/JYkGaAkIGoPnyYvmLEDqMz2SGihqlpqfzQ=="],
"@jimp/plugin-cover": ["@jimp/plugin-cover@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-Iow0h6yqSC269YUJ8HC3Q/MpCi2V55sMlbkkTTx4zPvd8mWZlC0ykrNDeAy9IJegrQ7v5E99rJwmQu25lygKLA=="],
"@jimp/plugin-crop": ["@jimp/plugin-crop@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-KqZkEhvs+21USdySCUDI+GFa393eDIzbi1smBqkUPTE+pRwSWMAf01D5OC3ZWB+xZsNla93BDS9iCkLHA8wang=="],
"@jimp/plugin-displace": ["@jimp/plugin-displace@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-4Y10X9qwr5F+Bo5ME356XSACEF55485j5nGdiyJ9hYzjQP9nGgxNJaZ4SAOqpd+k5sFaIeD7SQ0Occ26uIng5Q=="],
"@jimp/plugin-dither": ["@jimp/plugin-dither@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0" } }, "sha512-600d1RxY0pKwgyU0tgMahLNKsqEcxGdbgXadCiVCoGd6V6glyCvkNrnnwC0n5aJ56Htkj88PToSdF88tNVZEEQ=="],
"@jimp/plugin-fisheye": ["@jimp/plugin-fisheye@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-E5QHKWSCBFtpgZarlmN3Q6+rTQxjirFqo44ohoTjzYVrDI6B6beXNnPIThJgPr0Y9GwfzgyarKvQuQuqCnnfbA=="],
"@jimp/plugin-flip": ["@jimp/plugin-flip@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-/+rJVDuBIVOgwoyVkBjUFHtP+wmW0r+r5OQ2GpatQofToPVbJw1DdYWXlwviSx7hvixTWLKVgRWQ5Dw862emDg=="],
"@jimp/plugin-hash": ["@jimp/plugin-hash@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/js-bmp": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/js-tiff": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "any-base": "^1.1.0" } }, "sha512-wWzl0kTpDJgYVbZdajTf+4NBSKvmI3bRI8q6EH9CVeIHps9VWVsUvEyb7rpbcwVLWYuzDtP2R0lTT6WeBNQH9Q=="],
"@jimp/plugin-mask": ["@jimp/plugin-mask@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-Cwy7ExSJMZszvkad8NV8o/Z92X2kFUFM8mcDAhNVxU0Q6tA0op2UKRJY51eoK8r6eds/qak3FQkXakvNabdLnA=="],
"@jimp/plugin-print": ["@jimp/plugin-print@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/types": "1.6.0", "parse-bmfont-ascii": "^1.0.6", "parse-bmfont-binary": "^1.0.6", "parse-bmfont-xml": "^1.1.6", "simple-xml-to-json": "^1.2.2", "zod": "^3.23.8" } }, "sha512-zarTIJi8fjoGMSI/M3Xh5yY9T65p03XJmPsuNet19K/Q7mwRU6EV2pfj+28++2PV2NJ+htDF5uecAlnGyxFN2A=="],
"@jimp/plugin-quantize": ["@jimp/plugin-quantize@1.6.0", "", { "dependencies": { "image-q": "^4.0.0", "zod": "^3.23.8" } }, "sha512-EmzZ/s9StYQwbpG6rUGBCisc3f64JIhSH+ncTJd+iFGtGo0YvSeMdAd+zqgiHpfZoOL54dNavZNjF4otK+mvlg=="],
"@jimp/plugin-resize": ["@jimp/plugin-resize@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-uSUD1mqXN9i1SGSz5ov3keRZ7S9L32/mAQG08wUwZiEi5FpbV0K8A8l1zkazAIZi9IJzLlTauRNU41Mi8IF9fA=="],
"@jimp/plugin-rotate": ["@jimp/plugin-rotate@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-JagdjBLnUZGSG4xjCLkIpQOZZ3Mjbg8aGCCi4G69qR+OjNpOeGI7N2EQlfK/WE8BEHOW5vdjSyglNqcYbQBWRw=="],
"@jimp/plugin-threshold": ["@jimp/plugin-threshold@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-hash": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-M59m5dzLoHOVWdM41O8z9SyySzcDn43xHseOH0HavjsfQsT56GGCC4QzU1banJidbUrePhzoEdS42uFE8Fei8w=="],
"@jimp/types": ["@jimp/types@1.6.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-7UfRsiKo5GZTAATxm2qQ7jqmUXP0DxTArztllTcYdyw6Xi5oT4RaoXynVtCD4UyLK5gJgkZJcwonoijrhYFKfg=="],
"@jimp/utils": ["@jimp/utils@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "tinycolor2": "^1.6.0" } }, "sha512-gqFTGEosKbOkYF/WFj26jMHOI5OH2jeP1MmC/zbK6BF6VJBf8rIC5898dPfSzZEbSA0wbbV5slbntWVc5PKLFA=="],
"@joshua.litt/get-ripgrep": ["@joshua.litt/get-ripgrep@0.0.3", "", { "dependencies": { "@lvce-editor/verror": "^1.6.0", "execa": "^9.5.2", "extract-zip": "^2.0.1", "fs-extra": "^11.3.0", "got": "^14.4.5", "path-exists": "^5.0.0", "xdg-basedir": "^5.1.0" } }, "sha512-rycdieAKKqXi2bsM7G2ayDiNk5CAX8ZOzsTQsirfOqUKPef04Xw40BWGGyimaOOuvPgLWYt3tPnLLG3TvPXi5Q=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
@@ -1735,6 +1792,8 @@
"@theguild/federation-composition": ["@theguild/federation-composition@0.21.3", "", { "dependencies": { "constant-case": "^3.0.4", "debug": "4.4.3", "json5": "^2.2.3", "lodash.sortby": "^4.7.0" }, "peerDependencies": { "graphql": "^16.0.0" } }, "sha512-+LlHTa4UbRpZBog3ggAxjYIFvdfH3UMvvBUptur19TMWkqU4+n3GmN+mDjejU+dyBXIG27c25RsiQP1HyvM99g=="],
"@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
"@tokenlens/core": ["@tokenlens/core@1.3.0", "", {}, "sha512-d8YNHNC+q10bVpi95fELJwJyPVf1HfvBEI18eFQxRSZTdByXrP+f/ZtlhSzkx0Jl0aEmYVeBA5tPeeYRioLViQ=="],
"@tokenlens/fetch": ["@tokenlens/fetch@1.3.0", "", { "dependencies": { "@tokenlens/core": "1.3.0" } }, "sha512-RONDRmETYly9xO8XMKblmrZjKSwCva4s5ebJwQNfNlChZoA5kplPoCgnWceHnn1J1iRjLVlrCNB43ichfmGBKQ=="],
@@ -2053,6 +2112,8 @@
"antd-style": ["antd-style@3.7.1", "", { "dependencies": { "@ant-design/cssinjs": "^1.21.1", "@babel/runtime": "^7.24.1", "@emotion/cache": "^11.11.0", "@emotion/css": "^11.11.2", "@emotion/react": "^11.11.4", "@emotion/serialize": "^1.1.3", "@emotion/utils": "^1.2.1", "use-merge-value": "^1.2.0" }, "peerDependencies": { "antd": ">=5.8.1", "react": ">=18" } }, "sha512-CQOfddVp4aOvBfCepa+Kj2e7ap+2XBINg1Kn2osdE3oQvrD7KJu/K0sfnLcFLkgCJygbxmuazYdWLKb+drPDYA=="],
"any-base": ["any-base@1.1.0", "", {}, "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg=="],
"anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
@@ -2087,6 +2148,8 @@
"auto-bind": ["auto-bind@4.0.0", "", {}, "sha512-Hdw8qdNiqdJ8LqT0iK0sVzkFbzg6fhnQqqfWhBDxcHZvU75+B+ayzTy8x+k5Ix0Y92XOhOUlx74ps+bA6BeYMQ=="],
"await-to-js": ["await-to-js@3.0.0", "", {}, "sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g=="],
"aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="],
"axios": ["axios@1.13.5", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q=="],
@@ -2127,6 +2190,8 @@
"bluebird": ["bluebird@3.7.2", "", {}, "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="],
"bmp-ts": ["bmp-ts@1.0.9", "", {}, "sha512-cTEHk2jLrPyi+12M3dhpEbnnPOsaZuq7C45ylbbQIiWgDFZq4UVYPEY5mlqjvsj/6gJv9qX5sa+ebDzLXT28Vw=="],
"body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
@@ -2215,7 +2280,7 @@
"chrome-devtools-frontend": ["chrome-devtools-frontend@1.0.1577886", "", {}, "sha512-B9hY3o/0RuVCDWNYh9YnkEbRrPUMCY+NaOgBxvZRzGvqbGSMNckkVSdO67SwWR8bm4fo/qplXbUj0cSr229V6w=="],
"chrome-devtools-mcp": ["chrome-devtools-mcp@0.20.2", "", { "bin": { "chrome-devtools-mcp": "build/src/bin/chrome-devtools-mcp.js", "chrome-devtools": "build/src/bin/chrome-devtools.js" } }, "sha512-QYwRj8YJjvFHODiYVDJpHaUYLD8/wt0DP+HXQPn/IF+QbbAfr7Vn2JtACyrIPEzTX3XJXgDPZkr4gSYHRDgqvQ=="],
"chrome-devtools-mcp": ["chrome-devtools-mcp@0.20.3", "", { "bin": { "chrome-devtools-mcp": "build/src/bin/chrome-devtools-mcp.js", "chrome-devtools": "build/src/bin/chrome-devtools.js" } }, "sha512-6MlNKlKa+J1FX9w4SUnFERF4MRGWLlrnZvIJGhhsuuMPM7qUG0F4SwheRyjwl0+tsTemxMCBHiib8mXkg5j6og=="],
"chrome-launcher": ["chrome-launcher@1.2.0", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^2.0.1" }, "bin": { "print-chrome-path": "bin/print-chrome-path.cjs" } }, "sha512-JbuGuBNss258bvGil7FT4HKdC3SC2K7UAEUqiPy3ACS3Yxo3hAW6bvFpCu2HsIJLgTqxgEX6BkujvzZfLpUD0Q=="],
@@ -2619,6 +2684,8 @@
"execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="],
"exif-parser": ["exif-parser@0.1.12", "", {}, "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw=="],
"express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
"express-rate-limit": ["express-rate-limit@8.2.1", "", { "dependencies": { "ip-address": "10.0.1" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g=="],
@@ -2677,6 +2744,8 @@
"file-selector": ["file-selector@0.5.0", "", { "dependencies": { "tslib": "^2.0.3" } }, "sha512-s8KNnmIDTBoD0p9uJ9uD0XY38SCeBOtj0UMXyQSLg1Ypfrfj8+dAvwsLjYQkQ2GjhVtp2HrnF5cJzMhBjfD8HA=="],
"file-type": ["file-type@16.5.4", "", { "dependencies": { "readable-web-to-node-stream": "^3.0.0", "strtok3": "^6.2.4", "token-types": "^4.1.1" } }, "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw=="],
"filesize": ["filesize@11.0.13", "", {}, "sha512-mYJ/qXKvREuO0uH8LTQJ6v7GsUvVOguqxg2VTwQUkyTPXXRRWPdjuUPVqdBrJQhvci48OHlNGRnux+Slr2Rnvw=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
@@ -2755,6 +2824,8 @@
"get-value": ["get-value@2.0.6", "", {}, "sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA=="],
"gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="],
"giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="],
"giscus": ["giscus@1.6.0", "", { "dependencies": { "lit": "^3.2.1" } }, "sha512-Zrsi8r4t1LVW950keaWcsURuZUQwUaMKjvJgTCY125vkW6OiEBkatE7ScJDbpqKHdZwb///7FVC21SE3iFK3PQ=="],
@@ -2893,6 +2964,8 @@
"ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
"image-q": ["image-q@4.0.0", "", { "dependencies": { "@types/node": "16.9.1" } }, "sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw=="],
"immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="],
"immer": ["immer@11.1.3", "", {}, "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q=="],
@@ -3017,12 +3090,16 @@
"jest-worker": ["jest-worker@27.5.1", "", { "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", "supports-color": "^8.0.0" } }, "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg=="],
"jimp": ["jimp@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/diff": "1.6.0", "@jimp/js-bmp": "1.6.0", "@jimp/js-gif": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/js-tiff": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/plugin-blur": "1.6.0", "@jimp/plugin-circle": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-contain": "1.6.0", "@jimp/plugin-cover": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-displace": "1.6.0", "@jimp/plugin-dither": "1.6.0", "@jimp/plugin-fisheye": "1.6.0", "@jimp/plugin-flip": "1.6.0", "@jimp/plugin-hash": "1.6.0", "@jimp/plugin-mask": "1.6.0", "@jimp/plugin-print": "1.6.0", "@jimp/plugin-quantize": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/plugin-rotate": "1.6.0", "@jimp/plugin-threshold": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0" } }, "sha512-YcwCHw1kiqEeI5xRpDlPPBGL2EOpBKLwO4yIBJcXWHPj5PnA5urGq0jbyhM5KoNpypQ6VboSoxc9D8HyfvngSg=="],
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="],
"joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="],
"jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="],
"js-base64": ["js-base64@3.7.7", "", {}, "sha512-7rCnleh0z2CkXhH67J8K1Ytz0b2Y+yxTPL+/KOJoa20hfnVQ/3/T6W/KflYI4bRHRagNeXeU2bkNGI3v1oS/lw=="],
"js-cookie": ["js-cookie@3.0.5", "", {}, "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw=="],
@@ -3467,6 +3544,8 @@
"ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="],
"omggif": ["omggif@1.0.10", "", {}, "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw=="],
"on-change": ["on-change@4.0.2", "", {}, "sha512-cMtCyuJmTx/bg2HCpHo3ZLeF7FZnBOapLqZHr2AlLeJ5Ul0Zu2mUJJz051Fdwu/Et2YW04ZD+TtU+gVy0ACNCA=="],
"on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="],
@@ -3521,6 +3600,12 @@
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
"parse-bmfont-ascii": ["parse-bmfont-ascii@1.0.6", "", {}, "sha512-U4RrVsUFCleIOBsIGYOMKjn9PavsGOXxbvYGtMOEfnId0SVNsgehXh1DxUdVPLoxd5mvcEtvmKs2Mmf0Mpa1ZA=="],
"parse-bmfont-binary": ["parse-bmfont-binary@1.0.6", "", {}, "sha512-GxmsRea0wdGdYthjuUeWTMWPqm2+FAd4GI8vCvhgJsFnoGhTrLhXDDupwTo7rXVAgaLIGoVHDZS9p/5XbSqeWA=="],
"parse-bmfont-xml": ["parse-bmfont-xml@1.1.6", "", { "dependencies": { "xml-parse-from-string": "^1.0.0", "xml2js": "^0.5.0" } }, "sha512-0cEliVMZEhrFDwMh4SxIyVJpqYoOWDJ9P895tFuS+XuNzI5UBmBk5U5O4KuJdTnZpSBI4LFA2+ZiJaiwfSwlMA=="],
"parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
"parse-filepath": ["parse-filepath@1.0.2", "", { "dependencies": { "is-absolute": "^1.0.0", "map-cache": "^0.2.0", "path-root": "^0.1.1" } }, "sha512-FwdRXKCohSVeXqwtYonZTXtbGJKrn+HNyWDYVcp5yuJlesTwNH4rsmRZ+GrKAPJ5bLpRxESMeS+Rl0VCHRvB2Q=="],
@@ -3543,6 +3628,8 @@
"path-exists": ["path-exists@5.0.0", "", {}, "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ=="],
"path-expression-matcher": ["path-expression-matcher@1.2.0", "", {}, "sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
@@ -3561,6 +3648,8 @@
"peberminta": ["peberminta@0.9.0", "", {}, "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ=="],
"peek-readable": ["peek-readable@4.1.0", "", {}, "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg=="],
"pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="],
"perfect-debounce": ["perfect-debounce@2.1.0", "", {}, "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g=="],
@@ -3583,12 +3672,16 @@
"pino-std-serializers": ["pino-std-serializers@7.1.0", "", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="],
"pixelmatch": ["pixelmatch@5.3.0", "", { "dependencies": { "pngjs": "^6.0.0" }, "bin": { "pixelmatch": "bin/pixelmatch" } }, "sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q=="],
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
"pkg-dir": ["pkg-dir@4.2.0", "", { "dependencies": { "find-up": "^4.0.0" } }, "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ=="],
"pkg-types": ["pkg-types@2.3.0", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="],
"pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="],
"points-on-curve": ["points-on-curve@0.2.0", "", {}, "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A=="],
"points-on-path": ["points-on-path@0.2.1", "", { "dependencies": { "path-data-parser": "0.1.0", "points-on-curve": "0.2.0" } }, "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g=="],
@@ -3809,6 +3902,8 @@
"readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="],
"readable-web-to-node-stream": ["readable-web-to-node-stream@3.0.4", "", { "dependencies": { "readable-stream": "^4.7.0" } }, "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw=="],
"readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
"real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="],
@@ -3997,6 +4092,8 @@
"simple-git": ["simple-git@3.30.0", "", { "dependencies": { "@kwsites/file-exists": "^1.1.1", "@kwsites/promise-deferred": "^1.1.1", "debug": "^4.4.0" } }, "sha512-q6lxyDsCmEal/MEGhP1aVyQ3oxnagGlBDOVSIB4XUVLl1iZh0Pah6ebC9V4xBap/RfgP2WlI8EKs0WS0rMEJHg=="],
"simple-xml-to-json": ["simple-xml-to-json@1.2.4", "", {}, "sha512-3MY16e0ocMHL7N1ufpdObURGyX+lCo0T/A+y6VCwosLdH1HSda4QZl1Sdt/O+2qWp48WFi26XEp5rF0LoaL0Dg=="],
"sinon": ["sinon@21.0.1", "", { "dependencies": { "@sinonjs/commons": "^3.0.1", "@sinonjs/fake-timers": "^15.1.0", "@sinonjs/samsam": "^8.0.3", "diff": "^8.0.2", "supports-color": "^7.2.0" } }, "sha512-Z0NVCW45W8Mg5oC/27/+fCqIHFnW8kpkFOq0j9XJIev4Ld0mKmERaZv5DMLAb9fGCevjKwaEeIQz5+MBXfZcDw=="],
"sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
@@ -4093,6 +4190,8 @@
"strnum": ["strnum@2.2.0", "", {}, "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg=="],
"strtok3": ["strtok3@6.3.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "peek-readable": "^4.1.0" } }, "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw=="],
"stubborn-fs": ["stubborn-fs@2.0.0", "", { "dependencies": { "stubborn-utils": "^1.0.1" } }, "sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA=="],
"stubborn-utils": ["stubborn-utils@1.0.2", "", {}, "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg=="],
@@ -4149,6 +4248,8 @@
"timeout-signal": ["timeout-signal@2.0.0", "", {}, "sha512-YBGpG4bWsHoPvofT6y/5iqulfXIiIErl5B0LdtHT1mGXDFTAhhRrbUpTvBgYbovr+3cKblya2WAOcpoy90XguA=="],
"tinycolor2": ["tinycolor2@1.6.0", "", {}, "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="],
"tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
@@ -4165,6 +4266,8 @@
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
"token-types": ["token-types@4.2.1", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ=="],
"tokenlens": ["tokenlens@1.3.1", "", { "dependencies": { "@tokenlens/core": "1.3.0", "@tokenlens/fetch": "1.3.0", "@tokenlens/helpers": "1.3.1", "@tokenlens/models": "1.3.0" } }, "sha512-7oxmsS5PNCX3z+b+z07hL5vCzlgHKkCGrEQjQmWl5l+v5cUrtL7S1cuST4XThaL1XyjbTX8J5hfP0cjDJRkaLA=="],
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
@@ -4283,6 +4386,8 @@
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
"utif2": ["utif2@4.1.0", "", { "dependencies": { "pako": "^1.0.11" } }, "sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="],
@@ -4377,6 +4482,8 @@
"xdg-basedir": ["xdg-basedir@5.1.0", "", {}, "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ=="],
"xml-parse-from-string": ["xml-parse-from-string@1.0.1", "", {}, "sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g=="],
"xml2js": ["xml2js@0.6.2", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA=="],
"xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
@@ -4449,7 +4556,9 @@
"@browseros/agent/zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="],
"@browseros/eval/@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="],
"@browseros/eval/@aws-sdk/client-s3": ["@aws-sdk/client-s3@3.1014.0", "", { "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.23", "@aws-sdk/credential-provider-node": "^3.972.24", "@aws-sdk/middleware-bucket-endpoint": "^3.972.8", "@aws-sdk/middleware-expect-continue": "^3.972.8", "@aws-sdk/middleware-flexible-checksums": "^3.974.3", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-location-constraint": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.8", "@aws-sdk/middleware-sdk-s3": "^3.972.23", "@aws-sdk/middleware-ssec": "^3.972.8", "@aws-sdk/middleware-user-agent": "^3.972.24", "@aws-sdk/region-config-resolver": "^3.972.9", "@aws-sdk/signature-v4-multi-region": "^3.996.11", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", "@aws-sdk/util-user-agent-node": "^3.973.10", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.12", "@smithy/eventstream-serde-browser": "^4.2.12", "@smithy/eventstream-serde-config-resolver": "^4.3.12", "@smithy/eventstream-serde-node": "^4.2.12", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/hash-blob-browser": "^4.2.13", "@smithy/hash-node": "^4.2.12", "@smithy/hash-stream-node": "^4.2.12", "@smithy/invalid-dependency": "^4.2.12", "@smithy/md5-js": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-retry": "^4.4.44", "@smithy/middleware-serde": "^4.2.15", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/node-http-handler": "^4.5.0", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.43", "@smithy/util-defaults-mode-node": "^4.2.47", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/util-stream": "^4.5.20", "@smithy/util-utf8": "^4.2.2", "@smithy/util-waiter": "^4.2.13", "tslib": "^2.6.2" } }, "sha512-0XLrOT4Cm3NEhhiME7l/8LbTXS4KdsbR4dSrY207KNKTcHLLTZ9EXt4ZpgnTfLvWQF3pGP2us4Zi1fYLo0N+Ow=="],
"@browseros/eval/@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
"@browseros/server/@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="],
@@ -4567,6 +4676,8 @@
"@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
"@jimp/core/mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="],
"@lexical/react/react-error-boundary": ["react-error-boundary@3.1.4", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "react": ">=16.13.1" } }, "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA=="],
"@lobehub/fluent-emoji/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
@@ -4985,6 +5096,8 @@
"http-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"image-q/@types/node": ["@types/node@16.9.1", "", {}, "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g=="],
"import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
"is-inside-container/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
@@ -5049,12 +5162,16 @@
"pac-proxy-agent/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"parse-bmfont-xml/xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="],
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
"parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
"pino/pino-abstract-transport": ["pino-abstract-transport@2.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw=="],
"pixelmatch/pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="],
"pkg-dir/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
"postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
@@ -5191,7 +5308,101 @@
"@browseros/agent/@types/bun/bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
"@browseros/eval/@types/bun/bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/core": ["@aws-sdk/core@3.973.23", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@aws-sdk/xml-builder": "^3.972.15", "@smithy/core": "^3.23.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/signature-v4": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-aoJncvD1XvloZ9JLnKqTRL9dBy+Szkryoag9VT+V1TqsuUgIxV9cnBVM/hrDi2vE8bDqLiDR8nirdRcCdtJu0w=="],
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.24", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.21", "@aws-sdk/credential-provider-http": "^3.972.23", "@aws-sdk/credential-provider-ini": "^3.972.23", "@aws-sdk/credential-provider-process": "^3.972.21", "@aws-sdk/credential-provider-sso": "^3.972.23", "@aws-sdk/credential-provider-web-identity": "^3.972.23", "@aws-sdk/types": "^3.973.6", "@smithy/credential-provider-imds": "^4.2.12", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-9Jwi7aps3AfUicJyF5udYadPypPpCwUZ6BSKr/QjRbVCpRVS1wc+1Q6AEZ/qz8J4JraeRd247pSzyMQSIHVebw=="],
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/middleware-bucket-endpoint": ["@aws-sdk/middleware-bucket-endpoint@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-arn-parser": "^3.972.3", "@smithy/node-config-provider": "^4.3.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-WR525Rr2QJSETa9a050isktyWi/4yIGcmY3BQ1kpHqb0LqUglQHCS8R27dTJxxWNZvQ0RVGtEZjTCbZJpyF3Aw=="],
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/middleware-expect-continue": ["@aws-sdk/middleware-expect-continue@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-5DTBTiotEES1e2jOHAq//zyzCjeMB78lEHd35u15qnrid4Nxm7diqIf9fQQ3Ov0ChH1V3Vvt13thOnrACmfGVQ=="],
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/middleware-flexible-checksums": ["@aws-sdk/middleware-flexible-checksums@3.974.3", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@aws-crypto/crc32c": "5.2.0", "@aws-crypto/util": "5.2.0", "@aws-sdk/core": "^3.973.23", "@aws-sdk/crc64-nvme": "^3.972.5", "@aws-sdk/types": "^3.973.6", "@smithy/is-array-buffer": "^4.2.2", "@smithy/node-config-provider": "^4.3.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-middleware": "^4.2.12", "@smithy/util-stream": "^4.5.20", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-fB7FNLH1+VPUs0QL3PLrHW+DD4gKu6daFgWtyq3R0Y0Lx8DLZPvyGAxCZNFBxH+M2xt9KvBJX6USwjuqvitmCQ=="],
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/middleware-host-header": ["@aws-sdk/middleware-host-header@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-wAr2REfKsqoKQ+OkNqvOShnBoh+nkPurDKW7uAeVSu6kUECnWlSJiPvnoqxGlfousEY/v9LfS9sNc46hjSYDIQ=="],
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/middleware-location-constraint": ["@aws-sdk/middleware-location-constraint@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-KaUoFuoFPziIa98DSQsTPeke1gvGXlc5ZGMhy+b+nLxZ4A7jmJgLzjEF95l8aOQN2T/qlPP3MrAyELm8ExXucw=="],
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/middleware-logger": ["@aws-sdk/middleware-logger@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-CWl5UCM57WUFaFi5kB7IBY1UmOeLvNZAZ2/OZ5l20ldiJ3TiIz1pC65gYj8X0BCPWkeR1E32mpsCk1L1I4n+lA=="],
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/middleware-recursion-detection": ["@aws-sdk/middleware-recursion-detection@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-BnnvYs2ZEpdlmZ2PNlV2ZyQ8j8AEkMTjN79y/YA475ER1ByFYrkVR85qmhni8oeTaJcDqbx364wDpitDAA/wCA=="],
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/middleware-sdk-s3": ["@aws-sdk/middleware-sdk-s3@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-arn-parser": "^3.972.3", "@smithy/core": "^3.23.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/protocol-http": "^5.3.12", "@smithy/signature-v4": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/util-config-provider": "^4.2.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-stream": "^4.5.20", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-50QgHGPQAb2veqFOmTF1A3GsAklLHZXL47KbY35khIkfbXH5PLvqpEc/gOAEBPj/yFxrlgxz/8mqWcWTNxBkwQ=="],
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/middleware-ssec": ["@aws-sdk/middleware-ssec@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-wqlK0yO/TxEC2UsY9wIlqeeutF6jjLe0f96Pbm40XscTo57nImUk9lBcw0dPgsm0sppFtAkSlDrfpK+pC30Wqw=="],
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.972.24", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@smithy/core": "^3.23.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-retry": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-dLTWy6IfAMhNiSEvMr07g/qZ54be6pLqlxVblbF6AzafmmGAzMMj8qMoY9B4+YgT+gY9IcuxZslNh03L6PyMCQ=="],
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/region-config-resolver": ["@aws-sdk/region-config-resolver@3.972.9", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/config-resolver": "^4.4.13", "@smithy/node-config-provider": "^4.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-eQ+dFU05ZRC/lC2XpYlYSPlXtX3VT8sn5toxN2Fv7EXlMoA2p9V7vUBKqHunfD4TRLpxUq8Y8Ol/nCqiv327Ng=="],
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.11", "", { "dependencies": { "@aws-sdk/middleware-sdk-s3": "^3.972.23", "@aws-sdk/types": "^3.973.6", "@smithy/protocol-http": "^5.3.12", "@smithy/signature-v4": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-SKgZY7x6AloLUXO20FJGnkKJ3a6CXzNDt6PYs2yqoPzgU0xKWcUoGGJGEBTsfM5eihKW42lbwp+sXzACLbSsaA=="],
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/types": ["@aws-sdk/types@3.973.6", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-Atfcy4E++beKtwJHiDln2Nby8W/mam64opFPTiHEqgsthqeydFS1pY+OUlN1ouNOmf8ArPU/6cDS65anOP3KQw=="],
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.996.5", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-endpoints": "^3.3.3", "tslib": "^2.6.2" } }, "sha512-Uh93L5sXFNbyR5sEPMzUU8tJ++Ku97EY4udmC01nB8Zu+xfBPwpIwJ6F7snqQeq8h2pf+8SGN5/NoytfKgYPIw=="],
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/util-user-agent-browser": ["@aws-sdk/util-user-agent-browser@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-B3KGXJviV2u6Cdw2SDY2aDhoJkVfY/Q/Trwk2CMSkikE1Oi6gRzxhvhIfiRpHfmIsAhV4EA54TVEX8K6CbHbkA=="],
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.973.10", "", { "dependencies": { "@aws-sdk/middleware-user-agent": "^3.972.24", "@aws-sdk/types": "^3.973.6", "@smithy/node-config-provider": "^4.3.12", "@smithy/types": "^4.13.1", "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" }, "peerDependencies": { "aws-crt": ">=1.0.0" }, "optionalPeers": ["aws-crt"] }, "sha512-E99zeTscCc+pTMfsvnfi6foPpKmdD1cZfOC7/P8UUrjsoQdg9VEWPRD+xdFduKnfPXwcvby58AlO9jwwF6U96g=="],
"@browseros/eval/@aws-sdk/client-s3/@smithy/config-resolver": ["@smithy/config-resolver@4.4.13", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/types": "^4.13.1", "@smithy/util-config-provider": "^4.2.2", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-iIzMC5NmOUP6WL6o8iPBjFhUhBZ9pPjpUpQYWMUFQqKyXXzOftbfK8zcQCz/jFV1Psmf05BK5ypx4K2r4Tnwdg=="],
"@browseros/eval/@aws-sdk/client-s3/@smithy/core": ["@smithy/core@3.23.12", "", { "dependencies": { "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-stream": "^4.5.20", "@smithy/util-utf8": "^4.2.2", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" } }, "sha512-o9VycsYNtgC+Dy3I0yrwCqv9CWicDnke0L7EVOrZtJpjb2t0EjaEofmMrYc0T1Kn3yk32zm6cspxF9u9Bj7e5w=="],
"@browseros/eval/@aws-sdk/client-s3/@smithy/eventstream-serde-browser": ["@smithy/eventstream-serde-browser@4.2.12", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-XUSuMxlTxV5pp4VpqZf6Sa3vT/Q75FVkLSpSSE3KkWBvAQWeuWt1msTv8fJfgA4/jcJhrbrbMzN1AC/hvPmm5A=="],
"@browseros/eval/@aws-sdk/client-s3/@smithy/eventstream-serde-config-resolver": ["@smithy/eventstream-serde-config-resolver@4.3.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-7epsAZ3QvfHkngz6RXQYseyZYHlmWXSTPOfPmXkiS+zA6TBNo1awUaMFL9vxyXlGdoELmCZyZe1nQE+imbmV+Q=="],
"@browseros/eval/@aws-sdk/client-s3/@smithy/eventstream-serde-node": ["@smithy/eventstream-serde-node@4.2.12", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-D1pFuExo31854eAvg89KMn9Oab/wEeJR6Buy32B49A9Ogdtx5fwZPqBHUlDzaCDpycTFk2+fSQgX689Qsk7UGA=="],
"@browseros/eval/@aws-sdk/client-s3/@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.3.15", "", { "dependencies": { "@smithy/protocol-http": "^5.3.12", "@smithy/querystring-builder": "^4.2.12", "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" } }, "sha512-T4jFU5N/yiIfrtrsb9uOQn7RdELdM/7HbyLNr6uO/mpkj1ctiVs7CihVr51w4LyQlXWDpXFn4BElf1WmQvZu/A=="],
"@browseros/eval/@aws-sdk/client-s3/@smithy/hash-blob-browser": ["@smithy/hash-blob-browser@4.2.13", "", { "dependencies": { "@smithy/chunked-blob-reader": "^5.2.2", "@smithy/chunked-blob-reader-native": "^4.2.3", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-YrF4zWKh+ghLuquldj6e/RzE3xZYL8wIPfkt0MqCRphVICjyyjH8OwKD7LLlKpVEbk4FLizFfC1+gwK6XQdR3g=="],
"@browseros/eval/@aws-sdk/client-s3/@smithy/hash-node": ["@smithy/hash-node@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-QhBYbGrbxTkZ43QoTPrK72DoYviDeg6YKDrHTMJbbC+A0sml3kSjzFtXP7BtbyJnXojLfTQldGdUR0RGD8dA3w=="],
"@browseros/eval/@aws-sdk/client-s3/@smithy/hash-stream-node": ["@smithy/hash-stream-node@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-O3YbmGExeafuM/kP7Y8r6+1y0hIh3/zn6GROx0uNlB54K9oihAL75Qtc+jFfLNliTi6pxOAYZrRKD9A7iA6UFw=="],
"@browseros/eval/@aws-sdk/client-s3/@smithy/invalid-dependency": ["@smithy/invalid-dependency@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-/4F1zb7Z8LOu1PalTdESFHR0RbPwHd3FcaG1sI3UEIriQTWakysgJr65lc1jj6QY5ye7aFsisajotH6UhWfm/g=="],
"@browseros/eval/@aws-sdk/client-s3/@smithy/md5-js": ["@smithy/md5-js@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-W/oIpHCpWU2+iAkfZYyGWE+qkpuf3vEXHLxQQDx9FPNZTTdnul0dZ2d/gUFrtQ5je1G2kp4cjG0/24YueG2LbQ=="],
"@browseros/eval/@aws-sdk/client-s3/@smithy/middleware-content-length": ["@smithy/middleware-content-length@4.2.12", "", { "dependencies": { "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-YE58Yz+cvFInWI/wOTrB+DbvUVz/pLn5mC5MvOV4fdRUc6qGwygyngcucRQjAhiCEbmfLOXX0gntSIcgMvAjmA=="],
"@browseros/eval/@aws-sdk/client-s3/@smithy/middleware-endpoint": ["@smithy/middleware-endpoint@4.4.27", "", { "dependencies": { "@smithy/core": "^3.23.12", "@smithy/middleware-serde": "^4.2.15", "@smithy/node-config-provider": "^4.3.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-middleware": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-T3TFfUgXQlpcg+UdzcAISdZpj4Z+XECZ/cefgA6wLBd6V4lRi0svN2hBouN/be9dXQ31X4sLWz3fAQDf+nt6BA=="],
"@browseros/eval/@aws-sdk/client-s3/@smithy/middleware-retry": ["@smithy/middleware-retry@4.4.44", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/protocol-http": "^5.3.12", "@smithy/service-error-classification": "^4.2.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" } }, "sha512-Y1Rav7m5CFRPQyM4CI0koD/bXjyjJu3EQxZZhtLGD88WIrBrQ7kqXM96ncd6rYnojwOo/u9MXu57JrEvu/nLrA=="],
"@browseros/eval/@aws-sdk/client-s3/@smithy/middleware-serde": ["@smithy/middleware-serde@4.2.15", "", { "dependencies": { "@smithy/core": "^3.23.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-ExYhcltZSli0pgAKOpQQe1DLFBLryeZ22605y/YS+mQpdNWekum9Ujb/jMKfJKgjtz1AZldtwA/wCYuKJgjjlg=="],
"@browseros/eval/@aws-sdk/client-s3/@smithy/middleware-stack": ["@smithy/middleware-stack@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-kruC5gRHwsCOuyCd4ouQxYjgRAym2uDlCvQ5acuMtRrcdfg7mFBg6blaxcJ09STpt3ziEkis6bhg1uwrWU7txw=="],
"@browseros/eval/@aws-sdk/client-s3/@smithy/node-config-provider": ["@smithy/node-config-provider@4.3.12", "", { "dependencies": { "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-tr2oKX2xMcO+rBOjobSwVAkV05SIfUKz8iI53rzxEmgW3GOOPOv0UioSDk+J8OpRQnpnhsO3Af6IEBabQBVmiw=="],
"@browseros/eval/@aws-sdk/client-s3/@smithy/node-http-handler": ["@smithy/node-http-handler@4.5.0", "", { "dependencies": { "@smithy/abort-controller": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/querystring-builder": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-Rnq9vQWiR1+/I6NZZMNzJHV6pZYyEHt2ZnuV3MG8z2NNenC4i/8Kzttz7CjZiHSmsN5frhXhg17z3Zqjjhmz1A=="],
"@browseros/eval/@aws-sdk/client-s3/@smithy/protocol-http": ["@smithy/protocol-http@5.3.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-fit0GZK9I1xoRlR4jXmbLhoN0OdEpa96ul8M65XdmXnxXkuMxM0Y8HDT0Fh0Xb4I85MBvBClOzgSrV1X2s1Hxw=="],
"@browseros/eval/@aws-sdk/client-s3/@smithy/smithy-client": ["@smithy/smithy-client@4.12.7", "", { "dependencies": { "@smithy/core": "^3.23.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-stack": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-stream": "^4.5.20", "tslib": "^2.6.2" } }, "sha512-q3gqnwml60G44FECaEEsdQMplYhDMZYCtYhMCzadCnRnnHIobZJjegmdoUo6ieLQlPUzvrMdIJUpx6DoPmzANQ=="],
"@browseros/eval/@aws-sdk/client-s3/@smithy/types": ["@smithy/types@4.13.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g=="],
"@browseros/eval/@aws-sdk/client-s3/@smithy/url-parser": ["@smithy/url-parser@4.2.12", "", { "dependencies": { "@smithy/querystring-parser": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-wOPKPEpso+doCZGIlr+e1lVI6+9VAKfL4kZWFgzVgGWY2hZxshNKod4l2LXS3PRC9otH/JRSjtEHqQ/7eLciRA=="],
"@browseros/eval/@aws-sdk/client-s3/@smithy/util-defaults-mode-browser": ["@smithy/util-defaults-mode-browser@4.3.43", "", { "dependencies": { "@smithy/property-provider": "^4.2.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-Qd/0wCKMaXxev/z00TvNzGCH2jlKKKxXP1aDxB6oKwSQthe3Og2dMhSayGCnsma1bK/kQX1+X7SMP99t6FgiiQ=="],
"@browseros/eval/@aws-sdk/client-s3/@smithy/util-defaults-mode-node": ["@smithy/util-defaults-mode-node@4.2.47", "", { "dependencies": { "@smithy/config-resolver": "^4.4.13", "@smithy/credential-provider-imds": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-qSRbYp1EQ7th+sPFuVcVO05AE0QH635hycdEXlpzIahqHHf2Fyd/Zl+8v0XYMJ3cgDVPa0lkMefU7oNUjAP+DQ=="],
"@browseros/eval/@aws-sdk/client-s3/@smithy/util-endpoints": ["@smithy/util-endpoints@3.3.3", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-VACQVe50j0HZPjpwWcjyT51KUQ4AnsvEaQ2lKHOSL4mNLD0G9BjEniQ+yCt1qqfKfiAHRAts26ud7hBjamrwig=="],
"@browseros/eval/@aws-sdk/client-s3/@smithy/util-middleware": ["@smithy/util-middleware@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-Er805uFUOvgc0l8nv0e0su0VFISoxhJ/AwOn3gL2NWNY2LUEldP5WtVcRYSQBcjg0y9NfG8JYrCJaYDpupBHJQ=="],
"@browseros/eval/@aws-sdk/client-s3/@smithy/util-retry": ["@smithy/util-retry@4.2.12", "", { "dependencies": { "@smithy/service-error-classification": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-1zopLDUEOwumjcHdJ1mwBHddubYF8GMQvstVCLC54Y46rqoHwlIU+8ZzUeaBcD+WCJHyDGSeZ2ml9YSe9aqcoQ=="],
"@browseros/eval/@aws-sdk/client-s3/@smithy/util-stream": ["@smithy/util-stream@4.5.20", "", { "dependencies": { "@smithy/fetch-http-handler": "^5.3.15", "@smithy/node-http-handler": "^4.5.0", "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-4yXLm5n/B5SRBR2p8cZ90Sbv4zL4NKsgxdzCzp/83cXw2KxLEumt5p+GAVyRNZgQOSrzXn9ARpO0lUe8XSlSDw=="],
"@browseros/eval/@aws-sdk/client-s3/@smithy/util-waiter": ["@smithy/util-waiter@4.2.13", "", { "dependencies": { "@smithy/abort-controller": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-2zdZ9DTHngRtcYxJK1GUDxruNr53kv5W2Lupe0LMU+Imr6ohQg8M2T14MNkj1Y0wS3FFwpgpGQyvuaMF7CiTmQ=="],
"@browseros/eval/@types/bun/bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
"@browseros/server/@types/bun/bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="],
@@ -5505,6 +5716,70 @@
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.15", "", { "dependencies": { "@smithy/types": "^4.13.1", "fast-xml-parser": "5.5.8", "tslib": "^2.6.2" } }, "sha512-PxMRlCFNiQnke9YR29vjFQwz4jq+6Q04rOVFeTDR2K7Qpv9h9FOWOxG+zJjageimYbWqE3bTuLjmryWHAWbvaA=="],
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/core/@smithy/property-provider": ["@smithy/property-provider@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A=="],
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/core/@smithy/signature-v4": ["@smithy/signature-v4@5.3.12", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-uri-escape": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-B/FBwO3MVOL00DaRSXfXfa/TRXRheagt/q5A2NM13u7q+sHS59EOVGQNfG7DkmVtdQm5m3vOosoKAXSqn/OEgw=="],
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.21", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-BkAfKq8Bd4shCtec1usNz//urPJF/SZy14qJyxkSaRJQ/Vv1gVh0VZSTmS7aE6aLMELkFV5wHHrS9ZcdG8Kxsg=="],
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/types": "^3.973.6", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/node-http-handler": "^4.5.0", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/util-stream": "^4.5.20", "tslib": "^2.6.2" } }, "sha512-4XZ3+Gu5DY8/n8zQFHBgcKTF7hWQl42G6CY9xfXVo2d25FM/lYkpmuzhYopYoPL1ITWkJ2OSBQfYEu5JRfHOhA=="],
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/credential-provider-env": "^3.972.21", "@aws-sdk/credential-provider-http": "^3.972.23", "@aws-sdk/credential-provider-login": "^3.972.23", "@aws-sdk/credential-provider-process": "^3.972.21", "@aws-sdk/credential-provider-sso": "^3.972.23", "@aws-sdk/credential-provider-web-identity": "^3.972.23", "@aws-sdk/nested-clients": "^3.996.13", "@aws-sdk/types": "^3.973.6", "@smithy/credential-provider-imds": "^4.2.12", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-PZLSmU0JFpNCDFReidBezsgL5ji9jOBry8CnZdw4Jj6d0K2z3Ftnp44NXgADqYx5BLMu/ZHujfeJReaDoV+IwQ=="],
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.21", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-nRxbeOJ1E1gVA0lNQezuMVndx+ZcuyaW/RB05pUsznN5BxykSlH6KkZ/7Ca/ubJf3i5N3p0gwNO5zgPSCzj+ww=="],
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/nested-clients": "^3.996.13", "@aws-sdk/token-providers": "3.1014.0", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-APUccADuYPLL0f2htpM8Z4czabSmHOdo4r41W6lKEZdy++cNJ42Radqy6x4TopENzr3hR6WYMyhiuiqtbf/nAA=="],
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/nested-clients": "^3.996.13", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-H5JNqtIwOu/feInmMMWcK0dL5r897ReEn7n2m16Dd0DPD9gA2Hg8Cq4UDzZ/9OzaLh/uqBM6seixz0U6Fi2Eag=="],
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.2.12", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-cr2lR792vNZcYMriSIj+Um3x9KWrjcu98kn234xA6reOAFMmbRpQMOv8KPgEmLLtx3eldU6c5wALKFqNOhugmg=="],
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@smithy/property-provider": ["@smithy/property-provider@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A=="],
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@smithy/shared-ini-file-loader": ["@smithy/shared-ini-file-loader@4.4.7", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-HrOKWsUb+otTeo1HxVWeEb99t5ER1XrBi/xka2Wv6NVmTbuCUC1dvlrksdvxFtODLBjsC+PHK+fuy2x/7Ynyiw=="],
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/middleware-bucket-endpoint/@aws-sdk/util-arn-parser": ["@aws-sdk/util-arn-parser@3.972.3", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA=="],
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/middleware-flexible-checksums/@aws-sdk/crc64-nvme": ["@aws-sdk/crc64-nvme@3.972.5", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-2VbTstbjKdT+yKi8m7b3a9CiVac+pL/IY2PHJwsaGkkHmuuqkJZIErPck1h6P3T9ghQMLSdMPyW6Qp7Di5swFg=="],
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/middleware-sdk-s3/@aws-sdk/util-arn-parser": ["@aws-sdk/util-arn-parser@3.972.3", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA=="],
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/middleware-sdk-s3/@smithy/signature-v4": ["@smithy/signature-v4@5.3.12", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-uri-escape": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-B/FBwO3MVOL00DaRSXfXfa/TRXRheagt/q5A2NM13u7q+sHS59EOVGQNfG7DkmVtdQm5m3vOosoKAXSqn/OEgw=="],
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/signature-v4-multi-region/@smithy/signature-v4": ["@smithy/signature-v4@5.3.12", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-uri-escape": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-B/FBwO3MVOL00DaRSXfXfa/TRXRheagt/q5A2NM13u7q+sHS59EOVGQNfG7DkmVtdQm5m3vOosoKAXSqn/OEgw=="],
"@browseros/eval/@aws-sdk/client-s3/@smithy/eventstream-serde-browser/@smithy/eventstream-serde-universal": ["@smithy/eventstream-serde-universal@4.2.12", "", { "dependencies": { "@smithy/eventstream-codec": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-+yNuTiyBACxOJUTvbsNsSOfH9G9oKbaJE1lNL3YHpGcuucl6rPZMi3nrpehpVOVR2E07YqFFmtwpImtpzlouHQ=="],
"@browseros/eval/@aws-sdk/client-s3/@smithy/eventstream-serde-node/@smithy/eventstream-serde-universal": ["@smithy/eventstream-serde-universal@4.2.12", "", { "dependencies": { "@smithy/eventstream-codec": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-+yNuTiyBACxOJUTvbsNsSOfH9G9oKbaJE1lNL3YHpGcuucl6rPZMi3nrpehpVOVR2E07YqFFmtwpImtpzlouHQ=="],
"@browseros/eval/@aws-sdk/client-s3/@smithy/fetch-http-handler/@smithy/querystring-builder": ["@smithy/querystring-builder@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "@smithy/util-uri-escape": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-6wTZjGABQufekycfDGMEB84BgtdOE/rCVTov+EDXQ8NHKTUNIp/j27IliwP7tjIU9LR+sSzyGBOXjeEtVgzCHg=="],
"@browseros/eval/@aws-sdk/client-s3/@smithy/middleware-endpoint/@smithy/shared-ini-file-loader": ["@smithy/shared-ini-file-loader@4.4.7", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-HrOKWsUb+otTeo1HxVWeEb99t5ER1XrBi/xka2Wv6NVmTbuCUC1dvlrksdvxFtODLBjsC+PHK+fuy2x/7Ynyiw=="],
"@browseros/eval/@aws-sdk/client-s3/@smithy/middleware-retry/@smithy/service-error-classification": ["@smithy/service-error-classification@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1" } }, "sha512-LlP29oSQN0Tw0b6D0Xo6BIikBswuIiGYbRACy5ujw/JgWSzTdYj46U83ssf6Ux0GyNJVivs2uReU8pt7Eu9okQ=="],
"@browseros/eval/@aws-sdk/client-s3/@smithy/node-config-provider/@smithy/property-provider": ["@smithy/property-provider@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A=="],
"@browseros/eval/@aws-sdk/client-s3/@smithy/node-config-provider/@smithy/shared-ini-file-loader": ["@smithy/shared-ini-file-loader@4.4.7", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-HrOKWsUb+otTeo1HxVWeEb99t5ER1XrBi/xka2Wv6NVmTbuCUC1dvlrksdvxFtODLBjsC+PHK+fuy2x/7Ynyiw=="],
"@browseros/eval/@aws-sdk/client-s3/@smithy/node-http-handler/@smithy/abort-controller": ["@smithy/abort-controller@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-xolrFw6b+2iYGl6EcOL7IJY71vvyZ0DJ3mcKtpykqPe2uscwtzDZJa1uVQXyP7w9Dd+kGwYnPbMsJrGISKiY/Q=="],
"@browseros/eval/@aws-sdk/client-s3/@smithy/node-http-handler/@smithy/querystring-builder": ["@smithy/querystring-builder@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "@smithy/util-uri-escape": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-6wTZjGABQufekycfDGMEB84BgtdOE/rCVTov+EDXQ8NHKTUNIp/j27IliwP7tjIU9LR+sSzyGBOXjeEtVgzCHg=="],
"@browseros/eval/@aws-sdk/client-s3/@smithy/url-parser/@smithy/querystring-parser": ["@smithy/querystring-parser@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-P2OdvrgiAKpkPNKlKUtWbNZKB1XjPxM086NeVhK+W+wI46pIKdWBe5QyXvhUm3MEcyS/rkLvY8rZzyUdmyDZBw=="],
"@browseros/eval/@aws-sdk/client-s3/@smithy/util-defaults-mode-browser/@smithy/property-provider": ["@smithy/property-provider@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A=="],
"@browseros/eval/@aws-sdk/client-s3/@smithy/util-defaults-mode-node/@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.2.12", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-cr2lR792vNZcYMriSIj+Um3x9KWrjcu98kn234xA6reOAFMmbRpQMOv8KPgEmLLtx3eldU6c5wALKFqNOhugmg=="],
"@browseros/eval/@aws-sdk/client-s3/@smithy/util-defaults-mode-node/@smithy/property-provider": ["@smithy/property-provider@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A=="],
"@browseros/eval/@aws-sdk/client-s3/@smithy/util-retry/@smithy/service-error-classification": ["@smithy/service-error-classification@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1" } }, "sha512-LlP29oSQN0Tw0b6D0Xo6BIikBswuIiGYbRACy5ujw/JgWSzTdYj46U83ssf6Ux0GyNJVivs2uReU8pt7Eu9okQ=="],
"@browseros/eval/@aws-sdk/client-s3/@smithy/util-waiter/@smithy/abort-controller": ["@smithy/abort-controller@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-xolrFw6b+2iYGl6EcOL7IJY71vvyZ0DJ3mcKtpykqPe2uscwtzDZJa1uVQXyP7w9Dd+kGwYnPbMsJrGISKiY/Q=="],
"@google/gemini-cli-core/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/resources": ["@opentelemetry/resources@2.0.1", "", { "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw=="],
"@google/gemini-cli-core/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.0.1", "", { "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/resources": "2.0.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-wf8OaJoSnujMAHWR3g+/hGvNcsC16rf9s1So4JlMiFaFHiE4HpIA3oUh+uWZQ7CNuK8gVW/pQSkgoa5HkkOl0g=="],
@@ -5549,6 +5824,22 @@
"wxt/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/core/@aws-sdk/xml-builder/fast-xml-parser": ["fast-xml-parser@5.5.8", "", { "dependencies": { "fast-xml-builder": "^1.1.4", "path-expression-matcher": "^1.2.0", "strnum": "^2.2.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ=="],
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/nested-clients": "^3.996.13", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-OmE/pSkbMM3dCj1HdOnZ5kXnKK+R/Yz+kbBugraBecp0pGAs21eEURfQRz+1N2gzIHLVyGIP1MEjk/uSrFsngg=="],
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.996.13", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.23", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.8", "@aws-sdk/middleware-user-agent": "^3.972.24", "@aws-sdk/region-config-resolver": "^3.972.9", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", "@aws-sdk/util-user-agent-node": "^3.973.10", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.12", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/hash-node": "^4.2.12", "@smithy/invalid-dependency": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-retry": "^4.4.44", "@smithy/middleware-serde": "^4.2.15", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/node-http-handler": "^4.5.0", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.43", "@smithy/util-defaults-mode-node": "^4.2.47", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-ptZ1HF4yYHNJX8cgFF+8NdYO69XJKZn7ft0/ynV3c0hCbN+89fAbrLS+fqniU2tW8o9Kfqhj8FUh+IPXb2Qsuw=="],
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.996.13", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.23", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.8", "@aws-sdk/middleware-user-agent": "^3.972.24", "@aws-sdk/region-config-resolver": "^3.972.9", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", "@aws-sdk/util-user-agent-node": "^3.973.10", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.12", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/hash-node": "^4.2.12", "@smithy/invalid-dependency": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-retry": "^4.4.44", "@smithy/middleware-serde": "^4.2.15", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/node-http-handler": "^4.5.0", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.43", "@smithy/util-defaults-mode-node": "^4.2.47", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-ptZ1HF4yYHNJX8cgFF+8NdYO69XJKZn7ft0/ynV3c0hCbN+89fAbrLS+fqniU2tW8o9Kfqhj8FUh+IPXb2Qsuw=="],
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1014.0", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/nested-clients": "^3.996.13", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-gHTHNUoaOGNrSWkl32A7wFsU78jlNTlqMccLu0byUk5CysYYXaxNMIonIVr4YcykC7vgtDS5ABuz83giy6fzJA=="],
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.996.13", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.23", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.8", "@aws-sdk/middleware-user-agent": "^3.972.24", "@aws-sdk/region-config-resolver": "^3.972.9", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", "@aws-sdk/util-user-agent-node": "^3.973.10", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.12", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/hash-node": "^4.2.12", "@smithy/invalid-dependency": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-retry": "^4.4.44", "@smithy/middleware-serde": "^4.2.15", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/node-http-handler": "^4.5.0", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.43", "@smithy/util-defaults-mode-node": "^4.2.47", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-ptZ1HF4yYHNJX8cgFF+8NdYO69XJKZn7ft0/ynV3c0hCbN+89fAbrLS+fqniU2tW8o9Kfqhj8FUh+IPXb2Qsuw=="],
"@browseros/eval/@aws-sdk/client-s3/@smithy/eventstream-serde-browser/@smithy/eventstream-serde-universal/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.12", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FE3bZdEl62ojmy8x4FHqxq2+BuOHlcxiH5vaZ6aqHJr3AIZzwF5jfx8dEiU/X0a8RboyNDjmXjlbr8AdEyLgiA=="],
"@browseros/eval/@aws-sdk/client-s3/@smithy/eventstream-serde-node/@smithy/eventstream-serde-universal/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.12", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FE3bZdEl62ojmy8x4FHqxq2+BuOHlcxiH5vaZ6aqHJr3AIZzwF5jfx8dEiU/X0a8RboyNDjmXjlbr8AdEyLgiA=="],
"@google/genai/google-auth-library/gaxios/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"@google/genai/google-auth-library/gaxios/rimraf/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
@@ -5563,6 +5854,8 @@
"publish-browser-extension/listr2/cli-truncate/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/core/@aws-sdk/xml-builder/fast-xml-parser/fast-xml-builder": ["fast-xml-builder@1.1.4", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg=="],
"@google/genai/google-auth-library/gaxios/rimraf/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
"@google/genai/google-auth-library/gaxios/rimraf/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],

View File

@@ -0,0 +1,58 @@
# Credits Tracking UI Design
## Overview
Surface credit balance to users across two locations: a compact badge in the side panel chat header, and a dedicated Usage & Billing settings page. Credits refresh after each completed message turn or on error.
## 1. Side Panel — Credit Badge
**Location:** Chat header, next to provider selector. Only visible when provider is `browseros`.
**Display:**
- Coin/credit icon + remaining count (e.g., "87")
- Color-coded by threshold:
- Green: >30 credits
- Yellow/orange: 130 credits
- Red: 0 credits
- Clicking the badge navigates to the Usage & Billing settings page
**Update triggers:**
- Message turn completes successfully (agent finishes all tool calls and responds)
- CREDITS_EXHAUSTED error mid-turn (badge syncs to 0, error shown in chat)
## 2. Settings — Usage & Billing Page
**Sidebar entry:** "Usage & Billing" in the "Other" section (icon: CreditCard or Coins).
**Route:** `/settings/usage`
**Content:**
- Credits card: large display of remaining credits (e.g., "87 / 100") with color-coded progress bar
- Reset info: "Resets daily at midnight UTC" with last reset date
- Credit cost: "1 credit per request"
- Placeholder section: "Need more credits?" with disabled "Add Credits" button (future payment/recharge)
## 3. Data Flow
**Hook:** `useCredits()` — React Query hook fetching `GET /credits` from the agent server.
**Refresh strategy:**
- Refetch after each completed message turn (`onFinish` callback in chat session)
- Refetch on CREDITS_EXHAUSTED error
- Refetch on window focus (React Query default)
- No aggressive polling
**State sharing:** Credits query is global (React Query cache). Both side panel badge and settings page read from the same cache key.
## 4. Error Handling (0 credits)
When credits are exhausted mid-conversation:
- Chat stream shows error via existing `ChatError.tsx` pattern: "Daily credits exhausted. Resets at midnight UTC." with link to Usage & Billing page
- Header badge turns red (0 credits)
- Chat input stays enabled — user can switch to a different provider
## 5. Future Hooks
- "Add Credits" button on Usage & Billing page (currently disabled placeholder)
- Payment integration will live entirely within the Usage & Billing page
- Credit badge could show a "+" icon when balance is low, linking to recharge

View File

@@ -0,0 +1,384 @@
# Credits Tracking UI Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Show credit balance in the side panel chat header and a dedicated Usage & Billing settings page, with live updates after each message turn.
**Architecture:** A `useCredits()` React Query hook fetches `GET /credits` from the agent server. The side panel header shows a color-coded badge (green >30, yellow 1-30, red 0). A new settings page at `/settings/usage` shows full details. Credits refresh after each completed chat turn or on CREDITS_EXHAUSTED error.
**Tech Stack:** React, React Query, Shadcn UI, Lucide icons, Hono (server already done)
---
### Task 1: Create useCredits() hook
**Files:**
- Create: `apps/agent/lib/credits/useCredits.ts`
**Step 1: Write the hook**
```typescript
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { getAgentServerUrl } from '@/lib/browseros/helpers'
interface CreditsInfo {
credits: number
lastResetAt?: string
}
const CREDITS_QUERY_KEY = ['credits']
async function fetchCredits(): Promise<CreditsInfo> {
const baseUrl = await getAgentServerUrl()
const response = await fetch(`${baseUrl}/credits`)
if (!response.ok) throw new Error(`Failed to fetch credits: ${response.status}`)
return response.json()
}
export function useCredits() {
return useQuery<CreditsInfo>({
queryKey: CREDITS_QUERY_KEY,
queryFn: fetchCredits,
refetchOnWindowFocus: true,
staleTime: 30_000,
retry: 1,
})
}
export function useInvalidateCredits() {
const queryClient = useQueryClient()
return () => queryClient.invalidateQueries({ queryKey: CREDITS_QUERY_KEY })
}
```
**Step 2: Commit**
```bash
git add apps/agent/lib/credits/useCredits.ts
git commit -m "feat: add useCredits React Query hook"
```
---
### Task 2: Create CreditBadge component
**Files:**
- Create: `apps/agent/components/credits/CreditBadge.tsx`
**Step 1: Write the component**
The badge shows a coin icon + credit count, color-coded by threshold. Only renders when credits data is available.
```tsx
import { Coins } from 'lucide-react'
import type { FC } from 'react'
import { cn } from '@/lib/utils'
interface CreditBadgeProps {
credits: number
onClick?: () => void
}
function getCreditColor(credits: number): string {
if (credits <= 0) return 'text-red-500'
if (credits <= 30) return 'text-yellow-500'
return 'text-green-500'
}
export const CreditBadge: FC<CreditBadgeProps> = ({ credits, onClick }) => {
return (
<button
type="button"
onClick={onClick}
className={cn(
'inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-xs font-medium transition-colors hover:bg-muted/50',
getCreditColor(credits),
)}
title={`${credits} credits remaining`}
>
<Coins className="h-3.5 w-3.5" />
<span>{credits}</span>
</button>
)
}
```
**Step 2: Commit**
```bash
git add apps/agent/components/credits/CreditBadge.tsx
git commit -m "feat: add CreditBadge component with color thresholds"
```
---
### Task 3: Add CreditBadge to ChatHeader
**Files:**
- Modify: `apps/agent/entrypoints/sidepanel/index/ChatHeader.tsx`
**Step 1: Update ChatHeader**
Add the credit badge after the provider selector, only when provider is `browseros`. The badge links to the Usage & Billing settings page.
Changes to `ChatHeader.tsx`:
1. Import `CreditBadge` and `useCredits`
2. After the `ChatProviderSelector` closing tag (line 61), add the badge conditionally
```tsx
// Add imports at top:
import { CreditBadge } from '@/components/credits/CreditBadge'
import { useCredits } from '@/lib/credits/useCredits'
// After line 61 (closing </ChatProviderSelector>), before closing </div>:
{selectedProvider.type === 'browseros' && <CreditsBadgeWrapper />}
```
Create a small wrapper component inside the file to keep the hook call conditional:
```tsx
const CreditsBadgeWrapper: FC = () => {
const { data } = useCredits()
if (data === undefined) return null
return (
<CreditBadge
credits={data.credits}
onClick={() => window.open('/app.html#/settings/usage', '_blank')}
/>
)
}
```
**Step 2: Commit**
```bash
git add apps/agent/entrypoints/sidepanel/index/ChatHeader.tsx
git commit -m "feat: show credit badge in chat header for BrowserOS provider"
```
---
### Task 4: Add credit refresh on message completion
**Files:**
- Modify: `apps/agent/entrypoints/sidepanel/index/useChatSession.ts`
**Step 1: Update useChatSession**
Import `useInvalidateCredits` and call it when a message turn completes (status transitions from streaming/submitted to ready) and when an error occurs.
```typescript
// Add import:
import { useInvalidateCredits } from '@/lib/credits/useCredits'
// Inside useChatSession(), near other hook calls:
const invalidateCredits = useInvalidateCredits()
```
Find the existing completion detection logic (where `saveLocalConversation` or `saveRemoteConversation` is called after status becomes 'ready'). Add `invalidateCredits()` call there.
Also, in the error handling path (where `chatError` is set), add `invalidateCredits()` to sync badge on CREDITS_EXHAUSTED.
**Step 2: Commit**
```bash
git add apps/agent/entrypoints/sidepanel/index/useChatSession.ts
git commit -m "feat: refresh credits after chat message completion and on error"
```
---
### Task 5: Update ChatError for CREDITS_EXHAUSTED
**Files:**
- Modify: `apps/agent/entrypoints/sidepanel/index/ChatError.tsx`
**Step 1: Add CREDITS_EXHAUSTED detection to parseErrorMessage**
In `parseErrorMessage()` (line 29), add a new detection block after the existing rate limit check (line 48):
```typescript
// After the 'BrowserOS LLM daily limit reached' block, add:
if (message.includes('CREDITS_EXHAUSTED') || message.includes('Daily credits exhausted')) {
return {
text: 'Daily credits exhausted. Credits reset at midnight UTC.',
url: '/app.html#/settings/usage',
isRateLimit: true,
}
}
```
**Step 2: Commit**
```bash
git add apps/agent/entrypoints/sidepanel/index/ChatError.tsx
git commit -m "feat: handle CREDITS_EXHAUSTED error in chat"
```
---
### Task 6: Create Usage & Billing settings page
**Files:**
- Create: `apps/agent/entrypoints/app/usage/UsagePage.tsx`
**Step 1: Write the page component**
Follow the same pattern as `AISettingsPage.tsx` — a standalone page component rendered inside the settings sidebar layout.
```tsx
import { Coins } from 'lucide-react'
import type { FC } from 'react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { useCredits } from '@/lib/credits/useCredits'
import { cn } from '@/lib/utils'
function getCreditColor(credits: number): string {
if (credits <= 0) return 'text-red-500'
if (credits <= 30) return 'text-yellow-500'
return 'text-green-500'
}
function getProgressColor(credits: number): string {
if (credits <= 0) return 'bg-red-500'
if (credits <= 30) return 'bg-yellow-500'
return 'bg-green-500'
}
export const UsagePage: FC = () => {
const { data, isLoading } = useCredits()
if (isLoading) {
return (
<div className="flex items-center justify-center p-12 text-muted-foreground text-sm">
Loading usage data...
</div>
)
}
const credits = data?.credits ?? 0
const total = 100
const percentage = Math.min((credits / total) * 100, 100)
return (
<div className="space-y-6 p-6">
<div>
<h2 className="font-semibold text-lg">Usage & Billing</h2>
<p className="text-muted-foreground text-sm">
Monitor your BrowserOS AI credit usage.
</p>
</div>
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<Coins className="h-5 w-5" />
Credits
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-baseline gap-2">
<span className={cn('font-bold text-3xl', getCreditColor(credits))}>
{credits}
</span>
<span className="text-muted-foreground text-sm">/ {total} daily</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">
<div
className={cn('h-full rounded-full transition-all', getProgressColor(credits))}
style={{ width: `${percentage}%` }}
/>
</div>
<div className="space-y-1 text-muted-foreground text-sm">
<p>1 credit per request</p>
<p>Resets daily at midnight UTC</p>
{data?.lastResetAt && (
<p>Last reset: {new Date(data.lastResetAt).toLocaleDateString()}</p>
)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Need more credits?</CardTitle>
</CardHeader>
<CardContent>
<p className="mb-3 text-muted-foreground text-sm">
Additional credit packages will be available soon.
</p>
<Button variant="outline" disabled>
Add Credits (Coming Soon)
</Button>
</CardContent>
</Card>
</div>
)
}
```
**Step 2: Commit**
```bash
git add apps/agent/entrypoints/app/usage/UsagePage.tsx
git commit -m "feat: add Usage & Billing settings page"
```
---
### Task 7: Register route and sidebar entry
**Files:**
- Modify: `apps/agent/entrypoints/app/App.tsx` — add route
- Modify: `apps/agent/components/sidebar/SettingsSidebar.tsx` — add sidebar entry
**Step 1: Add route to App.tsx**
Inside the `<Route path="settings">` block (after line 103, before closing `</Route>`):
```tsx
import { UsagePage } from './usage/UsagePage'
// Add as new route:
<Route path="usage" element={<UsagePage />} />
```
**Step 2: Add sidebar entry to SettingsSidebar.tsx**
Import `CreditCard` from lucide-react (line 1). Add entry to the "Other" section in `primarySettingsSections` array (after line 81):
```typescript
{ name: 'Usage & Billing', to: '/settings/usage', icon: CreditCard },
```
**Step 3: Commit**
```bash
git add apps/agent/entrypoints/app/App.tsx apps/agent/components/sidebar/SettingsSidebar.tsx
git commit -m "feat: register usage page route and sidebar entry"
```
---
### Task 8: Verify end-to-end
**Step 1: Start dev server**
```bash
bun run dev:watch -- --new
```
**Step 2: Visual verification checklist**
- [ ] Open side panel — credit badge shows next to BrowserOS provider name
- [ ] Badge color is green when credits > 30
- [ ] Send a chat message — after response completes, badge count decrements
- [ ] Click badge — opens settings/usage page
- [ ] Settings sidebar shows "Usage & Billing" under "Other"
- [ ] Usage page shows credit count, progress bar, reset info
- [ ] Exhaust credits — badge turns red, chat shows error message
**Step 3: Commit any fixes**

View File

@@ -37,6 +37,10 @@
"types": "./src/types/logger.ts",
"default": "./src/types/logger.ts"
},
"./types/server-config": {
"types": "./src/types/server-config.ts",
"default": "./src/types/server-config.ts"
},
"./schemas/llm": {
"types": "./src/schemas/llm.ts",
"default": "./src/schemas/llm.ts"

View File

@@ -16,6 +16,7 @@ export const PATHS = {
CORE_MEMORY_FILE_NAME: 'CORE.md',
SKILLS_DIR_NAME: 'skills',
BUILTIN_DIR_NAME: 'builtin',
SERVER_CONFIG_FILE_NAME: 'server.json',
SOUL_MAX_LINES: 150,
MEMORY_RETENTION_DAYS: 30,
SESSION_RETENTION_DAYS: 30,

View File

@@ -0,0 +1,16 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* Shape of ~/.browseros/server.json written by the server on startup.
* The CLI reads this file for auto-discovery of the server URL.
*/
export interface ServerDiscoveryConfig {
server_port: number
url: string
server_version: string
browseros_version?: string
chromium_version?: string
}

View File

@@ -1,4 +1,4 @@
BROWSEROS_MAJOR=0
BROWSEROS_MINOR=44
BROWSEROS_BUILD=0
BROWSEROS_PATCH=0
BROWSEROS_PATCH=1