Compare commits

...

50 Commits

Author SHA1 Message Date
Mohamed Boudra
391cf75ece chore(release): cut 0.1.104-beta.4 2026-07-06 09:58:20 +02:00
Mohamed Boudra
7ee2d6fd7e docs: changelog for v0.1.104-beta.4 2026-07-06 09:56:20 +02:00
Mohamed Boudra
b1d11fccd5 Hide browser tools unless enabled
Browser tools are now hidden from agent tool catalogs unless daemon browser tools are enabled.
2026-07-05 19:02:59 +02:00
paseo-ai[bot]
05f8dda1e3 fix: update lockfile signatures and Nix hash [skip ci] 2026-07-05 16:11:29 +00:00
Mohamed Boudra
9968f2ba50 Speed up inbound WebSocket validation (#1895)
* perf(protocol): generate inbound ws validators

* perf(client): use generated ws validation

* docs(protocol): document generated validation

* fix(protocol): make validator generation source-only

* test(protocol): cover explicit provider model normalization

* fix(protocol): preserve inbound compat defaults

* fix(protocol): make validator import rewrite portable

* fix(protocol): harden validation safety checks

* fix(protocol): guard generated validator boundaries

* fix(protocol): normalize legacy inbound defaults

* fix(protocol): simplify generated validation safety net

* fix(protocol): keep cold typecheck source-only

* fix(protocol): encapsulate validator codegen

* fix(protocol): bootstrap source-alias typechecks

* fix(app): keep source aliases out of bundler config

* fix(protocol): harden validator codegen packaging

* fix(protocol): own zod-aot patches in generator

* fix(protocol): trim validator safety net

* fix(client): normalize provider updates before dispatch

* fix(protocol): keep validator generation out of install
2026-07-06 00:07:54 +08:00
paseo-ai[bot]
0ecf2fb8da fix: update lockfile signatures and Nix hash [skip ci] 2026-07-04 08:26:52 +00:00
Mohamed Boudra
b3eb77981a chore(release): cut 0.1.104-beta.3 2026-07-04 10:22:14 +02:00
Mohamed Boudra
3e28b9b59e chore(providers): update ACP catalog pins 2026-07-04 10:20:07 +02:00
Mohamed Boudra
42d1a2e4e3 docs(browser): document browser automation 2026-07-04 10:19:53 +02:00
Mohamed Boudra
e98d8c7a36 Auto-title workspaces created by agents (#1887)
* Auto-name workspaces created by agents

* Require workspace auto-namer in websocket harnesses

* Auto-title checkout worktrees without branch renames
2026-07-04 09:52:00 +02:00
Mohamed Boudra
9c220c2451 Browser tools: full accessibility snapshot, trusted input, dialogs, evaluate, and tab controls (#1881)
* feat(browser): replace flat snapshot with ARIA tree and page-side refs

* feat(browser): trusted CDP input with actionability waits

* feat(browser): auto-handle javascript dialogs

* feat(browser): add browser_evaluate

* feat(browser): add scroll, resize, and close_tab tools

* fix(browser): review fixes for snapshot/input/dialog seams

* fix(browser): address review findings on dialog capture, truncation, and keypress targets

* fix(harness): drop resize check that cannot run under parked compositor surfaces

* fix(browser): second review round — snapshot fidelity, key input, dialog scoping
2026-07-04 01:38:07 +02:00
Mohamed Boudra
b28f291493 Generalize browser automation hosting beyond the desktop app (#1878)
* feat(browser): generalize browser automation hosts

Rename the browser automation client capability to a host-neutral payload, advertise supported commands, and route tab-scoped commands by browserId affinity so future browser hosts can register without another protocol shape change.

* fix(browser): harden browser host registration

Reject unusable browser host capabilities, clean up pending requests when a host registration is replaced, and defer list_tabs affinity updates until every host in the aggregation succeeds.

* fix(browser): preserve host tab affinity across reconnects

Keep stranded tab ownership tied to the disconnected host so the same host can reclaim open tabs after reconnect, and tolerate future command names in browser_host capability payloads while retaining only known commands.
2026-07-04 01:46:04 +08:00
Mohamed Boudra
f226222638 Build Docker images from source (#1877)
* fix(docker): build images from source

Collapse the npm-install and source-build image paths into one Dockerfile so every Docker image is built from the checked-out workspace packages. Include procps in the runtime image because provider process cleanup depends on ps through tree-kill on Linux.

* fix(docker): include native speech packages

* fix(docker): cover source build inputs

* fix(docker): ignore local agent artifacts
2026-07-03 15:49:23 +02:00
Mohamed Boudra
6ce4e3f507 Let agents rename workspaces (#1876)
* feat(agent): let agents rename workspaces

* fix(agent): scope workspace rename tool

* fix(agent): allow explicit workspace renames
2026-07-03 15:41:01 +02:00
Mohamed Boudra
8d3a43e05d Stop new browser tabs from an agent stealing your focus (#1875)
* fix(browser): stop new MCP browser tabs stealing focus

browser_new_tab used openTabFocused, switching the workspace UI to
the new tab and interrupting whatever the user was looking at.
Automation commands don't need visual focus to work: webviews stay
resident and paintable regardless, so open the tab in the background
via openTabInBackground instead.

* fix(browser): stop background MCP tabs hijacking Reload

openBrowserTabForRequest still called setWorkspaceActiveBrowser for
tabs opened in the background, so the desktop Reload/Force Reload
menu (which targets getMostRecentWorkspaceActivePaseoBrowserWebContents)
could reload an agent's hidden tab instead of whatever the user was
looking at. Automation commands never needed this: every tab-scoped
MCP tool takes an explicit browserId, and the existing
useSyncWorkspaceActiveBrowser hook already keeps "active browser" in
sync with real UI focus. Drop the redundant call.
2026-07-03 13:19:24 +02:00
Mohamed Boudra
7cb0ccba60 Show Claude Ultra Code where supported (#1872)
* fix(providers): show Claude Ultra Code by capability

Expose Ultra Code for xhigh-capable Claude models and keep first-party Claude model metadata in one manifest so future model updates do not need scattered feature lists.

* fix(providers): preserve Claude fast mode aliases

Normalize versioned Claude model IDs through the manifest before checking fast-mode support so dated Opus IDs keep the same feature surface as their base model.

* fix(providers): keep Claude fast mode first-party

Anchor Claude model normalization so custom settings models that merely contain first-party IDs do not inherit manifest-only fast mode.

* fix(providers): preserve Claude runtime aliases

Keep Fast Mode capability checks strict to manifest IDs while allowing provider-form Claude runtime model strings to resolve known model metadata.
2026-07-03 18:49:59 +08:00
paseo-ai[bot]
f1a8e9a563 fix: update lockfile signatures and Nix hash [skip ci] 2026-07-03 10:02:44 +00:00
Mohamed Boudra
75b11ac93e chore(release): cut 0.1.104-beta.2 2026-07-03 11:58:21 +02:00
Mohamed Boudra
8680bc2236 docs: changelog for v0.1.104-beta.2 2026-07-03 11:44:51 +02:00
Mohamed Boudra
e67e25628b Merge branch 'main' of github.com:getpaseo/paseo 2026-07-03 11:43:17 +02:00
Mohamed Boudra
860fe92fdd fix(browser): normalize resident webview reuse
Resident nodes restored from the parking host could carry stale offscreen styles after hot reload, so reapply the permanent parking path before returning them.
2026-07-03 11:42:39 +02:00
Mohamed Boudra
ab62a023fd fix(browser): park webviews permanently paintable
Delete the renderer capture prep handshake and keep screenshots on the serialized invalidate/retry path. The capture harness now defaults to the production P1 attach-off parking check.
2026-07-03 11:34:48 +02:00
Mohamed Boudra
cc21262130 Extend browser capture parking harness 2026-07-03 11:12:38 +02:00
paseo-ai[bot]
2a226b3872 fix: update lockfile signatures and Nix hash [skip ci] 2026-07-03 06:31:30 +00:00
Mohamed Boudra
a13fa4e614 chore(release): cut 0.1.104-beta.1 2026-07-03 08:27:07 +02:00
Mohamed Boudra
ccd2bb2bf3 docs: tighten beta changelog browser bullet 2026-07-03 08:25:36 +02:00
Mohamed Boudra
570e652496 chore: update ACP provider catalog version pins 2026-07-03 08:17:17 +02:00
Mohamed Boudra
6d55565d19 docs: changelog for v0.1.104-beta.1 2026-07-03 08:16:12 +02:00
Mohamed Boudra
fec723c775 Retry browser screenshots until first frame 2026-07-03 08:02:59 +02:00
Mohamed Boudra
1bf50349a5 Prefer resident webview for screenshot prep 2026-07-03 07:50:34 +02:00
Mohamed Boudra
cda1ba43cc Fix fresh browser screenshot prep race 2026-07-03 07:18:45 +02:00
Mohamed Boudra
f22865df62 Fix multi-webview browser capture prep 2026-07-03 07:00:06 +02:00
Mohamed Boudra
33ffeb4d42 Harden browser capture prep cleanup 2026-07-03 03:53:53 +02:00
Mohamed Boudra
6781521c8e Test Codex MCP images through app-server boundary 2026-07-03 01:16:57 +02:00
Mohamed Boudra
4ca89f21f9 test(browser-tools): fix stale pageInfoCommand references in broker test 2026-07-03 00:38:00 +02:00
Mohamed Boudra
9430a897e8 Fix browser screenshot capture for parked webviews 2026-07-03 00:27:35 +02:00
Mohamed Boudra
b0088303fd Render MCP screenshot images without timeline base64 2026-07-03 00:03:44 +02:00
Mohamed Boudra
2ba3ad53ab feat(browser-tools): cut low-value tools 2026-07-02 23:56:20 +02:00
Mohamed Boudra
b2714ccd89 feat(browser): rework element annotation and picker toolbar
Element annotation is now free-text: dropped the Fix/Change/Question/
Approve intent pills and the intent field, so the user just says what
they want. The card uses the shared Button and is titled "Annotate
element".

Toolbar: the element picker and DevTools are no longer dev-gated and
work in prod; DevTools uses a wrench icon, and the old clipboard "grab"
is now a Camera "Screenshot element" tool. Each toolbar button
highlights only its own mode, and the copy toast reflects whether a
screenshot image was actually captured.
2026-07-02 23:20:57 +02:00
Mohamed Boudra
82993c2197 test(mcp-server): assert listing invariant via public catalog 2026-07-02 22:31:22 +02:00
Mohamed Boudra
742d47fe71 fix(browser-tools): restore MCP-compatible outputs
Browser tools were registering union output schemas that the MCP SDK could not validate at call time. Use raw object output shapes and cover the real tools/call boundary.
2026-07-02 22:14:26 +02:00
Mohamed Boudra
441b935b73 test(browser-tools): restore automation coverage 2026-07-02 20:38:48 +02:00
Mohamed Boudra
b2ad903005 Fix browser tool ergonomics followups 2026-07-02 20:18:22 +02:00
Mohamed Boudra
554e525186 Tighten browser tool schemas 2026-07-02 20:00:17 +02:00
Mohamed Boudra
71ce96b60f refactor(browser-tools): require explicit tab targets
Remove hidden active-browser targeting so automation requests carry workspace in the envelope and browserId in tab command args.
2026-07-02 19:53:40 +02:00
Mohamed Boudra
d9615c1e98 Serialize browser screenshot capture state 2026-07-02 19:04:47 +02:00
Mohamed Boudra
6fea8305a7 Use Electron capturePage for browser screenshots 2026-07-02 18:53:57 +02:00
Mohamed Boudra
172de4fbc2 Focus browser automation new tabs 2026-07-02 18:46:57 +02:00
Mohamed Boudra
adc0c01782 Fail fast on unpainted browser screenshots 2026-07-02 18:43:54 +02:00
Mohamed Boudra
3eed8c31a2 Fix browser tool workspace context 2026-07-02 18:39:10 +02:00
129 changed files with 15581 additions and 7193 deletions

View File

@@ -1,11 +1,31 @@
.git
.debug.conversations
.debug
.dev
.playwright-mcp
**/.playwright-mcp
.paseo
**/.paseo-provider-history
.plans
.tasks
.valknut
.claude/settings.local.json
**/.claude/settings.local.json
.claude/scheduled_tasks.lock
.claude/worktrees
.wrangler
**/.wrangler
**/.tanstack
PLAN.md
valknut-report.html
valknut-report.json
.env*
**/.env*
.dev.vars
**/.dev.vars
*.pem
**/*.pem
**/.secrets
**/node_modules
**/dist
**/build

View File

@@ -76,7 +76,6 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Build server stack
run: npm run build:server
@@ -113,7 +112,6 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Install agent CLIs for provider tests
run: npm install -g @anthropic-ai/claude-code opencode-ai
@@ -170,7 +168,6 @@ jobs:
}
Start-Sleep -Seconds (20 * $attempt)
}
- name: Build server stack
run: npm run build:server
@@ -202,7 +199,6 @@ jobs:
fi
sleep $((attempt * 20))
done
- name: Install Playwright browsers
timeout-minutes: 10
run: npx playwright install chromium
@@ -227,7 +223,6 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Build client dependencies
run: npm run build:client
@@ -270,7 +265,6 @@ jobs:
fi
sleep $((attempt * 20))
done
- name: Install Playwright browsers
timeout-minutes: 10
run: npx playwright install chromium

View File

@@ -27,7 +27,6 @@ jobs:
run: npm ci
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build app dependencies
run: npm run build:app-deps

View File

@@ -5,7 +5,22 @@ on:
branches: [main]
paths:
- "docker/**"
- ".dockerignore"
- ".github/workflows/docker.yml"
- "package.json"
- "package-lock.json"
- "patches/**"
- "scripts/**"
- "tsconfig.json"
- "tsconfig.base.json"
- "packages/app/**"
- "packages/cli/**"
- "packages/client/**"
- "packages/expo-two-way-audio/**"
- "packages/highlight/**"
- "packages/protocol/**"
- "packages/relay/**"
- "packages/server/**"
push:
branches: [main]
tags:
@@ -13,7 +28,7 @@ on:
workflow_dispatch:
inputs:
paseo_version:
description: "Paseo version to build. Required when publish is true."
description: "Expected source version to build. Required when publish is true."
required: false
default: ""
publish:
@@ -24,15 +39,6 @@ on:
options:
- "false"
- "true"
source_build:
description: "Build from the checked-out source tree instead of npm."
required: false
default: "auto"
type: choice
options:
- auto
- "false"
- "true"
publish_latest:
description: "Also publish ghcr.io/getpaseo/paseo:latest. Ignored for prereleases."
required: false
@@ -57,7 +63,6 @@ jobs:
image: ${{ steps.values.outputs.image }}
install_version: ${{ steps.values.outputs.install_version }}
publish: ${{ steps.values.outputs.publish }}
source_build: ${{ steps.values.outputs.source_build }}
check_tag: ${{ steps.values.outputs.check_tag }}
publish_tags: ${{ steps.values.outputs.publish_tags }}
steps:
@@ -68,7 +73,6 @@ jobs:
INPUT_PASEO_VERSION: ${{ inputs.paseo_version }}
INPUT_PUBLISH: ${{ inputs.publish }}
INPUT_PUBLISH_LATEST: ${{ inputs.publish_latest }}
INPUT_SOURCE_BUILD: ${{ inputs.source_build }}
REPO_OWNER: ${{ github.repository_owner }}
REF_NAME: ${{ github.ref_name }}
run: |
@@ -76,9 +80,9 @@ jobs:
owner="$(printf '%s' "${REPO_OWNER}" | tr '[:upper:]' '[:lower:]')"
image="ghcr.io/${owner}/paseo"
install_version="${INPUT_PASEO_VERSION:-latest}"
package_version="$(node -p "require('./package.json').version")"
install_version="${INPUT_PASEO_VERSION:-${package_version}}"
publish=false
source_build=false
publish_latest=false
prerelease=false
@@ -87,7 +91,6 @@ jobs:
publish=true
if [[ "${REF_NAME}" == *-* ]]; then
prerelease=true
source_build=true
else
publish_latest=true
fi
@@ -104,24 +107,6 @@ jobs:
prerelease=true
fi
case "${INPUT_SOURCE_BUILD:-auto}" in
true)
source_build=true
;;
false)
source_build=false
;;
auto)
if [[ "${prerelease}" == "true" ]]; then
source_build=true
fi
;;
*)
echo "::error::source_build must be auto, true, or false."
exit 1
;;
esac
if [[ "${INPUT_PUBLISH_LATEST:-false}" == "true" && "${prerelease}" != "true" ]]; then
publish_latest=true
fi
@@ -137,14 +122,13 @@ jobs:
echo "image=${image}"
echo "install_version=${install_version}"
echo "publish=${publish}"
echo "source_build=${source_build}"
echo "check_tag=${check_tag}"
echo "publish_tags<<EOF"
echo "${publish_tags}"
echo "EOF"
} >> "$GITHUB_OUTPUT"
echo "Resolved image=${image} install_version=${install_version} publish=${publish} source_build=${source_build}"
echo "Resolved image=${image} install_version=${install_version} publish=${publish}"
build:
needs: setup
@@ -160,8 +144,8 @@ jobs:
- uses: docker/build-push-action@v7
with:
context: ${{ needs.setup.outputs.source_build == 'true' && '.' || 'docker/base' }}
file: ${{ needs.setup.outputs.source_build == 'true' && 'docker/base/Dockerfile.source' || 'docker/base/Dockerfile' }}
context: .
file: docker/base/Dockerfile
platforms: ${{ env.PLATFORMS }}
build-args: |
PASEO_VERSION=${{ needs.setup.outputs.install_version }}
@@ -193,8 +177,8 @@ jobs:
- uses: docker/build-push-action@v7
with:
context: ${{ needs.setup.outputs.source_build == 'true' && '.' || 'docker/base' }}
file: ${{ needs.setup.outputs.source_build == 'true' && 'docker/base/Dockerfile.source' || 'docker/base/Dockerfile' }}
context: .
file: docker/base/Dockerfile
platforms: ${{ env.PLATFORMS }}
build-args: |
PASEO_VERSION=${{ needs.setup.outputs.install_version }}

1
.gitignore vendored
View File

@@ -84,6 +84,7 @@ valknut-report.json/
.plans/
packages/server/src/server/fixtures/dictation/dictation-debug-largest.wav
packages/server/src/server/fixtures/dictation/dictation-debug-largest.transcript.txt
packages/protocol/src/generated/validation/*.aot.ts
/artifacts
packages/desktop/.cache/

View File

@@ -1,5 +1,32 @@
# Changelog
## 0.1.104-beta.4 - 2026-07-06
### Added
- Agents can drive the in-app browser with page snapshots, trusted input, dialogs, and tab controls ([#1881](https://github.com/getpaseo/paseo/pull/1881))
- Inspect, annotate, and send page elements from a browser tab to the agent ([#1708](https://github.com/getpaseo/paseo/pull/1708) by [@huiliaoning](https://github.com/huiliaoning))
- Schedules screen to create and manage recurring agents ([#1246](https://github.com/getpaseo/paseo/pull/1246))
- Open a project from anywhere with Cmd+O ([#1849](https://github.com/getpaseo/paseo/pull/1849))
- Agents can rename workspaces after they understand the task ([#1876](https://github.com/getpaseo/paseo/pull/1876))
- Claude Ultra Code is available for supported Claude models ([#1872](https://github.com/getpaseo/paseo/pull/1872))
- ByteDance TRAE CLI available as an agent provider ([#1831](https://github.com/getpaseo/paseo/pull/1831) by [@park0er](https://github.com/park0er))
### Improved
- Large provider and model refreshes load faster in the app ([#1895](https://github.com/getpaseo/paseo/pull/1895))
- Workspaces created by agents now get readable generated names ([#1887](https://github.com/getpaseo/paseo/pull/1887))
- Browser tabs opened by agents stay in the background until you switch to them ([#1875](https://github.com/getpaseo/paseo/pull/1875))
- Clearer cards when an agent asks a question ([#1643](https://github.com/getpaseo/paseo/pull/1643) by [@cleiter](https://github.com/cleiter))
### Fixed
- Docker images keep running during provider cleanup and diagnostics ([#1877](https://github.com/getpaseo/paseo/pull/1877))
- New Workspace drafts survive archiving a workspace ([#1838](https://github.com/getpaseo/paseo/pull/1838))
- Composer autocomplete stays open after switching screens ([#1851](https://github.com/getpaseo/paseo/pull/1851))
- Claude usage appears when a quota window has no scheduled reset ([#1855](https://github.com/getpaseo/paseo/pull/1855))
- New workspace action shows for non-git projects in the sidebar ([#1857](https://github.com/getpaseo/paseo/pull/1857) by [@cleiter](https://github.com/cleiter))
## 0.1.103 - 2026-07-01
### Added

View File

@@ -21,34 +21,36 @@ This is an npm workspace monorepo:
At the start of non-trivial work, list `docs/` and skim anything relevant to the task. When you learn something meta worth preserving — a gotcha, a convention, a workflow, a piece of system context that will outlive the current task — update an existing doc or propose a new one. Code-level facts belong in inline comments next to the code; system, process, and gotcha-level facts belong in `docs/`.
| Doc | What's in it |
| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| [docs/product.md](docs/product.md) | What Paseo is, who it's for, where it's going |
| [docs/architecture.md](docs/architecture.md) | System design, package layering, WebSocket protocol, agent lifecycle, data flow |
| [docs/agent-lifecycle.md](docs/agent-lifecycle.md) | Agent states, parent/child relationships, archive semantics, tabs vs archive, subagents track |
| [docs/data-model.md](docs/data-model.md) | File-based JSON persistence, Zod schemas, atomic writes, no migrations |
| [docs/glossary.md](docs/glossary.md) | Authoritative terminology — UI label wins, no synonyms |
| [docs/coding-standards.md](docs/coding-standards.md) | Type hygiene, error handling, state design, React patterns, file organization |
| [docs/design.md](docs/design.md) | Theme tokens — colors, fonts, spacing, radii, icons |
| [docs/hover.md](docs/hover.md) | Hover — the canonical pattern (plain View + onPointerEnter/Leave, separate inner Pressable) and the three ways agents break it |
| [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order |
| [docs/floating-panels.md](docs/floating-panels.md) | Anchored popovers — Portal/Modal escape for Android, lifecycle gates, keyboard-shared-value, status-bar offset, the flash |
| [docs/expo-router.md](docs/expo-router.md) | Expo Router route ownership, startup restore, and native blank-screen gotchas |
| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer |
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
| [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config |
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows |
| [docs/docker.md](docs/docker.md) | Running the daemon and bundled web UI in Docker, volumes, agent images, security |
| [docs/release.md](docs/release.md) | Release playbook, draft releases, completion checklist |
| [docs/terminal-activity.md](docs/terminal-activity.md) | Terminal activity indicators — source-agnostic tracker, agent hook reporting, adding a new hook provider |
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
| Doc | What's in it |
| ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ |
| [docs/product.md](docs/product.md) | What Paseo is, who it's for, where it's going |
| [docs/architecture.md](docs/architecture.md) | System design, package layering, WebSocket protocol, agent lifecycle, data flow |
| [docs/agent-lifecycle.md](docs/agent-lifecycle.md) | Agent states, parent/child relationships, archive semantics, tabs vs archive, subagents track |
| [docs/data-model.md](docs/data-model.md) | File-based JSON persistence, Zod schemas, atomic writes, no migrations |
| [docs/glossary.md](docs/glossary.md) | Authoritative terminology — UI label wins, no synonyms |
| [docs/coding-standards.md](docs/coding-standards.md) | Type hygiene, error handling, state design, React patterns, file organization |
| [docs/design.md](docs/design.md) | Theme tokens — colors, fonts, spacing, radii, icons |
| [docs/hover.md](docs/hover.md) | Hover — the canonical pattern (plain View + onPointerEnter/Leave, separate inner Pressable) and the three ways agents break it |
| [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order |
| [docs/floating-panels.md](docs/floating-panels.md) | Anchored popovers — Portal/Modal escape for Android, lifecycle gates, keyboard-shared-value, status-bar offset, the flash |
| [docs/expo-router.md](docs/expo-router.md) | Expo Router route ownership, startup restore, and native blank-screen gotchas |
| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer |
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
| [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config |
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
| [docs/protocol-validation.md](docs/protocol-validation.md) | zod-aot generated inbound WebSocket validation, patched compiler regressions, schema-purity rules |
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
| [docs/browser-capture-harness.md](docs/browser-capture-harness.md) | Real-Electron browser screenshot harness and compositor-surface gotcha |
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows |
| [docs/docker.md](docs/docker.md) | Running the daemon and bundled web UI in Docker, volumes, agent images, security |
| [docs/release.md](docs/release.md) | Release playbook, draft releases, completion checklist |
| [docs/terminal-activity.md](docs/terminal-activity.md) | Terminal activity indicators — source-agnostic tracker, agent hook reporting, adding a new hook provider |
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
## Quick start
@@ -91,10 +93,13 @@ See [docs/development.md](docs/development.md) for full setup, build sync requir
- `npm run format:files -- CLAUDE.md packages/app/src/components/message.tsx`
- **The protocol stays backward-compatible. Features don't have to.** Two separate contracts:
- **Protocol contract (always):** schema changes must not break parsing in either direction. An old client must still parse messages from a new daemon; a new daemon must still parse messages from an old client.
- New fields: `.optional()` with a sensible default or `.transform()` fallback.
- New fields: `.optional()` with a sensible default.
- Never flip optional → required, remove fields, or narrow types (`string``enum`, `nullable` → non-null).
- Removed fields stay accepted (we stop sending them, not stop reading them).
- Test with: "does a 6-month-old client still parse this?" and "does a 6-month-old daemon still send something this client accepts?"
- Wire schemas are pure structural declarations. Do not add `.transform()`, `.catch()`, or `.preprocess()` to WebSocket message schemas; put normalization in an explicit post-validation pass.
- Plain `z.union()` is forbidden when every branch has a shared literal tag. Use `z.discriminatedUnion()` unless generated-code regression tests prove that specific shape is miscompiled.
- `.default()` is acceptable on primitive leaves only. Never put defaults on item schemas for large arrays or big inbound containers.
- **Feature contract (per-feature):** a new feature may require a new daemon capability. The client detects whether the capability is present and either runs the feature or shows "Update the host to use this." That's it.
- **No fallback paths.** Don't write a degraded version of a new feature that runs on old daemons. Don't fan out across legacy RPCs to simulate a missing capability. The user upgrades or doesn't get the feature.
- **No defensive branches scattered through the feature.** Capability detection happens in one place; downstream code reads a clean shape.

View File

@@ -1,9 +1,32 @@
# syntax=docker/dockerfile:1
ARG NODE_IMAGE=node:22-bookworm-slim
FROM ${NODE_IMAGE}
FROM --platform=$BUILDPLATFORM ${NODE_IMAGE} AS source-pack
ARG PASEO_VERSION=latest
ARG PASEO_VERSION
ENV ONNXRUNTIME_NODE_INSTALL=skip
WORKDIR /tmp/paseo-src
COPY . .
RUN set -eux; \
if [ -n "${PASEO_VERSION:-}" ]; then \
test "$(node -p "require('./package.json').version")" = "${PASEO_VERSION}"; \
fi; \
node -e 'const fs=require("node:fs"); const pkg=JSON.parse(fs.readFileSync("package.json","utf8")); delete pkg.scripts.prepare; fs.writeFileSync("package.json", `${JSON.stringify(pkg)}\n`);'; \
npm ci
RUN set -eux; \
mkdir -p /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/highlight --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/relay --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/protocol --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/client --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/server --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/cli --pack-destination /tmp/paseo-packs
FROM ${NODE_IMAGE}
ENV HOME=/home/paseo \
PASEO_HOME=/home/paseo/.paseo \
@@ -29,13 +52,14 @@ RUN set -eux; \
gosu \
lbzip2 \
openssh-client \
procps \
tini; \
rm -rf /var/lib/apt/lists/*
COPY --from=source-pack /tmp/paseo-packs /tmp/paseo-packs
RUN set -eux; \
npm install -g --omit=optional \
"@getpaseo/server@${PASEO_VERSION}" \
"@getpaseo/cli@${PASEO_VERSION}"; \
npm install -g /tmp/paseo-packs/*.tgz; \
rm -rf /tmp/paseo-packs; \
npm cache clean --force; \
server_entry="$(npm root -g)/@getpaseo/server/dist/scripts/supervisor-entrypoint.js"; \
test -f "$server_entry"; \
@@ -66,7 +90,7 @@ RUN set -eux; \
"$XDG_CACHE_HOME"; \
chown -R paseo:paseo /home/paseo /workspace
COPY rootfs/ /
COPY docker/base/rootfs/ /
RUN chmod +x /usr/local/bin/paseo-docker-entrypoint
WORKDIR /workspace

View File

@@ -1,101 +0,0 @@
# syntax=docker/dockerfile:1
ARG NODE_IMAGE=node:22-bookworm-slim
FROM --platform=$BUILDPLATFORM ${NODE_IMAGE} AS source-pack
ARG PASEO_VERSION
ENV ONNXRUNTIME_NODE_INSTALL=skip
WORKDIR /tmp/paseo-src
COPY . .
RUN set -eux; \
test "$(node -p "require('./package.json').version")" = "${PASEO_VERSION}"; \
node -e 'const fs=require("node:fs"); const pkg=JSON.parse(fs.readFileSync("package.json","utf8")); delete pkg.scripts.prepare; fs.writeFileSync("package.json", `${JSON.stringify(pkg)}\n`);'; \
npm ci
RUN set -eux; \
mkdir -p /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/highlight --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/relay --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/protocol --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/client --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/server --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/cli --pack-destination /tmp/paseo-packs
FROM ${NODE_IMAGE}
ENV HOME=/home/paseo \
PASEO_HOME=/home/paseo/.paseo \
PASEO_LISTEN=0.0.0.0:6767 \
PASEO_WEB_UI_ENABLED=true \
PASEO_LOG_FORMAT=json \
PASEO_LOG_LEVEL=info \
CLAUDE_CONFIG_DIR=/home/paseo/.claude \
CODEX_HOME=/home/paseo/.codex \
XDG_CONFIG_HOME=/home/paseo/.config \
XDG_DATA_HOME=/home/paseo/.local/share \
XDG_STATE_HOME=/home/paseo/.local/state \
XDG_CACHE_HOME=/home/paseo/.cache \
ONNXRUNTIME_NODE_INSTALL=skip
RUN set -eux; \
apt-get update; \
apt-get install -y --no-install-recommends \
bash \
ca-certificates \
curl \
git \
gosu \
lbzip2 \
openssh-client \
tini; \
rm -rf /var/lib/apt/lists/*
COPY --from=source-pack /tmp/paseo-packs /tmp/paseo-packs
RUN set -eux; \
npm install -g --omit=optional /tmp/paseo-packs/*.tgz; \
rm -rf /tmp/paseo-packs; \
npm cache clean --force; \
server_entry="$(npm root -g)/@getpaseo/server/dist/scripts/supervisor-entrypoint.js"; \
test -f "$server_entry"; \
printf '%s\n' "$server_entry" > /etc/paseo-server-entry; \
node --check "$server_entry"
RUN set -eux; \
existing_group="$(getent group 1000 | cut -d: -f1 || true)"; \
if [ -n "$existing_group" ] && [ "$existing_group" != "paseo" ]; then \
groupmod --new-name paseo "$existing_group"; \
elif [ -z "$existing_group" ]; then \
groupadd --gid 1000 paseo; \
fi; \
existing_user="$(getent passwd 1000 | cut -d: -f1 || true)"; \
if [ -n "$existing_user" ] && [ "$existing_user" != "paseo" ]; then \
usermod --login paseo --gid paseo --home /home/paseo --shell /bin/bash "$existing_user"; \
elif [ -z "$existing_user" ]; then \
useradd --uid 1000 --gid paseo --create-home --home-dir /home/paseo --shell /bin/bash paseo; \
fi; \
mkdir -p \
/workspace \
"$PASEO_HOME" \
"$CLAUDE_CONFIG_DIR" \
"$CODEX_HOME" \
"$XDG_CONFIG_HOME" \
"$XDG_DATA_HOME" \
"$XDG_STATE_HOME" \
"$XDG_CACHE_HOME"; \
chown -R paseo:paseo /home/paseo /workspace
COPY docker/base/rootfs/ /
RUN chmod +x /usr/local/bin/paseo-docker-entrypoint
WORKDIR /workspace
EXPOSE 6767
VOLUME ["/home/paseo"]
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD node -e "const listen=process.env.PASEO_LISTEN||'0.0.0.0:6767'; const m=listen.match(/:(\\d+)$/); const port=m?Number(m[1]):6767; require('http').get({hostname:'127.0.0.1',port,path:'/api/health'},r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/paseo-docker-entrypoint"]

View File

@@ -138,7 +138,7 @@ Electron wrapper for macOS, Linux, and Windows.
> **Window-state v1 limitation:** only the _first_ window of a session restores and persists saved geometry (size/position/maximized). Windows opened via ⌘⇧N / second-instance / "Open in new window" open at the default size, OS-cascaded, and do not persist — this avoids every window stacking on the same restored bounds and fighting over the single window-state store. Lifting this needs per-window state keys.
>
> **In-app browser panes are not yet per-window.** Browser webviews are tracked by one process-global registry that keeps a single current `WebContents` per browser id. Human focus and agent automation targets are intentionally separate: the workspace-active browser follows the user's focused tab, while the agent-active browser is the default target for browser MCP commands. The webview registration queue (`pendingBrowserWebviewIds` in `main.ts`) is still process-global. With browser panes open in two windows, a menu Reload can target the other window's webview, and near-simultaneous webview attach across windows can register under the wrong browser id. Multi-window v1 ships windows; making the browser-webview subsystem window-scoped is a follow-up.
> **In-app browser panes are not yet per-window.** Browser webviews are tracked by one process-global registry that keeps a single current `WebContents` per browser id. Human focus still records the workspace-active browser for UI state and `list_tabs` reporting, but agent automation targets only explicit browser ids returned by `browser_new_tab` or `browser_list_tabs`. The webview registration queue (`pendingBrowserWebviewIds` in `main.ts`) is still process-global. With browser panes open in two windows, a menu Reload can target the other window's webview, and near-simultaneous webview attach across windows can register under the wrong browser id. Multi-window v1 ships windows; making the browser-webview subsystem window-scoped is a follow-up.
### `packages/website` — Marketing site

View File

@@ -0,0 +1,69 @@
# Browser Capture Harness
The desktop capture harness is the real-Electron verification path for browser screenshots.
It validates the compositor behavior that unit tests cannot see:
- the resident automation `<webview>` starts in the production parking state;
- the parked guest remains paintable and has a copyable viewport frame;
- the resident webview guest is sized to 1280x800 logical pixels;
- multiple resident webviews are parked as an overlapping stack without per-capture
stacking changes;
- a newly attached resident webview whose first useful frame is delayed can be captured
by retrying until the frame appears;
- both viewport `capturePage` and full-page CDP screenshots return real pixels from
the permanent production parking state;
- guest background throttling can be disabled once at attach without per-capture
renderer coordination.
Run it with the repo Electron:
```bash
npm run capture-harness --workspace=@getpaseo/desktop
```
Run the browser automation fixture with:
```bash
PASEO_CAPTURE_HARNESS_GROUP=automation npm run capture-harness --workspace=@getpaseo/desktop
```
The automation group uses a real guest webview to verify the page-side ref contract:
ARIA-like snapshot text includes headings, static text, and controls; refs survive
`pushState` when the element still matches; same-URL rerenders stale old refs; and a
file-input ref can be resolved to a CDP backend node id for upload. It also verifies
page-context evaluation, including passing a resolved ref element as the function argument.
On macOS the harness process must set `app.setActivationPolicy("accessory")` and
hide the Dock icon before creating any window. `showInactive()` only prevents window
focus; a normal Electron app launch can still activate the app and steal focus.
Harness windows are then created hidden, positioned in a screen corner, skipped from
the taskbar where Electron supports it, and revealed with `showInactive()` from
`ready-to-show`. Do not replace this with `show()`, `focus()`, or `app.focus()`:
the compositor only needs visible inactive windows, and harness runs must not steal
focus from the person using the machine.
The harness writes PNG evidence and `results.json` to:
```text
packages/desktop/capture-harness/out/
```
A passing run prints `PASS` lines for the production P1 attach-off parking state,
including fresh, settled, 75-second soak, multi-tab, viewport, and full-page checks. The
PNG sizes may be device-pixel scaled; on a Retina display the 1280x800 logical viewport
is usually saved as 2560x1600.
## Mechanism
Electron captures copy from the guest web contents' compositor surface. A resident
webview parked with `display:none`, offscreen coordinates, or `opacity:0` can lose its
copyable surface. The production parking state keeps the host fixed at `left:0`, `top:0`,
`width:1px`, `height:1px`, `overflow:hidden`, `opacity:1`, and `pointer-events:none`.
The webviews inside stay full-size at 1280x800, `display:inline-flex`, and absolutely
overlap at `left:0`, `top:0`.
There is no renderer prep/restore handshake. Main disables guest background throttling
once when the webview attaches, then screenshot capture uses the shared serialized queue,
invalidates before each attempt, and retries known first-frame failures within the
5-second capture budget. Viewport screenshots use `capturePage({ stayHidden:false })`;
full-page screenshots use the existing CDP path with layout metrics and screenshot clip.

View File

@@ -10,8 +10,7 @@ The image source lives in [`docker/`](../docker/).
The official image:
- installs `@getpaseo/server` and `@getpaseo/cli` from npm for stable images,
or from source-built workspace tarballs for beta images
- builds `@getpaseo/server` and `@getpaseo/cli` from source-built workspace tarballs
- runs the daemon as the non-root `paseo` user
- listens on `0.0.0.0:6767` inside the container
- enables the bundled daemon web UI with `PASEO_WEB_UI_ENABLED=true`
@@ -190,16 +189,17 @@ See [SECURITY.md](../SECURITY.md) for the daemon trust model.
## Building Locally
```bash
docker build -t paseo:local docker/base
docker build -f docker/base/Dockerfile -t paseo:local .
```
To bake a specific published npm version:
To assert the source tree version while building:
```bash
docker build \
--build-arg PASEO_VERSION=0.1.102 \
-t paseo:0.1.102 \
docker/base
-f docker/base/Dockerfile \
.
```
The Docker workflow builds the image on pull requests and on `main` as a
@@ -216,13 +216,12 @@ pushing a `v*` release tag:
gh workflow run docker.yml \
--ref main \
-f paseo_version=0.1.102-beta.1 \
-f publish=true \
-f source_build=auto
-f publish=true
```
Manual Docker publishes require an explicit `paseo_version`. Prerelease
versions build from the checked-out source tree by default and publish only the
exact prerelease image tag.
Manual Docker publishes require an explicit `paseo_version`. The workflow builds
from the checked-out source tree and publishes only the exact prerelease image
tag for prerelease versions.
The published image is multi-arch for `linux/amd64` and `linux/arm64`.

View File

@@ -73,7 +73,7 @@ Anyone who builds software:
- Built-in providers: Claude Code (Agent SDK), Codex (app-server), GitHub Copilot (ACP), OpenCode, Pi, OMP
- One-click ACP provider catalog: CodeWhale, Cursor, Hermes, Qwen Coder, Kimi Code, and others — plus custom ACP providers
- Voice mode: dictate prompts or talk through problems hands-free
- MCP server exposes the daemon to other agents (create_agent, send_agent_prompt, schedules, terminals, worktrees)
- MCP server exposes the daemon to other agents (create_agent, send_agent_prompt, schedules, terminals, worktrees, workspace renaming)
- Scheduled agents (cron-style triggers) via app, CLI, and MCP
- Frequent releases (multiple per week)
- Community contributions across packaging, providers, and bug fixes

View File

@@ -0,0 +1,42 @@
# Protocol Validation
The client validates inbound WebSocket messages with a zod-aot generated validator instead of runtime Zod on the hot path. Zod remains the authoring source of truth for schemas and TypeScript types.
The reason is mobile performance. A captured 353 KB provider snapshot cost about 10.9 ms and 5.9 MB allocated per message for `JSON.parse` plus Zod on Hermes. After moving provider-model normalization out of the schema so zod-aot could compile the hot subtree, the generated validator path measured about 2.5 ms and 1.2 MB allocated.
## Runtime Path
`packages/protocol/src/validation/ws-outbound.ts` is the shipped boundary. It calls the generated `WSOutboundMessageSchema.safeParse` and returns the validated data. It does not normalize, repair, or re-validate the generated result.
Generated validators preserve unknown keys where Zod object parsing strips them. The client dispatch path uses known `type` and payload fields, so this passthrough behavior is accepted for inbound messages. The wire format is unchanged.
Provider model normalization is a parser-side compatibility shim in the client consumers that need it. Newer daemons normalize at the provider registry source.
## Codegen Ownership
The protocol package owns generation.
- `packages/protocol/codegen/ws-outbound.compile.ts` is the build-time zod-aot discovery entry.
- `packages/protocol/scripts/generate-validation-aot.mjs` runs the exact-pinned compiler and applies the small local compiler patches before generation.
- `packages/protocol/scripts/watch-validation-aot.mjs` reruns generation while editing protocol sources.
- `packages/protocol/src/generated/validation/ws-outbound.aot.ts` is generated runtime code and is gitignored.
- `packages/protocol/src/validation/ws-outbound-schema-metadata.ts` is runtime schema metadata for zod-aot fallback/default references.
Generation runs from protocol-owned lifecycle hooks: `prebuild`, `pretypecheck`, `pretest`, and `watch`. Installs do not run generation: published packages consume protocol from prebuilt `dist`, and local build/typecheck/test flows generate the source file at the point it is actually needed.
## Regression Tests
zod-aot is exact-pinned and young enough that compiler patches are treated as part of this package. `packages/protocol/tests/validation/ws-outbound.test.ts` keeps small regression tests for the patched cases:
- discriminated-union branch output must propagate `.default()` fields
- current sequential item routing must accept `tool_call`-like status branches
- generated runtime imports must keep `.js` extensions for packaged Node ESM
- the generated WebSocket envelope accepts a minimal valid message and rejects a corrupted one
## Schema Purity
Message schemas are structural declarations. Do not put `.transform()`, `.catch()`, or `.preprocess()` on WebSocket message schemas. If parsed data needs normalization, put it in an explicit consumer or post-validation pass.
Use `z.discriminatedUnion()` when every branch has a shared literal tag. Plain `z.union()` is acceptable only when there is no shared literal discriminator or when a generated-code regression test proves that specific shape is miscompiled.
Defaults are allowed only on primitive leaves. Do not place `.default()` on large arrays, item schemas, or big containers in inbound message schemas.

View File

@@ -18,6 +18,8 @@ Implement the `AgentClient` and `AgentSession` interfaces from `agent-sdk-types.
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`providers/pi/agent.ts`), and `omp` (a Pi-compatible built-in backed by the Pi adapter). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
Claude first-party model metadata lives in `packages/server/src/server/agent/providers/claude/model-manifest.ts`. When adding or updating a Claude model, update that manifest only; the model picker thinking options and Claude-specific feature gates are derived from the manifest. Do not add model-specific Claude capability lists in feature code.
Paseo tools are not implemented as MCP tools internally. They live in a shared tool catalog under `packages/server/src/server/agent/tools/`; MCP is only the fallback adapter. A provider that can register runtime tools directly should set `supportsNativePaseoTools: true` and consume `launchContext.paseoTools` in `createSession`/`resumeSession`. When native tools are present, `AgentManager` strips the internal Paseo MCP server from the provider launch config so the provider does not receive the same tools twice. Providers that only know MCP should keep `supportsMcpServers: true` and let the daemon inject `/mcp/agents`.
Pi is a process-backed provider. Paseo requires the user to have the `pi` binary installed and talks to it through `pi --mode rpc`; the server package does not embed Pi's SDK/runtime packages.

View File

@@ -50,7 +50,7 @@ npm run release:patch
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag. The tag push triggers `Desktop Release`, `Android APK Release`, `Docker`, and `Release Notes Sync` on GitHub Actions. EAS picks up the same tag via the EAS GitHub app and starts the iOS + Android store builds in parallel (see "Mobile builds (EAS)" below) — there is no `release-mobile.yml` in this repo.
The Docker workflow builds images on pull requests and on `main` as non-publishing checks. Stable `vX.Y.Z` tag pushes publish `ghcr.io/getpaseo/paseo:X.Y.Z` and `ghcr.io/getpaseo/paseo:latest`; beta `vX.Y.Z-beta.N` tag pushes publish only `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` and never move `latest`. Beta Docker images build from the checked-out source tree so the beta flow can intentionally skip npm publishing.
The Docker workflow builds images from the checked-out source tree on pull requests and on `main` as non-publishing checks. Stable `vX.Y.Z` tag pushes publish `ghcr.io/getpaseo/paseo:X.Y.Z` and `ghcr.io/getpaseo/paseo:latest`; beta `vX.Y.Z-beta.N` tag pushes publish only `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` and never move `latest`.
**Releases are always patch.** "Release paseo", "release stable", "ship stable", and similar always mean a patch bump from the previous stable. Never bump minor or major to trigger a build, ever — minor and major bumps are reserved for genuinely larger product cuts and require an explicit user instruction with the word "minor" or "major". If you find yourself reaching for `release:minor` to retrigger a failed build, you are doing the wrong thing — push a retry tag instead (see "Fixing a failed release build" below).
@@ -281,8 +281,7 @@ and EAS mobile release builds. Use the Docker workflow dispatch instead:
gh workflow run docker.yml \
--ref main \
-f paseo_version=X.Y.Z-beta.N \
-f publish=true \
-f source_build=auto
-f publish=true
```
This replaces `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` in place without touching

View File

@@ -1 +1 @@
sha256-o+VzG7lK0qpyUXF4F5Hk08ooW5CPoZSsOG7DyIReUKQ=
sha256-roNSM3Vzqqb9pMuA1UKuYbrWK5XvRXYbEgNvJb1bl7U=

77
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.103",
"version": "0.1.104-beta.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.103",
"version": "0.1.104-beta.4",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -21797,9 +21797,9 @@
}
},
"node_modules/get-tsconfig": {
"version": "4.13.6",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
"integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==",
"version": "4.14.0",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz",
"integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==",
"devOptional": true,
"license": "MIT",
"dependencies": {
@@ -28563,9 +28563,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"license": "MIT",
"engines": {
"node": ">=12"
@@ -35069,6 +35069,26 @@
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zod-aot": {
"version": "0.20.4",
"resolved": "https://registry.npmjs.org/zod-aot/-/zod-aot-0.20.4.tgz",
"integrity": "sha512-vVuKSG4MpJw3bRQu7UEd6ziJ8+AJY3JB2rWqshnSIRJmhxWFb3FiZ9QYnYcldKYZRzO7edgid8BxJkGbZg43OA==",
"dev": true,
"license": "MIT",
"dependencies": {
"acorn": "^8.16.0",
"get-tsconfig": "^4.14.0",
"jiti": "^2.7.0",
"picomatch": "^4.0.4",
"unplugin": "^3.0.0"
},
"bin": {
"zod-aot": "dist/cli/index.js"
},
"peerDependencies": {
"zod": "^4.0.0"
}
},
"node_modules/zod-to-json-schema": {
"version": "3.25.1",
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz",
@@ -35132,7 +35152,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.103",
"version": "0.1.104-beta.4",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -36150,12 +36170,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.103",
"version": "0.1.104-beta.4",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.1.103",
"@getpaseo/protocol": "0.1.103",
"@getpaseo/server": "0.1.103",
"@getpaseo/client": "0.1.104-beta.4",
"@getpaseo/protocol": "0.1.104-beta.4",
"@getpaseo/server": "0.1.104-beta.4",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -36401,10 +36421,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.1.103",
"version": "0.1.104-beta.4",
"dependencies": {
"@getpaseo/protocol": "0.1.103",
"@getpaseo/relay": "0.1.103",
"@getpaseo/protocol": "0.1.104-beta.4",
"@getpaseo/relay": "0.1.104-beta.4",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -36415,7 +36435,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.103",
"version": "0.1.104-beta.4",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "*",
@@ -36658,7 +36678,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.103",
"version": "0.1.104-beta.4",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -37554,7 +37574,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.103",
"version": "0.1.104-beta.4",
"dependencies": {
"@codemirror/language": "^6.12.3",
"@codemirror/legacy-modes": "^6.5.3",
@@ -37786,19 +37806,20 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.1.103",
"version": "0.1.104-beta.4",
"dependencies": {
"zod": "^4.4.3"
},
"devDependencies": {
"@types/node": "^20.9.0",
"typescript": "^5.2.2",
"vitest": "^4.1.6"
"vitest": "^4.1.6",
"zod-aot": "0.20.4"
}
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.103",
"version": "0.1.104-beta.4",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -38016,15 +38037,15 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.103",
"version": "0.1.104-beta.4",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.195",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.1.103",
"@getpaseo/highlight": "0.1.103",
"@getpaseo/protocol": "0.1.103",
"@getpaseo/relay": "0.1.103",
"@getpaseo/client": "0.1.104-beta.4",
"@getpaseo/highlight": "0.1.104-beta.4",
"@getpaseo/protocol": "0.1.104-beta.4",
"@getpaseo/relay": "0.1.104-beta.4",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
@@ -38561,7 +38582,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.103",
"version": "0.1.104-beta.4",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",

View File

@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.103",
"version": "0.1.104-beta.4",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -58,7 +58,7 @@
"build:daemon-web-ui": "node scripts/build-daemon-web-ui.mjs",
"build:app-deps": "npm run build:highlight && npm run build:client && npm run build --workspace=@getpaseo/expo-two-way-audio",
"build:app-deps:clean": "npm run build:highlight:clean && npm run build:client:clean && npm run build --workspace=@getpaseo/expo-two-way-audio",
"watch:protocol": "tsc -p packages/protocol/tsconfig.json --watch --preserveWatchOutput",
"watch:protocol": "npm run watch --workspace=@getpaseo/protocol",
"watch:client": "tsc -p packages/client/tsconfig.json --watch --preserveWatchOutput",
"typecheck": "npm run typecheck --workspaces --if-present",
"typecheck:server": "npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/protocol && npm run typecheck --workspace=@getpaseo/client && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.1.103",
"version": "0.1.104-beta.4",
"private": true,
"main": "index.ts",
"scripts": {

View File

@@ -9,7 +9,7 @@ import {
} from "lucide-react-native";
import { withUnistyles } from "react-native-unistyles";
import type { AgentAttachment } from "@getpaseo/protocol/messages";
import type { BrowserAnnotationIntent, WorkspaceComposerAttachment } from "@/attachments/types";
import type { WorkspaceComposerAttachment } from "@/attachments/types";
import { getFileTypeLabel } from "@/attachments/file-types";
import { isPullRequestContextAttachment } from "@/attachments/workspace-attachment-utils";
import { ICON_SIZE, type Theme } from "@/styles/theme";
@@ -36,13 +36,6 @@ function getPullRequestContextSubtitle(attachment: WorkspaceComposerAttachment):
return "Review";
}
const BROWSER_INTENT_LABEL_KEYS: Record<BrowserAnnotationIntent, string> = {
fix: "workspace.browser.annotate.intents.fix",
change: "workspace.browser.annotate.intents.change",
question: "workspace.browser.annotate.intents.question",
approve: "workspace.browser.annotate.intents.approve",
};
function getTextAttachmentSubtitle(
attachment: Extract<AgentAttachment, { type: "text" }>,
t: TFunction,
@@ -96,11 +89,10 @@ export function getWorkspaceAttachmentPillContent(
t: TFunction,
): AttachmentPillContent {
if (attachment.kind === "browser_element") {
const intent = attachment.attachment.intent;
return {
icon: attachmentBrowserIcon,
title: attachment.attachment.tag,
subtitle: intent ? t(BROWSER_INTENT_LABEL_KEYS[intent]) : t("composer.attachments.element"),
subtitle: t("composer.attachments.element"),
};
}
if (isPullRequestContextAttachment(attachment)) {

View File

@@ -21,12 +21,6 @@ export interface AttachmentMetadata {
createdAt: number;
}
/**
* The kind of review feedback the user is attaching to a selected browser
* element, sent to the agent alongside the element context.
*/
export type BrowserAnnotationIntent = "fix" | "change" | "question" | "approve";
export interface BrowserElementAttachment {
url: string;
selector: string;
@@ -50,8 +44,6 @@ export interface BrowserElementAttachment {
children: string[];
/** Free-text review note the user wrote about this element, if any. */
comment?: string;
/** What the user wants the agent to do with this element, if annotated. */
intent?: BrowserAnnotationIntent;
/**
* Cropped screenshot of the selected element, sent to the agent as an image
* alongside the textual element context. Persisted via the attachment store;

View File

@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { beforeEach, describe, expect, test } from "vitest";
import type { SessionInboundMessage, SessionOutboundMessage } from "@getpaseo/protocol/messages";
import { createJSONStorage, type StateStorage } from "zustand/middleware";
import { mountBrowserAutomationHandler } from "./handler";
import type { DesktopHostBridge } from "@/desktop/host";
import { useBrowserStore } from "@/stores/browser-store";
@@ -8,20 +9,6 @@ import {
useWorkspaceLayoutStore,
} from "@/stores/workspace-layout-store";
vi.mock("expo-router", () => ({
router: {
navigate: vi.fn(),
},
}));
vi.mock("@react-native-async-storage/async-storage", () => ({
default: {
getItem: vi.fn(async () => null),
setItem: vi.fn(async () => undefined),
removeItem: vi.fn(async () => undefined),
},
}));
type BrowserAutomationExecuteRequest = Extract<
SessionOutboundMessage,
{ type: "browser.automation.execute.request" }
@@ -30,9 +17,28 @@ type BrowserAutomationExecuteResponse = Extract<
SessionInboundMessage,
{ type: "browser.automation.execute.response" }
>;
type BrowserAutomationResponsePayload = BrowserAutomationExecuteResponse["payload"];
class FakeStateStorage implements StateStorage {
private readonly values = new Map<string, string>();
public getItem = (key: string): string | null => this.values.get(key) ?? null;
public setItem = (key: string, value: string): void => {
this.values.set(key, value);
};
public removeItem = (key: string): void => {
this.values.delete(key);
};
public clear(): void {
this.values.clear();
}
}
class FakeDaemonClient {
public sentResponses: BrowserAutomationExecuteResponse[] = [];
public readonly sentResponses: BrowserAutomationExecuteResponse[] = [];
private handler: ((request: BrowserAutomationExecuteRequest) => void) | null = null;
public on(
@@ -55,6 +61,107 @@ class FakeDaemonClient {
public receive(nextRequest: BrowserAutomationExecuteRequest): void {
this.handler?.(nextRequest);
}
public payloadAt(index: number): BrowserAutomationResponsePayload {
const response = this.sentResponses[index];
if (!response) {
throw new Error(`Missing browser automation response at index ${index}`);
}
return response.payload;
}
}
class FakeBrowserBridge {
public readonly executedRequests: BrowserAutomationExecuteRequest[] = [];
public readonly registeredWorkspaceBrowsers: Array<{ browserId: string; workspaceId: string }> =
[];
public readonly unregisteredWorkspaceBrowsers: string[] = [];
public readonly clearedPartitions: string[] = [];
public readonly activeWorkspaceBrowsers: Array<{
browserId: string | null;
workspaceId: string;
}> = [];
public response: BrowserAutomationResponsePayload | null = null;
public thrownError: unknown = null;
public executeAutomationCommand = async (
request: BrowserAutomationExecuteRequest,
): Promise<BrowserAutomationResponsePayload> => {
this.executedRequests.push(request);
if (this.thrownError) {
throw this.thrownError;
}
return this.response ?? currentListTabsPayload(request.requestId);
};
public registerWorkspaceBrowser = async (input: {
browserId: string;
workspaceId: string;
}): Promise<void> => {
this.registeredWorkspaceBrowsers.push(input);
};
public unregisterWorkspaceBrowser = async (browserId: string): Promise<void> => {
this.unregisteredWorkspaceBrowsers.push(browserId);
};
public clearPartition = async (browserId: string): Promise<void> => {
this.clearedPartitions.push(browserId);
};
public setWorkspaceActiveBrowser = async (input: {
browserId: string | null;
workspaceId: string;
}): Promise<void> => {
this.activeWorkspaceBrowsers.push(input);
};
}
class FakeResidentBrowser {
public readonly ensuredWebviews: Array<{ browserId: string; url: string }> = [];
public ensure = (input: { browserId: string; url: string }): HTMLElement | null => {
this.ensuredWebviews.push(input);
return null;
};
}
class BrowserAutomationHandlerHarness {
public readonly client = new FakeDaemonClient();
public readonly browser = new FakeBrowserBridge();
public readonly resident = new FakeResidentBrowser();
private unsubscribe: (() => void) | null = null;
public mount(
input: {
serverId?: string;
host?: DesktopHostBridge | null;
registrationWaitTimeoutMs?: number;
registrationPollIntervalMs?: number;
} = {},
): void {
this.unsubscribe = mountBrowserAutomationHandler({
client: this.client,
...(input.serverId ? { serverId: input.serverId } : {}),
getHost: () => (input.host === undefined ? { browser: this.browser } : input.host),
ensureResidentBrowserWebview: this.resident.ensure,
...(input.registrationWaitTimeoutMs !== undefined
? { registrationWaitTimeoutMs: input.registrationWaitTimeoutMs }
: {}),
...(input.registrationPollIntervalMs !== undefined
? { registrationPollIntervalMs: input.registrationPollIntervalMs }
: {}),
});
}
public unmount(): void {
this.unsubscribe?.();
this.unsubscribe = null;
}
public receive(request: BrowserAutomationExecuteRequest): void {
this.client.receive(request);
}
}
function browserAutomationRequest(): BrowserAutomationExecuteRequest {
@@ -70,45 +177,69 @@ function browserNewTabRequest(): BrowserAutomationExecuteRequest {
type: "browser.automation.execute.request",
requestId: "req-new",
agentId: "agent-1",
workspaceId: "/repo",
command: { command: "new_tab", args: { workspaceId: "/repo", url: "https://example.com" } },
workspaceId: "wks_workspace_a",
command: {
command: "new_tab",
args: { url: "https://example.com" },
},
};
}
function emptyListTabsPayload(requestId = "req-new:list_tabs") {
function browserResizeRequest(
browserId: string,
input: { workspaceId?: string } = {},
): BrowserAutomationExecuteRequest {
return {
type: "browser.automation.execute.request",
requestId: "req-resize",
agentId: "agent-1",
workspaceId: input.workspaceId ?? "wks_workspace_a",
command: {
command: "resize",
args: { browserId, width: 1024, height: 768 },
},
};
}
function browserCloseTabRequest(browserId: string): BrowserAutomationExecuteRequest {
return {
type: "browser.automation.execute.request",
requestId: "req-close-tab",
agentId: "agent-1",
workspaceId: "wks_workspace_a",
command: {
command: "close_tab",
args: { browserId },
},
};
}
function emptyListTabsPayload(requestId = "req-new:list_tabs"): BrowserAutomationResponsePayload {
return {
requestId,
ok: true as const,
ok: true,
result: {
command: "list_tabs" as const,
command: "list_tabs",
tabs: [],
},
};
}
function currentListTabsPayload(requestId = "req-new:list_tabs") {
function currentListTabsPayload(requestId = "req-new:list_tabs"): BrowserAutomationResponsePayload {
return {
requestId,
ok: true as const,
ok: true,
result: {
command: "list_tabs" as const,
command: "list_tabs",
tabs: currentBrowserTabs(),
},
};
}
function flushPromises(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
function waitForAsyncWork(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 20));
}
function currentBrowserTabs() {
return Object.values(useBrowserStore.getState().browsersById).map((browser) => ({
browserId: browser.browserId,
workspaceId: "/repo",
workspaceId: "wks_workspace_a",
url: browser.url,
title: browser.title,
isActive: true,
@@ -116,151 +247,268 @@ function currentBrowserTabs() {
}));
}
function newTabResultFrom(payload: BrowserAutomationResponsePayload) {
expect(payload).toMatchObject({
requestId: "req-new",
ok: true,
result: { command: "new_tab", workspaceId: "wks_workspace_a", url: "https://example.com" },
});
if (!payload.ok || payload.result.command !== "new_tab") {
throw new Error("Expected browser_new_tab success payload");
}
return payload.result;
}
function workspaceBrowserTabs(workspaceKey: string, browserId: string) {
return useWorkspaceLayoutStore
.getState()
.getWorkspaceTabs(workspaceKey)
.filter((tab) => tab.target.kind === "browser" && tab.target.browserId === browserId);
}
function flushAsyncWork(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
function waitForRegistrationTimeout(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 20));
}
const browserAutomationStorage = new FakeStateStorage();
useBrowserStore.persist.setOptions({
storage: createJSONStorage(() => browserAutomationStorage),
});
useWorkspaceLayoutStore.persist.setOptions({
storage: createJSONStorage(() => browserAutomationStorage),
});
describe("mountBrowserAutomationHandler", () => {
beforeEach(() => {
browserAutomationStorage.clear();
useBrowserStore.setState({ browsersById: {} });
useWorkspaceLayoutStore.setState({ layoutByWorkspace: {} });
});
test("creates an unfocused workspace browser tab for browser_new_tab", async () => {
const client = new FakeDaemonClient();
const setWorkspaceActiveBrowser = vi.fn(async () => undefined);
const setAgentActiveBrowser = vi.fn(async () => undefined);
const registerWorkspaceBrowser = vi.fn(async () => undefined);
const ensureResidentBrowserWebview = vi.fn();
const executeAutomationCommand = vi.fn(async () => currentListTabsPayload());
test("browser_new_tab creates a workspace browser tab without stealing focus", async () => {
const browser = new BrowserAutomationHandlerHarness();
const workspaceKey = buildWorkspaceTabPersistenceKey({
serverId: "server-1",
workspaceId: "/repo",
workspaceId: "wks_workspace_a",
});
if (!workspaceKey) throw new Error("expected workspace key");
const focusedTabId = useWorkspaceLayoutStore
if (!workspaceKey) {
throw new Error("Expected workspace key");
}
const previousFocusedTabId = useWorkspaceLayoutStore
.getState()
.openTabFocused(workspaceKey, { kind: "draft", draftId: "human-draft" });
if (!focusedTabId) throw new Error("expected focused tab");
mountBrowserAutomationHandler({
client,
serverId: "server-1",
getHost: () =>
({
browser: {
executeAutomationCommand,
registerWorkspaceBrowser,
setWorkspaceActiveBrowser,
setAgentActiveBrowser,
},
}) satisfies DesktopHostBridge,
ensureResidentBrowserWebview,
});
browser.mount({ serverId: "server-1" });
client.receive(browserNewTabRequest());
await flushPromises();
browser.receive(browserNewTabRequest());
await flushAsyncWork();
const payload = client.sentResponses[0]?.payload;
expect(payload?.ok).toBe(true);
if (!payload?.ok) throw new Error("expected success");
expect(payload.result.command).toBe("new_tab");
if (payload.result.command !== "new_tab") throw new Error("expected new_tab result");
expect(payload.result.url).toBe("https://example.com");
expect(useWorkspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toContainEqual(
expect.objectContaining({ target: { kind: "browser", browserId: payload.result.browserId } }),
);
const result = newTabResultFrom(browser.client.payloadAt(0));
const openedTabs = workspaceBrowserTabs(workspaceKey, result.browserId);
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(layout?.root.kind).toBe("pane");
if (layout?.root.kind !== "pane") throw new Error("expected root pane");
expect(layout.root.pane.focusedTabId).toBe(focusedTabId);
expect(registerWorkspaceBrowser).toHaveBeenCalledWith({
browserId: payload.result.browserId,
workspaceId: "/repo",
});
expect(setAgentActiveBrowser).toHaveBeenCalledWith({
agentId: "agent-1",
browserId: payload.result.browserId,
});
expect(setWorkspaceActiveBrowser).not.toHaveBeenCalled();
expect(ensureResidentBrowserWebview).toHaveBeenCalledWith({
browserId: payload.result.browserId,
url: "https://example.com",
});
expect(executeAutomationCommand).toHaveBeenCalledTimes(1);
expect(openedTabs).toEqual([
expect.objectContaining({
target: { kind: "browser", browserId: result.browserId },
}),
]);
expect(layout?.root).toEqual(
expect.objectContaining({
kind: "pane",
pane: expect.objectContaining({ focusedTabId: previousFocusedTabId }),
}),
);
expect(openedTabs[0]?.tabId).not.toBe(previousFocusedTabId);
expect(browser.browser.registeredWorkspaceBrowsers).toEqual([
{ browserId: result.browserId, workspaceId: "wks_workspace_a" },
]);
expect(browser.browser.activeWorkspaceBrowsers).toEqual([]);
expect(browser.resident.ensuredWebviews).toEqual([
{ browserId: result.browserId, url: "https://example.com" },
]);
expect(browser.browser.executedRequests).toEqual([
{
type: "browser.automation.execute.request",
requestId: "req-new:list_tabs",
agentId: "agent-1",
workspaceId: "wks_workspace_a",
command: { command: "list_tabs", args: {} },
},
]);
});
test("returns browser_timeout when the resident webview does not register", async () => {
const client = new FakeDaemonClient();
const ensureResidentBrowserWebview = vi.fn();
const executeAutomationCommand = vi.fn(async () => emptyListTabsPayload());
mountBrowserAutomationHandler({
client,
test("browser_new_tab returns a retryable timeout when the resident webview does not register", async () => {
const browser = new BrowserAutomationHandlerHarness();
browser.browser.response = emptyListTabsPayload();
browser.mount({
serverId: "server-1",
getHost: () => ({ browser: { executeAutomationCommand } }) satisfies DesktopHostBridge,
ensureResidentBrowserWebview,
registrationWaitTimeoutMs: 1,
registrationPollIntervalMs: 1,
});
client.receive(browserNewTabRequest());
await waitForAsyncWork();
browser.receive(browserNewTabRequest());
await waitForRegistrationTimeout();
expect(client.sentResponses[0]?.payload).toMatchObject({
requestId: "req-new",
ok: false,
error: {
code: "browser_timeout",
retryable: true,
expect(browser.client.sentResponses).toEqual([
{
type: "browser.automation.execute.response",
payload: {
requestId: "req-new",
ok: false,
error: {
code: "browser_timeout",
message: expect.stringContaining("Timed out waiting for browser tab"),
retryable: true,
},
},
},
});
expect(ensureResidentBrowserWebview).toHaveBeenCalledWith(
]);
expect(browser.resident.ensuredWebviews).toEqual([
expect.objectContaining({ url: "https://example.com" }),
);
expect(client.sentResponses[0]?.payload).not.toMatchObject({
ok: true,
result: { command: "new_tab" },
});
]);
});
test("wraps browser_new_tab registration bridge errors in a response", async () => {
const client = new FakeDaemonClient();
const executeAutomationCommand = vi.fn(async () => {
throw new Error("IPC registration check failed");
});
mountBrowserAutomationHandler({
client,
serverId: "server-1",
getHost: () => ({ browser: { executeAutomationCommand } }) satisfies DesktopHostBridge,
});
test("browser_new_tab wraps registration bridge errors in a response", async () => {
const browser = new BrowserAutomationHandlerHarness();
browser.browser.thrownError = new Error("IPC registration check failed");
browser.mount({ serverId: "server-1" });
client.receive(browserNewTabRequest());
await flushPromises();
browser.receive(browserNewTabRequest());
await flushAsyncWork();
expect(client.sentResponses[0]?.payload).toEqual({
requestId: "req-new",
expect(browser.client.sentResponses).toEqual([
{
type: "browser.automation.execute.response",
payload: {
requestId: "req-new",
ok: false,
error: {
code: "browser_unknown_error",
message: "IPC registration check failed",
retryable: false,
},
},
},
]);
});
test("browser_resize updates resident webview dimensions", async () => {
const browser = new BrowserAutomationHandlerHarness();
browser.mount({ serverId: "server-1" });
browser.receive(browserNewTabRequest());
await flushAsyncWork();
const result = newTabResultFrom(browser.client.payloadAt(0));
browser.receive(browserResizeRequest(result.browserId));
await flushAsyncWork();
expect(browser.client.payloadAt(1)).toEqual({
requestId: "req-resize",
ok: true,
result: {
command: "resize",
browserId: result.browserId,
width: 1024,
height: 768,
},
});
expect(browser.browser.executedRequests).toHaveLength(1);
});
test("browser_resize returns not found for a tab outside the request workspace", async () => {
const browser = new BrowserAutomationHandlerHarness();
browser.mount({ serverId: "server-1" });
browser.receive(browserNewTabRequest());
await flushAsyncWork();
const result = newTabResultFrom(browser.client.payloadAt(0));
browser.receive(browserResizeRequest(result.browserId, { workspaceId: "wks_workspace_b" }));
await flushAsyncWork();
expect(browser.client.payloadAt(1)).toEqual({
requestId: "req-resize",
ok: false,
error: {
code: "browser_unknown_error",
message: "IPC registration check failed",
code: "browser_tab_not_found",
message: `No browser tab found for ID: ${result.browserId}`,
retryable: false,
},
});
});
test("sends a success response from the desktop bridge", async () => {
const client = new FakeDaemonClient();
const executeAutomationCommand = vi.fn(async () => ({
requestId: "req-1",
ok: true as const,
result: { command: "list_tabs" as const, tabs: [] },
}));
mountBrowserAutomationHandler({
client,
getHost: () => ({ browser: { executeAutomationCommand } }) satisfies DesktopHostBridge,
test("browser_close_tab removes the workspace tab, browser record, resident webview, registry entry, and partition", async () => {
const browser = new BrowserAutomationHandlerHarness();
const workspaceKey = buildWorkspaceTabPersistenceKey({
serverId: "server-1",
workspaceId: "wks_workspace_a",
});
if (!workspaceKey) {
throw new Error("Expected workspace key");
}
browser.mount({ serverId: "server-1" });
client.receive(browserAutomationRequest());
await flushPromises();
browser.receive(browserNewTabRequest());
await flushAsyncWork();
const result = newTabResultFrom(browser.client.payloadAt(0));
expect(executeAutomationCommand).toHaveBeenCalledWith(browserAutomationRequest());
expect(client.sentResponses).toEqual([
browser.receive(browserCloseTabRequest(result.browserId));
await flushAsyncWork();
expect(browser.client.payloadAt(1)).toEqual({
requestId: "req-close-tab",
ok: true,
result: { command: "close_tab", browserId: result.browserId },
});
expect(workspaceBrowserTabs(workspaceKey, result.browserId)).toEqual([]);
expect(useBrowserStore.getState().browsersById[result.browserId]).toBeUndefined();
expect(browser.browser.unregisteredWorkspaceBrowsers).toEqual([result.browserId]);
expect(browser.browser.clearedPartitions).toEqual([result.browserId]);
expect(currentBrowserTabs()).toEqual([]);
});
test("browser_close_tab returns not found after the tab is gone", async () => {
const browser = new BrowserAutomationHandlerHarness();
browser.mount({ serverId: "server-1" });
browser.receive(browserNewTabRequest());
await flushAsyncWork();
const result = newTabResultFrom(browser.client.payloadAt(0));
browser.receive(browserCloseTabRequest(result.browserId));
await flushAsyncWork();
browser.receive(browserCloseTabRequest(result.browserId));
await flushAsyncWork();
expect(browser.client.payloadAt(2)).toEqual({
requestId: "req-close-tab",
ok: false,
error: {
code: "browser_tab_not_found",
message: `No browser tab found for ID: ${result.browserId}`,
retryable: false,
},
});
});
test("non-new-tab requests send the desktop bridge response", async () => {
const browser = new BrowserAutomationHandlerHarness();
browser.browser.response = {
requestId: "desktop-req",
ok: true,
result: { command: "list_tabs", tabs: [] },
};
browser.mount();
const request = browserAutomationRequest();
browser.receive(request);
await flushAsyncWork();
expect(browser.browser.executedRequests).toEqual([request]);
expect(browser.client.sentResponses).toEqual([
{
type: "browser.automation.execute.response",
payload: {
@@ -272,14 +520,14 @@ describe("mountBrowserAutomationHandler", () => {
]);
});
test("missing bridge sends browser_unsupported", async () => {
const client = new FakeDaemonClient();
mountBrowserAutomationHandler({ client, getHost: () => null });
test("missing desktop bridge sends browser_unsupported", async () => {
const browser = new BrowserAutomationHandlerHarness();
browser.mount({ host: null });
client.receive(browserAutomationRequest());
await flushPromises();
browser.receive(browserAutomationRequest());
await flushAsyncWork();
expect(client.sentResponses).toEqual([
expect(browser.client.sentResponses).toEqual([
{
type: "browser.automation.execute.response",
payload: {
@@ -287,7 +535,7 @@ describe("mountBrowserAutomationHandler", () => {
ok: false,
error: {
code: "browser_unsupported",
message: "Desktop browser automation is not available in this app runtime.",
message: "Browser automation is not available in this app runtime.",
retryable: false,
},
},
@@ -296,80 +544,68 @@ describe("mountBrowserAutomationHandler", () => {
});
test("typed bridge errors become failure responses", async () => {
const client = new FakeDaemonClient();
mountBrowserAutomationHandler({
client,
getHost: () => ({
browser: {
executeAutomationCommand: async () => {
throw {
code: "browser_tab_not_found",
message: "Browser tab browser-1 was not found.",
retryable: false,
};
const browser = new BrowserAutomationHandlerHarness();
browser.browser.thrownError = {
code: "browser_tab_not_found",
message: "Browser tab browser-1 was not found.",
retryable: false,
};
browser.mount();
browser.receive(browserAutomationRequest());
await flushAsyncWork();
expect(browser.client.sentResponses).toEqual([
{
type: "browser.automation.execute.response",
payload: {
requestId: "req-1",
ok: false,
error: {
code: "browser_tab_not_found",
message: "Browser tab browser-1 was not found.",
retryable: false,
},
},
}),
});
client.receive(browserAutomationRequest());
await flushPromises();
expect(client.sentResponses[0]?.payload).toEqual({
requestId: "req-1",
ok: false,
error: {
code: "browser_tab_not_found",
message: "Browser tab browser-1 was not found.",
retryable: false,
},
});
]);
});
test("unimplemented preload IPC reports browser_unsupported", async () => {
const client = new FakeDaemonClient();
mountBrowserAutomationHandler({
client,
getHost: () => ({
browser: {
executeAutomationCommand: async () => {
throw new Error('No handler registered for "paseo:browser:execute-automation-command"');
const browser = new BrowserAutomationHandlerHarness();
browser.browser.thrownError = new Error(
'No handler registered for "paseo:browser:execute-automation-command"',
);
browser.mount();
browser.receive(browserAutomationRequest());
await flushAsyncWork();
expect(browser.client.sentResponses).toEqual([
{
type: "browser.automation.execute.response",
payload: {
requestId: "req-1",
ok: false,
error: {
code: "browser_unsupported",
message: "Browser automation is not implemented by this app build yet.",
retryable: false,
},
},
}),
});
client.receive(browserAutomationRequest());
await flushPromises();
expect(client.sentResponses[0]?.payload).toEqual({
requestId: "req-1",
ok: false,
error: {
code: "browser_unsupported",
message: "Desktop browser automation is not implemented by this desktop build yet.",
retryable: false,
},
});
]);
});
test("unsubscribe stops handling requests", async () => {
const client = new FakeDaemonClient();
const executeAutomationCommand = vi.fn(async () => ({
requestId: "req-1",
ok: true as const,
result: { command: "list_tabs" as const, tabs: [] },
}));
const unsubscribe = mountBrowserAutomationHandler({
client,
getHost: () => ({ browser: { executeAutomationCommand } }),
});
test("unsubscribe stops handling browser automation requests", async () => {
const browser = new BrowserAutomationHandlerHarness();
browser.mount();
unsubscribe();
client.receive(browserAutomationRequest());
await flushPromises();
browser.unmount();
browser.receive(browserAutomationRequest());
await flushAsyncWork();
expect(executeAutomationCommand).not.toHaveBeenCalled();
expect(client.sentResponses).toEqual([]);
expect(browser.browser.executedRequests).toEqual([]);
expect(browser.client.sentResponses).toEqual([]);
});
});

View File

@@ -1,9 +1,14 @@
import type { SessionInboundMessage, SessionOutboundMessage } from "@getpaseo/protocol/messages";
import { getDesktopHost, type DesktopHostBridge } from "@/desktop/host";
import { ensureResidentBrowserWebview as ensureResidentBrowserWebviewDefault } from "@/components/browser-webview-resident";
import { createWorkspaceBrowser } from "@/stores/browser-store";
import {
ensureResidentBrowserWebview as ensureResidentBrowserWebviewDefault,
removeResidentBrowserWebview,
resizeResidentBrowserWebview,
} from "@/components/browser-webview-resident";
import { createWorkspaceBrowser, getBrowserRecord, useBrowserStore } from "@/stores/browser-store";
import {
buildWorkspaceTabPersistenceKey,
collectAllTabs,
useWorkspaceLayoutStore,
} from "@/stores/workspace-layout-store";
@@ -40,7 +45,7 @@ export function mountBrowserAutomationHandler(
options: BrowserAutomationHandlerOptions,
): () => void {
const getHost = options.getHost ?? getDesktopHost;
return options.client.on("browser.automation.execute.request", (request) => {
const unsubscribe = options.client.on("browser.automation.execute.request", (request) => {
void handleBrowserAutomationRequest({
client: options.client,
getHost,
@@ -56,6 +61,9 @@ export function mountBrowserAutomationHandler(
: {}),
});
});
return () => {
unsubscribe();
};
}
export function mountBrowserAutomationDaemonClientHandler(
@@ -111,7 +119,32 @@ async function handleBrowserAutomationRequest(params: {
return;
}
await rememberAgentBrowserTarget({ request, browserHost });
if (request.command.command === "resize") {
client.sendBrowserAutomationExecuteResponse({
type: "browser.automation.execute.response",
payload: resizeBrowserTabForRequest({ request, serverId }),
});
return;
}
if (request.command.command === "close_tab") {
try {
client.sendBrowserAutomationExecuteResponse({
type: "browser.automation.execute.response",
payload: await closeBrowserTabForRequest({
request,
serverId,
browserHost,
}),
});
} catch (error) {
client.sendBrowserAutomationExecuteResponse({
type: "browser.automation.execute.response",
payload: normalizeThrownBridgeError(request.requestId, error),
});
}
return;
}
if (!executeAutomationCommand) {
client.sendBrowserAutomationExecuteResponse({
@@ -119,7 +152,7 @@ async function handleBrowserAutomationRequest(params: {
payload: browserAutomationFailure({
requestId: request.requestId,
code: "browser_unsupported",
message: "Desktop browser automation is not available in this app runtime.",
message: "Browser automation is not available in this app runtime.",
}),
});
return;
@@ -139,6 +172,127 @@ async function handleBrowserAutomationRequest(params: {
}
}
function resizeBrowserTabForRequest(params: {
request: BrowserAutomationExecuteRequest;
serverId?: string;
}): BrowserAutomationResponsePayload {
const { request, serverId } = params;
const command = request.command as Extract<
BrowserAutomationExecuteRequest["command"],
{ command: "resize" }
>;
const browserId = command.args.browserId;
if (!getBrowserRecord(browserId)) {
return browserAutomationFailure({
requestId: request.requestId,
code: "browser_tab_not_found",
message: `No browser tab found for ID: ${browserId}`,
});
}
const workspaceId = request.workspaceId;
if (serverId && workspaceId && !findWorkspaceBrowserTab({ serverId, workspaceId, browserId })) {
return browserAutomationFailure({
requestId: request.requestId,
code: "browser_tab_not_found",
message: `No browser tab found for ID: ${browserId}`,
});
}
const dimensions = resizeResidentBrowserWebview({
browserId,
width: command.args.width,
height: command.args.height,
});
if (!dimensions) {
return browserAutomationFailure({
requestId: request.requestId,
code: "browser_tab_not_found",
message: `No browser tab found for ID: ${browserId}`,
});
}
return {
requestId: request.requestId,
ok: true,
result: {
command: "resize",
browserId,
width: dimensions.width,
height: dimensions.height,
},
};
}
async function closeBrowserTabForRequest(params: {
request: BrowserAutomationExecuteRequest;
serverId?: string;
browserHost: DesktopHostBridge["browser"] | undefined;
}): Promise<BrowserAutomationResponsePayload> {
const { request, serverId, browserHost } = params;
const command = request.command as Extract<
BrowserAutomationExecuteRequest["command"],
{ command: "close_tab" }
>;
const browserId = command.args.browserId;
const workspaceId = request.workspaceId;
const workspaceTab = serverId
? findWorkspaceBrowserTab({ serverId, workspaceId, browserId })
: null;
if (!workspaceTab && (!serverId || !workspaceId)) {
return browserAutomationFailure({
requestId: request.requestId,
code: "browser_unsupported",
message: "Cannot close a browser tab without a workspace context.",
});
}
if (!workspaceTab || !getBrowserRecord(browserId)) {
return browserAutomationFailure({
requestId: request.requestId,
code: "browser_tab_not_found",
message: `No browser tab found for ID: ${browserId}`,
});
}
useWorkspaceLayoutStore.getState().closeTab(workspaceTab.workspaceKey, workspaceTab.tabId);
useBrowserStore.getState().removeBrowser(browserId);
removeResidentBrowserWebview(browserId);
await browserHost?.unregisterWorkspaceBrowser?.(browserId);
await browserHost?.clearPartition?.(browserId);
return {
requestId: request.requestId,
ok: true,
result: { command: "close_tab", browserId },
};
}
function findWorkspaceBrowserTab(input: {
serverId: string;
workspaceId: string | undefined;
browserId: string;
}): { workspaceKey: string; tabId: string } | null {
if (!input.workspaceId) {
return null;
}
const workspaceKey = buildWorkspaceTabPersistenceKey({
serverId: input.serverId,
workspaceId: input.workspaceId,
});
if (!workspaceKey) {
return null;
}
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const tab = layout
? collectAllTabs(layout.root).find((candidate) => {
return (
candidate.target.kind === "browser" && candidate.target.browserId === input.browserId
);
})
: null;
return tab ? { workspaceKey, tabId: tab.tabId } : null;
}
async function openBrowserTabForRequest(params: {
request: BrowserAutomationExecuteRequest;
serverId?: string;
@@ -159,11 +313,11 @@ async function openBrowserTabForRequest(params: {
BrowserAutomationExecuteRequest["command"],
{ command: "new_tab" }
>;
const workspaceId = request.workspaceId ?? command.args.workspaceId;
const workspaceId = request.workspaceId;
if (!serverId || !workspaceId) {
return browserAutomationFailure({
requestId: request.requestId,
code: "browser_no_tab",
code: "browser_unsupported",
message: "Cannot create a browser tab without a workspace context.",
});
}
@@ -174,7 +328,7 @@ async function openBrowserTabForRequest(params: {
if (!workspaceKey) {
return browserAutomationFailure({
requestId: request.requestId,
code: "browser_no_tab",
code: "browser_unsupported",
message: "Cannot create a browser tab without a workspace context.",
});
}
@@ -184,9 +338,6 @@ async function openBrowserTabForRequest(params: {
});
await browserHost?.registerWorkspaceBrowser?.({ browserId, workspaceId });
if (request.agentId) {
await browserHost?.setAgentActiveBrowser?.({ agentId: request.agentId, browserId });
}
if (browserHost?.executeAutomationCommand) {
ensureResidentBrowserWebview({ browserId, url: normalizedUrl });
@@ -204,7 +355,7 @@ async function openBrowserTabForRequest(params: {
return browserAutomationFailure({
requestId: request.requestId,
code: "browser_timeout",
message: `Timed out waiting for browser tab ${browserId} to register with desktop automation. Try browser_new_tab again.`,
message: `Timed out waiting for browser tab ${browserId} to register with the browser automation host. Try browser_new_tab again.`,
retryable: true,
});
}
@@ -235,7 +386,7 @@ async function waitForBrowserRegistration(params: {
agentId: params.request.agentId,
cwd: params.request.cwd,
workspaceId: params.workspaceId,
command: { command: "list_tabs", args: { workspaceId: params.workspaceId } },
command: { command: "list_tabs", args: {} },
});
if (payload.ok && payload.result.command === "list_tabs") {
if (payload.result.tabs.some((tab) => tab.browserId === params.browserId)) {
@@ -251,28 +402,6 @@ function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function rememberAgentBrowserTarget(input: {
request: BrowserAutomationExecuteRequest;
browserHost: DesktopHostBridge["browser"] | undefined;
}): Promise<void> {
if (!input.request.agentId) {
return;
}
const browserId = readRequestBrowserId(input.request);
if (!browserId) {
return;
}
await input.browserHost?.setAgentActiveBrowser?.({ agentId: input.request.agentId, browserId });
}
function readRequestBrowserId(request: BrowserAutomationExecuteRequest): string | null {
if (request.browserId) {
return request.browserId;
}
const args = request.command.args as { browserId?: unknown };
return typeof args.browserId === "string" && args.browserId.length > 0 ? args.browserId : null;
}
function normalizeBridgePayload(
requestId: string,
payload: BrowserAutomationResponsePayload,
@@ -294,14 +423,14 @@ function normalizeThrownBridgeError(
return browserAutomationFailure({
requestId,
code: "browser_unsupported",
message: "Desktop browser automation is not implemented by this desktop build yet.",
message: "Browser automation is not implemented by this app build yet.",
});
}
return browserAutomationFailure({
requestId,
code: "browser_unknown_error",
message: message || "Desktop browser automation failed.",
message: message || "Browser automation failed.",
});
}

View File

@@ -12,21 +12,22 @@ import { Pressable, Text, TextInput, View, type StyleProp, type ViewStyle } from
import {
ArrowLeft,
ArrowRight,
Camera,
ChevronDown,
Copy,
Maximize,
Monitor,
MousePointer2,
PencilRuler,
RotateCw,
Smartphone,
Tablet,
Wrench,
X,
type LucideIcon,
} from "lucide-react-native";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import * as Clipboard from "expo-clipboard";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useToast } from "@/contexts/toast-context";
import {
@@ -40,11 +41,7 @@ import {
useWorkspaceAttachments,
useWorkspaceAttachmentsStore,
} from "@/attachments/workspace-attachments-store";
import type {
AttachmentMetadata,
BrowserAnnotationIntent,
BrowserElementAttachment,
} from "@/attachments/types";
import type { AttachmentMetadata, BrowserElementAttachment } from "@/attachments/types";
import { persistAttachmentFromDataUrl } from "@/attachments/service";
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import {
@@ -52,7 +49,6 @@ import {
isElectronRuntime,
type DesktopBrowserShortcutEvent,
} from "@/desktop/host";
import { isDev } from "@/constants/platform";
import { useBrowserStore, normalizeWorkspaceBrowserUrl } from "@/stores/browser-store";
import {
prepareBrowserWebview,
@@ -79,25 +75,14 @@ type WebTextInput = TextInput & {
getNativeRef?: () => unknown;
};
type BrowserElementSelection = Omit<
BrowserElementAttachment,
"formatted" | "comment" | "intent"
> & {
type BrowserElementSelection = Omit<BrowserElementAttachment, "formatted" | "comment"> & {
attributes?: Record<string, string>;
};
interface BrowserElementAnnotation {
comment: string;
intent: BrowserAnnotationIntent;
}
const BROWSER_ANNOTATION_INTENTS: readonly BrowserAnnotationIntent[] = [
"fix",
"change",
"question",
"approve",
];
type DeviceSizeId =
| "responsive"
| "iphone-se"
@@ -223,10 +208,6 @@ function formatElementAttachment(
const html = truncateText(selection.outerHTML.trim(), 800);
const parts: string[] = [];
if (annotation) {
parts.push(`intent: ${annotation.intent}`);
}
if (selection.reactSource?.fileName) {
const loc = [
selection.reactSource.fileName,
@@ -289,7 +270,6 @@ function buildBrowserElementAttachment(
parentChain: selection.parentChain,
children: selection.children,
...(comment ? { comment } : {}),
...(annotation ? { intent: annotation.intent } : {}),
...(screenshot ? { screenshot } : {}),
formatted: formatElementAttachment(selection, annotation),
};
@@ -641,10 +621,11 @@ export function BrowserPane({
const pendingNavigationUrlRef = useRef<string | null>(null);
const domReadyRef = useRef(false);
const annotationMarkersRef = useRef<BrowserAnnotationMarker[]>([]);
const [selectorActive, setSelectorActive] = useState(false);
const [selectorMode, setSelectorMode] = useState<"annotate" | "screenshot" | null>(null);
const selectorActive = selectorMode !== null;
// Which action the active selector performs on click: open the annotation card
// ("annotate") or copy the element to the clipboard ("grab").
const selectorModeRef = useRef<"annotate" | "grab">("annotate");
// ("annotate") or copy a screenshot of the element to the clipboard ("screenshot").
const selectorModeRef = useRef<"annotate" | "screenshot">("annotate");
const toast = useToast();
const toastRef = useRef(toast);
toastRef.current = toast;
@@ -1074,7 +1055,7 @@ export function BrowserPane({
[],
);
const grabElementToClipboard = useCallback(
const screenshotElementToClipboard = useCallback(
async (selection: BrowserElementSelection) => {
const text = formatElementAttachment(selection);
const copyElement = getDesktopHost()?.browser?.copyElement;
@@ -1087,19 +1068,23 @@ export function BrowserPane({
const dataUrl = await captureElement(browserIdRef.current, { x, y, width, height });
imageDataUrl = dataUrl ?? undefined;
} catch (error) {
console.warn("[browser-pane] capture element for grab failed", error);
console.warn("[browser-pane] capture element for screenshot failed", error);
}
}
const copiedMessage = imageDataUrl
? t("workspace.browser.controls.screenshotCopied")
: t("workspace.browser.controls.elementCopied");
// Copy via the main process; the renderer's navigator.clipboard rejects
// with NotAllowedError because focus is inside the guest <webview>.
if (typeof copyElement === "function") {
try {
const ok = await copyElement({ text, imageDataUrl });
if (ok) {
toastRef.current?.copied(t("workspace.browser.controls.grabElementLabel"));
toastRef.current?.show(copiedMessage, { variant: "success" });
} else {
toastRef.current?.error(t("workspace.browser.controls.grabFailed"));
toastRef.current?.error(t("workspace.browser.controls.screenshotFailed"));
}
return;
} catch (error) {
@@ -1110,10 +1095,12 @@ export function BrowserPane({
// Fallback to expo-clipboard (text only) when the bridge is unavailable.
try {
await Clipboard.setStringAsync(text);
toastRef.current?.copied(t("workspace.browser.controls.grabElementLabel"));
toastRef.current?.show(t("workspace.browser.controls.elementCopied"), {
variant: "success",
});
} catch (error) {
console.warn("[browser-pane] clipboard fallback failed", error);
toastRef.current?.error(t("workspace.browser.controls.grabFailed"));
toastRef.current?.error(t("workspace.browser.controls.screenshotFailed"));
}
},
[t],
@@ -1121,8 +1108,8 @@ export function BrowserPane({
const handleSelectorResult = useCallback(
(selection: BrowserElementSelection) => {
if (selectorModeRef.current === "grab") {
void grabElementToClipboard(selection);
if (selectorModeRef.current === "screenshot") {
void screenshotElementToClipboard(selection);
return;
}
pendingScreenshotRef.current = undefined;
@@ -1132,7 +1119,7 @@ export function BrowserPane({
return undefined;
});
},
[captureElementScreenshot, grabElementToClipboard],
[captureElementScreenshot, screenshotElementToClipboard],
);
const submitAnnotation = useCallback(
@@ -1155,15 +1142,15 @@ export function BrowserPane({
}, []);
const startElementSelector = useCallback(
(mode: "annotate" | "grab") => {
(mode: "annotate" | "screenshot") => {
const webview = webviewRef.current;
if (!webview || !domReadyRef.current) return;
// Annotate needs a workspace scope to attach to; grab only copies.
// Annotate needs a workspace scope to attach to; screenshot only copies.
if (mode === "annotate" && !workspaceAttachmentScopeKey) return;
selectorModeRef.current = mode;
pendingScreenshotRef.current = undefined;
setPendingSelection(null);
setSelectorActive(true);
setSelectorMode(mode);
const js = `
(function() {
@@ -1389,11 +1376,11 @@ export function BrowserPane({
const poll = startSelectorResultPolling({
webview,
onSelection: handleSelectorResult,
onDone: () => setSelectorActive(false),
onDone: () => setSelectorMode(null),
});
window.setTimeout(() => {
window.clearInterval(poll);
setSelectorActive(false);
setSelectorMode(null);
if (webviewRef.current !== webview || !domReadyRef.current) {
return;
}
@@ -1402,10 +1389,10 @@ export function BrowserPane({
return undefined;
})
.catch(() => {
setSelectorActive(false);
setSelectorMode(null);
});
} catch {
setSelectorActive(false);
setSelectorMode(null);
}
},
[handleSelectorResult, workspaceAttachmentScopeKey],
@@ -1413,7 +1400,7 @@ export function BrowserPane({
const cancelElementSelector = useCallback(() => {
const webview = webviewRef.current;
setSelectorActive(false);
setSelectorMode(null);
if (webview && domReadyRef.current) {
try {
clearWebviewSelector(webview);
@@ -1470,12 +1457,12 @@ export function BrowserPane({
startElementSelector("annotate");
}, [cancelElementSelector, selectorActive, startElementSelector]);
const handleToggleGrab = useCallback(() => {
const handleToggleScreenshot = useCallback(() => {
if (selectorActive) {
cancelElementSelector();
return;
}
startElementSelector("grab");
startElementSelector("screenshot");
}, [cancelElementSelector, selectorActive, startElementSelector]);
const handleOpenDevTools = useCallback(() => {
@@ -1521,13 +1508,21 @@ export function BrowserPane({
],
[browser?.canGoForward],
);
const selectorIconButtonStyle = useCallback(
const annotateIconButtonStyle = useCallback(
({ hovered, pressed }: { hovered?: boolean; pressed?: boolean }) => [
styles.iconButton,
selectorActive && styles.selectorActiveButton,
selectorMode === "annotate" && styles.selectorActiveButton,
(hovered || pressed) && styles.iconButtonHovered,
],
[selectorActive],
[selectorMode],
);
const screenshotIconButtonStyle = useCallback(
({ hovered, pressed }: { hovered?: boolean; pressed?: boolean }) => [
styles.iconButton,
selectorMode === "screenshot" && styles.selectorActiveButton,
(hovered || pressed) && styles.iconButtonHovered,
],
[selectorMode],
);
const devicePreset = useMemo(
@@ -1633,36 +1628,46 @@ export function BrowserPane({
onSelect={setDeviceSizeId}
triggerStyle={baseIconButtonStyle}
/>
{isDev ? (
<ToolbarButton
label={t("workspace.browser.controls.openDevTools")}
onPress={handleOpenDevTools}
style={baseIconButtonStyle}
>
<PencilRuler size={16} color={theme.colors.foregroundMuted} />
</ToolbarButton>
) : null}
<ToolbarButton
label={t("workspace.browser.controls.openDevTools")}
onPress={handleOpenDevTools}
style={baseIconButtonStyle}
>
<Wrench size={16} color={theme.colors.foregroundMuted} />
</ToolbarButton>
<ToolbarButton
label={
selectorActive
selectorMode === "annotate"
? t("workspace.browser.controls.cancelSelector")
: t("workspace.browser.controls.selectElement")
: t("workspace.browser.controls.annotateElement")
}
active={selectorActive}
active={selectorMode === "annotate"}
onPress={handleToggleElementSelector}
style={selectorIconButtonStyle}
style={annotateIconButtonStyle}
>
<MousePointer2
size={16}
color={selectorActive ? theme.colors.accent : theme.colors.foregroundMuted}
color={
selectorMode === "annotate" ? theme.colors.accent : theme.colors.foregroundMuted
}
/>
</ToolbarButton>
<ToolbarButton
label={t("workspace.browser.controls.grabElement")}
onPress={handleToggleGrab}
style={baseIconButtonStyle}
label={
selectorMode === "screenshot"
? t("workspace.browser.controls.cancelSelector")
: t("workspace.browser.controls.screenshotElement")
}
active={selectorMode === "screenshot"}
onPress={handleToggleScreenshot}
style={screenshotIconButtonStyle}
>
<Copy size={16} color={theme.colors.foregroundMuted} />
<Camera
size={16}
color={
selectorMode === "screenshot" ? theme.colors.accent : theme.colors.foregroundMuted
}
/>
</ToolbarButton>
</View>
</View>
@@ -1690,45 +1695,6 @@ export function BrowserPane({
);
}
const INTENT_LABEL_KEYS: Record<BrowserAnnotationIntent, string> = {
fix: "workspace.browser.annotate.intents.fix",
change: "workspace.browser.annotate.intents.change",
question: "workspace.browser.annotate.intents.question",
approve: "workspace.browser.annotate.intents.approve",
};
function IntentChip({
active,
intent,
label,
onSelect,
}: {
active: boolean;
intent: BrowserAnnotationIntent;
label: string;
onSelect: (intent: BrowserAnnotationIntent) => void;
}) {
const handlePress = useCallback(() => {
onSelect(intent);
}, [intent, onSelect]);
const chipStyle = useMemo(() => [styles.intentChip, active && styles.intentChipActive], [active]);
const textStyle = useMemo(
() => [styles.intentChipText, active && styles.intentChipTextActive],
[active],
);
const accessibilityState = useMemo(() => ({ selected: active }), [active]);
return (
<Pressable
accessibilityRole="button"
accessibilityState={accessibilityState}
onPress={handlePress}
style={chipStyle}
>
<Text style={textStyle}>{label}</Text>
</Pressable>
);
}
function BrowserElementAnnotationCard({
selection,
onSubmit,
@@ -1740,14 +1706,11 @@ function BrowserElementAnnotationCard({
}) {
const { t } = useTranslation();
const [comment, setComment] = useState("");
const [intent, setIntent] = useState<BrowserAnnotationIntent>("fix");
const commentRef = useRef(comment);
commentRef.current = comment;
const intentRef = useRef(intent);
intentRef.current = intent;
const handleSubmit = useCallback(() => {
onSubmit({ comment: commentRef.current, intent: intentRef.current });
onSubmit({ comment: commentRef.current });
}, [onSubmit]);
useEffect(() => {
@@ -1792,17 +1755,6 @@ function BrowserElementAnnotationCard({
<Text numberOfLines={1} style={styles.annotationElement}>
{elementLabel}
</Text>
<View style={styles.annotationIntents}>
{BROWSER_ANNOTATION_INTENTS.map((option) => (
<IntentChip
key={option}
active={option === intent}
intent={option}
label={t(INTENT_LABEL_KEYS[option])}
onSelect={setIntent}
/>
))}
</View>
<ThemedAnnotationInput
accessibilityLabel={t("workspace.browser.annotate.placeholder")}
autoFocus
@@ -1814,24 +1766,12 @@ function BrowserElementAnnotationCard({
value={comment}
/>
<View style={styles.annotationActions}>
<Pressable
accessibilityRole="button"
onPress={onCancel}
style={styles.annotationSecondaryButton}
>
<Text style={styles.annotationSecondaryText}>
{t("workspace.browser.annotate.cancel")}
</Text>
</Pressable>
<Pressable
accessibilityRole="button"
onPress={handleSubmit}
style={styles.annotationPrimaryButton}
>
<Text style={styles.annotationPrimaryText}>
{t("workspace.browser.annotate.submit")}
</Text>
</Pressable>
<Button variant="ghost" size="sm" onPress={onCancel}>
{t("workspace.browser.annotate.cancel")}
</Button>
<Button variant="default" size="sm" onPress={handleSubmit}>
{t("workspace.browser.annotate.submit")}
</Button>
</View>
</View>
</View>
@@ -1986,31 +1926,7 @@ const styles = StyleSheet.create((theme) => ({
annotationElement: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
annotationIntents: {
flexDirection: "row",
flexWrap: "wrap",
gap: theme.spacing[1],
},
intentChip: {
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
borderRadius: theme.borderRadius.md,
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface1,
},
intentChipActive: {
borderColor: theme.colors.accent,
backgroundColor: `${String(theme.colors.accent)}20`,
},
intentChipText: {
fontSize: theme.fontSize.xs,
fontWeight: "500",
color: theme.colors.foregroundMuted,
},
intentChipTextActive: {
color: theme.colors.accent,
marginBottom: theme.spacing[2],
},
annotationInput: {
minHeight: 64,
@@ -2029,27 +1945,6 @@ const styles = StyleSheet.create((theme) => ({
justifyContent: "flex-end",
gap: theme.spacing[2],
},
annotationSecondaryButton: {
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.md,
},
annotationSecondaryText: {
fontSize: theme.fontSize.sm,
fontWeight: "500",
color: theme.colors.foregroundMuted,
},
annotationPrimaryButton: {
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.accent,
},
annotationPrimaryText: {
fontSize: theme.fontSize.sm,
fontWeight: "600",
color: theme.colors.accentForeground ?? "#ffffff",
},
unavailableState: {
flex: 1,
alignItems: "center",

View File

@@ -2,33 +2,66 @@ import { afterEach, describe, expect, it } from "vitest";
import {
clearResidentBrowserWebviewsForTests,
ensureResidentBrowserWebview,
prepareBrowserWebview,
releaseResidentBrowserWebview,
removeResidentBrowserWebview,
takeResidentBrowserWebview,
} from "./browser-webview-resident";
const RESIDENT_HOST_ID = "paseo-browser-resident-webviews";
function residentHost(): HTMLElement {
const host = document.getElementById(RESIDENT_HOST_ID);
if (!host) {
throw new Error("Expected resident browser host");
}
return host;
}
function expectPermanentHostParking(host: HTMLElement): void {
expect(host.style.position).toBe("fixed");
expect(host.style.left).toBe("0px");
expect(host.style.top).toBe("0px");
expect(host.style.width).toBe("1px");
expect(host.style.height).toBe("1px");
expect(host.style.overflow).toBe("hidden");
expect(host.style.opacity).toBe("1");
expect(host.style.pointerEvents).toBe("none");
expect(host.style.display).toBe("block");
expect(host.style.visibility).toBe("visible");
expect(host.style.transform).toBe("");
}
function expectResidentWebviewParking(webview: HTMLElement): void {
expect(webview.style.display).toBe("inline-flex");
expect(webview.style.flex).toBe("0 0 auto");
expect(webview.style.width).toBe("1280px");
expect(webview.style.height).toBe("800px");
expect(webview.style.position).toBe("absolute");
expect(webview.style.left).toBe("0px");
expect(webview.style.top).toBe("0px");
expect(webview.style.zIndex).toBe("0");
}
describe("resident browser webviews", () => {
afterEach(() => {
clearResidentBrowserWebviewsForTests();
});
it("keeps a browser webview mounted offscreen and reuses the same node", () => {
const host = document.createElement("div");
it("parks a browser webview in the permanent paintable 1x1 host", () => {
const visibleHost = document.createElement("div");
const webview = document.createElement("webview");
host.appendChild(webview);
document.body.appendChild(host);
visibleHost.appendChild(webview);
document.body.appendChild(visibleHost);
releaseResidentBrowserWebview("browser-a", webview);
expect(host.children).toHaveLength(0);
const host = residentHost();
expect(visibleHost.children).toHaveLength(0);
expect(Array.from(host.children)).toEqual([webview]);
expect(webview.isConnected).toBe(true);
expect(webview.style.width).toBe("1280px");
expect(webview.style.height).toBe("800px");
const reused = takeResidentBrowserWebview("browser-a");
expect(reused).toBe(webview);
expect(takeResidentBrowserWebview("browser-a")).toBeNull();
expectPermanentHostParking(host);
expectResidentWebviewParking(webview);
});
it("creates a resident webview for an agent-created unfocused tab", () => {
@@ -44,6 +77,113 @@ describe("resident browser webviews", () => {
expect((webview as HTMLUnknownElement & { src?: string })?.src).toContain(
"https://example.com",
);
expectPermanentHostParking(residentHost());
expectResidentWebviewParking(webview as HTMLElement);
});
it("normalizes an existing resident host back to permanent parking", () => {
const staleHost = document.createElement("div");
staleHost.id = RESIDENT_HOST_ID;
staleHost.style.left = "-20000px";
staleHost.style.width = "1280px";
staleHost.style.height = "800px";
staleHost.style.opacity = "0";
staleHost.style.display = "none";
document.body.appendChild(staleHost);
const webview = ensureResidentBrowserWebview({
browserId: "browser-stale-host",
url: "https://example.com",
});
expect(webview).not.toBeNull();
expectPermanentHostParking(staleHost);
expectResidentWebviewParking(webview as HTMLElement);
});
it("normalizes an existing resident webview and its stale host before reusing them", () => {
const staleHost = document.createElement("div");
staleHost.id = RESIDENT_HOST_ID;
staleHost.style.left = "-20000px";
staleHost.style.width = "1280px";
staleHost.style.height = "800px";
staleHost.style.opacity = "0";
staleHost.style.display = "none";
const staleWebview = document.createElement("webview");
prepareBrowserWebview(staleWebview, {
browserId: "browser-stale-child",
initialUrl: "https://example.com",
});
staleWebview.style.display = "none";
staleWebview.style.width = "1px";
staleWebview.style.height = "1px";
staleWebview.style.position = "fixed";
staleWebview.style.left = "-20000px";
staleHost.appendChild(staleWebview);
document.body.appendChild(staleHost);
const webview = ensureResidentBrowserWebview({
browserId: "browser-stale-child",
url: "https://example.com/agent",
});
expect(webview).toBe(staleWebview);
expect(Array.from(staleHost.children)).toEqual([staleWebview]);
expectPermanentHostParking(staleHost);
expectResidentWebviewParking(staleWebview);
});
it("parks resident webviews as an overlapping stack", () => {
const firstWebview = ensureResidentBrowserWebview({
browserId: "browser-first",
url: "https://example.com/first",
});
const secondWebview = ensureResidentBrowserWebview({
browserId: "browser-second",
url: "https://example.com/second",
});
const host = residentHost();
expect(firstWebview?.parentElement).toBe(host);
expect(secondWebview?.parentElement).toBe(host);
expectResidentWebviewParking(firstWebview as HTMLElement);
expectResidentWebviewParking(secondWebview as HTMLElement);
});
it("moves a resident webview into a visible pane without recreating the node", () => {
const webview = ensureResidentBrowserWebview({
browserId: "browser-visible",
url: "https://example.com",
});
const visibleWebview = takeResidentBrowserWebview("browser-visible");
expect(visibleWebview).toBe(webview);
expect(webview?.style.position).toBe("");
expect(webview?.style.left).toBe("");
expect(webview?.style.top).toBe("");
expect(webview?.style.zIndex).toBe("");
expect(takeResidentBrowserWebview("browser-visible")).toBeNull();
});
it("returns an existing visible pane webview instead of creating a resident duplicate", () => {
const visibleHost = document.createElement("div");
const visibleWebview = document.createElement("webview");
prepareBrowserWebview(visibleWebview, {
browserId: "browser-visible-pane",
initialUrl: "https://example.com",
});
visibleHost.appendChild(visibleWebview);
document.body.appendChild(visibleHost);
const webview = ensureResidentBrowserWebview({
browserId: "browser-visible-pane",
url: "https://example.com/agent",
});
expect(webview).toBe(visibleWebview);
expect(document.getElementById(RESIDENT_HOST_ID)).toBeNull();
});
it("removes a resident webview when its browser tab closes", () => {

View File

@@ -4,6 +4,7 @@ const RESIDENT_VIEWPORT_WIDTH = 1280;
const RESIDENT_VIEWPORT_HEIGHT = 800;
const residentWebviewsByBrowserId = new Map<string, HTMLElement>();
const residentWebviewSizesByBrowserId = new Map<string, { width: number; height: number }>();
interface BrowserWebviewElement extends HTMLElement {
src: string;
@@ -21,42 +22,84 @@ function readDocument(): Document | null {
return typeof document === "undefined" ? null : document;
}
function applyResidentHostParkingStyle(host: HTMLElement): void {
// Parked browser webviews must remain paintable at all times; screenshot
// correctness depends on the proven states in docs/browser-capture-harness.md.
host.setAttribute("aria-hidden", "true");
host.style.position = "fixed";
host.style.left = "0";
host.style.top = "0";
host.style.width = "1px";
host.style.height = "1px";
host.style.overflow = "hidden";
host.style.opacity = "1";
host.style.pointerEvents = "none";
host.style.display = "block";
host.style.zIndex = "";
host.style.clipPath = "";
host.style.visibility = "visible";
host.style.transform = "";
}
function getResidentBrowserHost(ownerDocument: Document): HTMLElement {
const existing = ownerDocument.getElementById(RESIDENT_BROWSER_HOST_ID);
if (existing) {
applyResidentHostParkingStyle(existing);
return existing;
}
const host = ownerDocument.createElement("div");
host.id = RESIDENT_BROWSER_HOST_ID;
host.setAttribute("aria-hidden", "true");
host.style.position = "fixed";
host.style.left = "-20000px";
host.style.top = "0";
host.style.width = `${RESIDENT_VIEWPORT_WIDTH}px`;
host.style.height = `${RESIDENT_VIEWPORT_HEIGHT}px`;
host.style.overflow = "hidden";
host.style.opacity = "0";
host.style.pointerEvents = "none";
applyResidentHostParkingStyle(host);
ownerDocument.body.appendChild(host);
return host;
}
function findBrowserWebview(browserId: string, ownerDocument: Document): HTMLElement | null {
for (const element of ownerDocument.querySelectorAll(`[${BROWSER_ID_ATTRIBUTE}]`)) {
if (!(element instanceof HTMLElement)) {
continue;
}
if (element.getAttribute(BROWSER_ID_ATTRIBUTE) === browserId) {
return element as HTMLElement;
return element;
}
}
return null;
}
function applyResidentWebviewStyle(webview: HTMLElement): void {
webview.style.display = "block";
webview.style.width = `${RESIDENT_VIEWPORT_WIDTH}px`;
webview.style.height = `${RESIDENT_VIEWPORT_HEIGHT}px`;
function dimensionsForBrowser(browserId: string | null): { width: number; height: number } {
if (!browserId) {
return { width: RESIDENT_VIEWPORT_WIDTH, height: RESIDENT_VIEWPORT_HEIGHT };
}
return (
residentWebviewSizesByBrowserId.get(browserId) ?? {
width: RESIDENT_VIEWPORT_WIDTH,
height: RESIDENT_VIEWPORT_HEIGHT,
}
);
}
function applyResidentWebviewStyle(webview: HTMLElement, browserId: string | null): void {
const dimensions = dimensionsForBrowser(browserId);
webview.style.display = "inline-flex";
webview.style.flex = "0 0 auto";
webview.style.width = `${dimensions.width}px`;
webview.style.height = `${dimensions.height}px`;
webview.style.border = "0";
webview.style.background = "transparent";
webview.style.position = "absolute";
webview.style.left = "0";
webview.style.top = "0";
webview.style.marginTop = "0";
webview.style.zIndex = "0";
}
function clearResidentWebviewParkingStyle(webview: HTMLElement): void {
webview.style.position = "";
webview.style.left = "";
webview.style.top = "";
webview.style.marginTop = "";
webview.style.zIndex = "";
}
export function prepareBrowserWebview(
@@ -88,11 +131,15 @@ export function ensureResidentBrowserWebview(input: {
const resident = residentWebviewsByBrowserId.get(browserId) ?? null;
if (resident?.isConnected) {
releaseResidentBrowserWebview(browserId, resident);
return resident;
}
const existing = findBrowserWebview(browserId, ownerDocument);
if (existing) {
if (existing.parentElement?.id === RESIDENT_BROWSER_HOST_ID) {
releaseResidentBrowserWebview(browserId, existing);
}
return existing;
}
@@ -114,6 +161,7 @@ export function takeResidentBrowserWebview(browserId: string): HTMLElement | nul
}
residentWebviewsByBrowserId.delete(normalizedBrowserId);
clearResidentWebviewParkingStyle(webview);
return webview;
}
@@ -129,10 +177,33 @@ export function releaseResidentBrowserWebview(browserId: string, webview: HTMLEl
}
residentWebviewsByBrowserId.set(normalizedBrowserId, webview);
applyResidentWebviewStyle(webview);
applyResidentWebviewStyle(webview, normalizedBrowserId);
getResidentBrowserHost(ownerDocument).appendChild(webview);
}
export function resizeResidentBrowserWebview(input: {
browserId: string;
width: number;
height: number;
}): { width: number; height: number } | null {
const normalizedBrowserId = trimNonEmpty(input.browserId);
if (!normalizedBrowserId) {
return null;
}
const width = Math.max(1, Math.round(input.width));
const height = Math.max(1, Math.round(input.height));
residentWebviewSizesByBrowserId.set(normalizedBrowserId, { width, height });
const ownerDocument = readDocument();
const webview = ownerDocument ? findBrowserWebview(normalizedBrowserId, ownerDocument) : null;
if (webview) {
webview.style.width = `${width}px`;
webview.style.height = `${height}px`;
}
return { width, height };
}
export function removeResidentBrowserWebview(browserId: string): void {
const normalizedBrowserId = trimNonEmpty(browserId);
if (!normalizedBrowserId) {
@@ -141,6 +212,7 @@ export function removeResidentBrowserWebview(browserId: string): void {
const resident = residentWebviewsByBrowserId.get(normalizedBrowserId) ?? null;
residentWebviewsByBrowserId.delete(normalizedBrowserId);
residentWebviewSizesByBrowserId.delete(normalizedBrowserId);
resident?.remove();
}
@@ -149,5 +221,6 @@ export function clearResidentBrowserWebviewsForTests(): void {
webview.remove();
}
residentWebviewsByBrowserId.clear();
residentWebviewSizesByBrowserId.clear();
readDocument()?.getElementById(RESIDENT_BROWSER_HOST_ID)?.remove();
}

View File

@@ -11,7 +11,6 @@ export function getAttachmentKey(attachment: WorkspaceComposerAttachment): strin
tag: attachment.attachment.tag,
text: attachment.attachment.text,
html: attachment.attachment.outerHTML,
intent: attachment.attachment.intent ?? null,
comment: attachment.attachment.comment ?? null,
});
}

View File

@@ -37,10 +37,10 @@ const CATALOG_DATA = [
title: "Auggie CLI",
description:
"Augment Code's powerful software agent, backed by industry-leading context engine",
version: "0.31.0",
version: "0.32.0",
iconId: "auggie",
installLink: "https://www.augmentcode.com/",
command: ["npx", "-y", "@augmentcode/auggie@0.31.0", "--acp"],
command: ["npx", "-y", "@augmentcode/auggie@0.32.0", "--acp"],
env: {
AUGMENT_DISABLE_AUTO_UPDATE: "1",
},
@@ -59,19 +59,19 @@ const CATALOG_DATA = [
title: "Cline",
description:
"Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
version: "3.0.34",
version: "3.0.37",
iconId: "cline",
installLink: "https://cline.bot/cli",
command: ["npx", "-y", "cline@3.0.34", "--acp"],
command: ["npx", "-y", "cline@3.0.37", "--acp"],
},
{
id: "codebuddy-code",
title: "Codebuddy Code",
description: "Tencent Cloud's official intelligent coding tool",
version: "2.114.0",
version: "2.116.0",
iconId: "codebuddy-code",
installLink: "https://www.codebuddy.cn/cli/",
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.114.0", "--acp"],
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.116.0", "--acp"],
},
{
id: "codewhale",
@@ -140,29 +140,29 @@ const CATALOG_DATA = [
id: "dimcode",
title: "DimCode",
description: "A coding agent that puts leading models at your command.",
version: "0.2.12",
version: "0.2.19",
iconId: "dimcode",
installLink: "https://dimcode.dev/docs/acp.html",
command: ["npx", "-y", "dimcode@0.2.12", "acp"],
command: ["npx", "-y", "dimcode@0.2.19", "acp"],
},
{
id: "dirac",
title: "Dirac",
description:
"Reduces API costs by more than 50%, produces better and faster work. Uses Hash anchored parallel edits, AST manipulation and a whole lot of neat optimizations. Fully Open Source.",
version: "0.4.12",
version: "0.4.13",
iconId: "dirac",
installLink: "https://dirac.run",
command: ["npx", "-y", "dirac-cli@0.4.12", "--acp"],
command: ["npx", "-y", "dirac-cli@0.4.13", "--acp"],
},
{
id: "factory-droid",
title: "Factory Droid",
description: "Factory Droid - AI coding agent powered by Factory AI",
version: "0.161.0",
version: "0.164.0",
iconId: "factory-droid",
installLink: "https://factory.ai/product/cli",
command: ["npx", "-y", "droid@0.161.0", "exec", "--output-format", "acp-daemon"],
command: ["npx", "-y", "droid@0.164.0", "exec", "--output-format", "acp-daemon"],
env: {
DROID_DISABLE_AUTO_UPDATE: "true",
FACTORY_DROID_AUTO_UPDATE_ENABLED: "false",
@@ -173,10 +173,10 @@ const CATALOG_DATA = [
id: "fast-agent",
title: "fast-agent",
description: "Code and build agents with comprehensive multi-provider support",
version: "0.8.1",
version: "0.8.3",
iconId: "fast-agent",
installLink: "https://fast-agent.ai/acp/",
command: ["uvx", "--from", "fast-agent-acp==0.8.1", "fast-agent-acp", "-x"],
command: ["uvx", "--from", "fast-agent-acp==0.8.3", "fast-agent-acp", "-x"],
},
{
id: "gemini",
@@ -284,10 +284,10 @@ const CATALOG_DATA = [
id: "nova",
title: "Nova",
description: "Nova by Compass AI - a fully-fledged software engineer at your command",
version: "1.1.22",
version: "1.1.24",
iconId: "nova",
installLink: "https://www.compassap.ai/portfolio/nova.html",
command: ["npx", "-y", "@compass-ai/nova@1.1.22", "acp"],
command: ["npx", "-y", "@compass-ai/nova@1.1.24", "acp"],
},
{
id: "poolside",
@@ -302,19 +302,19 @@ const CATALOG_DATA = [
id: "qoder",
title: "Qoder CLI",
description: "AI coding assistant with agentic capabilities",
version: "1.0.33",
version: "1.0.37",
iconId: "qoder",
installLink: "https://qoder.com",
command: ["npx", "-y", "@qoder-ai/qodercli@1.0.33", "--acp"],
command: ["npx", "-y", "@qoder-ai/qodercli@1.0.37", "--acp"],
},
{
id: "qwen-code",
title: "Qwen Code",
description: "Alibaba's Qwen coding assistant",
version: "0.19.3",
version: "0.19.6",
iconId: "qwen-code",
installLink: "https://qwenlm.github.io/qwen-code-docs/en/users/overview",
command: ["npx", "-y", "@qwen-code/qwen-code@0.19.3", "--acp", "--experimental-skills"],
command: ["npx", "-y", "@qwen-code/qwen-code@0.19.6", "--acp", "--experimental-skills"],
},
{
id: "sigit",

View File

@@ -128,11 +128,11 @@ export interface DesktopBrowserNewTabRequestEvent {
export interface DesktopBrowserBridge {
registerWorkspaceBrowser?: (input: { browserId: string; workspaceId: string }) => Promise<void>;
unregisterWorkspaceBrowser?: (browserId: string) => Promise<void>;
setWorkspaceActiveBrowser?: (input: {
workspaceId: string;
browserId: string | null;
}) => Promise<void>;
setAgentActiveBrowser?: (input: { agentId: string; browserId: string | null }) => Promise<void>;
openDevTools?: (browserId: string) => Promise<unknown>;
clearPartition?: (browserId: string) => Promise<void>;
executeAutomationCommand?: (

View File

@@ -422,22 +422,17 @@ export const ar: TranslationResources = {
enterUrl: "أدخل URL",
openDevTools: "افتح أدوات تطوير المتصفح",
cancelSelector: "إلغاء محدد العنصر",
selectElement: "حدد العنصر",
grabElement: "نسخ العنصر إلى الحافظة",
grabElementLabel: "العنصر",
grabFailed: عذّر نسخ العنصر",
annotateElement: "التعليق على العنصر",
screenshotElement: "لقطة للعنصر",
screenshotCopied: "تم نسخ لقطة الشاشة إلى الحافظة",
elementCopied: م نسخ العنصر إلى الحافظة",
screenshotFailed: "تعذّر نسخ لقطة الشاشة",
},
annotate: {
title: "إرسال ملاحظات إلى الوكيل",
placeholder: "صف ما الذي ينبغي تغييره…",
title: "التعليق على العنصر",
placeholder: "رسالة إلى الوكيل حول هذا العنصر…",
submit: "إرفاق",
cancel: "إلغاء",
intents: {
fix: "إصلاح",
change: "تغيير",
question: "سؤال",
approve: "موافقة",
},
},
devices: {
label: "حجم الجهاز",

View File

@@ -422,22 +422,17 @@ export const en = {
enterUrl: "Enter URL",
openDevTools: "Open browser dev tools",
cancelSelector: "Cancel element selector",
selectElement: "Select element",
grabElement: "Copy element to clipboard",
grabElementLabel: "element",
grabFailed: "Couldn't copy element",
annotateElement: "Annotate element",
screenshotElement: "Screenshot element",
screenshotCopied: "Copied screenshot to clipboard",
elementCopied: "Copied element to clipboard",
screenshotFailed: "Couldn't copy screenshot",
},
annotate: {
title: "Send feedback to agent",
placeholder: "Describe what should change…",
title: "Annotate element",
placeholder: "Message to the agent about this element…",
submit: "Attach",
cancel: "Cancel",
intents: {
fix: "Fix",
change: "Change",
question: "Question",
approve: "Approve",
},
},
devices: {
label: "Device size",

View File

@@ -426,22 +426,17 @@ export const es: TranslationResources = {
enterUrl: "IngreseURL",
openDevTools: "Abrir herramientas de desarrollo del navegador",
cancelSelector: "Cancelar selector de elementos",
selectElement: "Seleccionar elemento",
grabElement: "Copiar elemento al portapapeles",
grabElementLabel: "elemento",
grabFailed: "No se pudo copiar el elemento",
annotateElement: "Anotar elemento",
screenshotElement: "Capturar elemento",
screenshotCopied: "Captura copiada al portapapeles",
elementCopied: "Elemento copiado al portapapeles",
screenshotFailed: "No se pudo copiar la captura",
},
annotate: {
title: "Enviar comentarios al agente",
placeholder: "Describe qué debería cambiar…",
title: "Anotar elemento",
placeholder: "Mensaje al agente sobre este elemento…",
submit: "Adjuntar",
cancel: "Cancelar",
intents: {
fix: "Corregir",
change: "Cambiar",
question: "Pregunta",
approve: "Aprobar",
},
},
devices: {
label: "Tamaño del dispositivo",

View File

@@ -426,22 +426,17 @@ export const fr: TranslationResources = {
enterUrl: "EntrezURL",
openDevTools: "Outils de développement du navigateur ouvert",
cancelSelector: "Annuler le sélecteur d'élément",
selectElement: "Sélectionner un élément",
grabElement: "Copier l'élément dans le presse-papiers",
grabElementLabel: "élément",
grabFailed: "Impossible de copier l'élément",
annotateElement: "Annoter l'élément",
screenshotElement: "Capturer l'élément",
screenshotCopied: "Capture d'écran copiée dans le presse-papiers",
elementCopied: "Élément copié dans le presse-papiers",
screenshotFailed: "Impossible de copier la capture",
},
annotate: {
title: "Envoyer un retour à l'agent",
placeholder: "Décrivez ce qui doit changer…",
title: "Annoter l'élément",
placeholder: "Message à l'agent concernant cet élément…",
submit: "Joindre",
cancel: "Annuler",
intents: {
fix: "Corriger",
change: "Modifier",
question: "Question",
approve: "Approuver",
},
},
devices: {
label: "Taille de l'appareil",

View File

@@ -426,22 +426,17 @@ export const ja: TranslationResources = {
enterUrl: "URLを入力",
openDevTools: "ブラウザ開発ツールを開く",
cancelSelector: "要素セレクターをキャンセル",
selectElement: "要素を選択",
grabElement: "要素をクリップボードにコピー",
grabElementLabel: "要素",
grabFailed: "要素をコピーできませんでした",
annotateElement: "要素に注釈を付ける",
screenshotElement: "要素のスクリーンショット",
screenshotCopied: "スクリーンショットをクリップボードにコピーしました",
elementCopied: "要素をクリップボードにコピーしました",
screenshotFailed: "スクリーンショットをコピーできませんでした",
},
annotate: {
title: "エージェントにフィードバックを送信",
placeholder: "変更すべき内容を記述…",
title: "要素に注釈を付ける",
placeholder: "この要素についてエージェントへのメッセージ…",
submit: "添付",
cancel: "キャンセル",
intents: {
fix: "修正",
change: "変更",
question: "質問",
approve: "承認",
},
},
devices: {
label: "デバイスサイズ",

View File

@@ -426,22 +426,17 @@ export const ptBR: TranslationResources = {
enterUrl: "Inserir URL",
openDevTools: "Abrir ferramentas de desenvolvedor do navegador",
cancelSelector: "Cancelar seletor de elemento",
selectElement: "Selecionar elemento",
grabElement: "Copiar elemento para a área de transferência",
grabElementLabel: "elemento",
grabFailed: "Não foi possível copiar o elemento",
annotateElement: "Anotar elemento",
screenshotElement: "Capturar elemento",
screenshotCopied: "Captura copiada para a área de transferência",
elementCopied: "Elemento copiado para a área de transferência",
screenshotFailed: "Não foi possível copiar a captura",
},
annotate: {
title: "Enviar feedback ao agente",
placeholder: "Descreva o que deve mudar…",
title: "Anotar elemento",
placeholder: "Mensagem ao agente sobre este elemento…",
submit: "Anexar",
cancel: "Cancelar",
intents: {
fix: "Corrigir",
change: "Alterar",
question: "Pergunta",
approve: "Aprovar",
},
},
devices: {
label: "Tamanho do dispositivo",

View File

@@ -426,22 +426,17 @@ export const ru: TranslationResources = {
enterUrl: "Введите URL",
openDevTools: "Открыть инструменты разработки браузера",
cancelSelector: "Отменить выбор элемента",
selectElement: "Выберите элемент",
grabElement: "Скопировать элемент в буфер обмена",
grabElementLabel: "элемент",
grabFailed: "Не удалось скопировать элемент",
annotateElement: "Аннотировать элемент",
screenshotElement: "Снимок элемента",
screenshotCopied: "Снимок скопирован в буфер обмена",
elementCopied: "Элемент скопирован в буфер обмена",
screenshotFailed: "Не удалось скопировать снимок",
},
annotate: {
title: "Отправить отзыв агенту",
placeholder: "Опишите, что нужно изменить…",
title: "Аннотировать элемент",
placeholder: "Сообщение агенту об этом элементе…",
submit: "Прикрепить",
cancel: "Отмена",
intents: {
fix: "Исправить",
change: "Изменить",
question: "Вопрос",
approve: "Одобрить",
},
},
devices: {
label: "Размер устройства",

View File

@@ -422,22 +422,17 @@ export const zhCN: TranslationResources = {
enterUrl: "输入 URL",
openDevTools: "打开浏览器开发者工具",
cancelSelector: "取消元素选择器",
selectElement: "选择元素",
grabElement: "复制元素到剪贴板",
grabElementLabel: "元素",
grabFailed: "复制元素失败",
annotateElement: "标注元素",
screenshotElement: "截图元素",
screenshotCopied: "已将截图复制到剪贴板",
elementCopied: "已将元素复制到剪贴板",
screenshotFailed: "无法复制截图",
},
annotate: {
title: "发送反馈给智能体",
placeholder: "描述需要修改的内容…",
title: "标注元素",
placeholder: "给智能体关于此元素的留言…",
submit: "附加",
cancel: "取消",
intents: {
fix: "修复",
change: "修改",
question: "提问",
approve: "认可",
},
},
devices: {
label: "设备尺寸",

View File

@@ -39,6 +39,7 @@ import {
} from "@/desktop/daemon/desktop-daemon-transport";
import { getDesktopHost } from "@/desktop/host";
import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities";
import { BROWSER_AUTOMATION_COMMAND_NAMES } from "@getpaseo/protocol/browser-automation/rpc-schemas";
import { replaceFetchedAgentDirectory } from "@/utils/agent-directory-sync";
import { useSessionStore } from "@/stores/session-store";
import {
@@ -522,10 +523,15 @@ function probeIntervalForConnection(
}
function createDefaultDeps(): HostRuntimeControllerDeps {
const desktopBrowserAutomationAvailable =
const browserHostAvailable =
typeof getDesktopHost()?.browser?.executeAutomationCommand === "function";
const browserAutomationCapabilities = desktopBrowserAutomationAvailable
? { [CLIENT_CAPS.desktopBrowserAutomation]: true }
const browserAutomationCapabilities = browserHostAvailable
? {
[CLIENT_CAPS.browserHost]: {
supportedCommands: [...BROWSER_AUTOMATION_COMMAND_NAMES],
hostKind: "desktop app",
},
}
: undefined;
return {

View File

@@ -2,7 +2,7 @@ import type { MutableDaemonConfig } from "@getpaseo/protocol/messages";
export const BROWSER_TOOLS_TITLE = "Browser tools";
export const BROWSER_TOOLS_WARNING =
"Allow agents to access and control Paseo desktop browser tabs, including logged-in browser state. Only enable this for agents you trust.";
"Allow agents to access and control Paseo browser tabs, including logged-in browser state. Only enable this for agents you trust.";
export interface BrowserToolsCardState {
isVisible: boolean;

View File

@@ -1,4 +1,5 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { BrowserAutomationBrowserIdSchema } from "@getpaseo/protocol/browser-automation/rpc-schemas";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import {
@@ -22,10 +23,14 @@ interface BrowserStoreState extends BrowserIndexState {
}
function createBrowserId(): string {
let browserId: string;
if (typeof globalThis.crypto?.randomUUID === "function") {
return globalThis.crypto.randomUUID();
browserId = globalThis.crypto.randomUUID();
} else {
const randomSuffix = Math.random().toString(16).slice(2) || "0";
browserId = `${Date.now()}-${randomSuffix}`;
}
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
return BrowserAutomationBrowserIdSchema.parse(browserId);
}
export const useBrowserStore = create<BrowserStoreState>()(

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.103",
"version": "0.1.104-beta.4",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -27,9 +27,9 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.1.103",
"@getpaseo/protocol": "0.1.103",
"@getpaseo/server": "0.1.103",
"@getpaseo/client": "0.1.104-beta.4",
"@getpaseo/protocol": "0.1.104-beta.4",
"@getpaseo/server": "0.1.104-beta.4",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/client",
"version": "0.1.103",
"version": "0.1.104-beta.4",
"description": "Paseo client SDK package",
"files": [
"dist",
@@ -27,7 +27,7 @@
},
"scripts": {
"clean": "node ../../scripts/clean-package-dist.mjs",
"build": "tsc -p tsconfig.json --incremental false",
"build": "npm run build --workspace=@getpaseo/protocol && tsc -p tsconfig.json --incremental false",
"build:clean": "npm run clean && npm run build",
"prepack": "npm run build:clean",
"typecheck": "tsgo --noEmit",
@@ -35,8 +35,8 @@
"test": "vitest run"
},
"dependencies": {
"@getpaseo/protocol": "0.1.103",
"@getpaseo/relay": "0.1.103",
"@getpaseo/protocol": "0.1.104-beta.4",
"@getpaseo/relay": "0.1.104-beta.4",
"zod": "^4.4.3"
},
"devDependencies": {

View File

@@ -0,0 +1,74 @@
import type { AgentModelDefinition, ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
import { normalizeAgentModelDefinition } from "@getpaseo/protocol/agent-types";
import type {
GetProvidersSnapshotResponseMessage,
ListProviderModelsResponseMessage,
SessionOutboundMessage,
} from "@getpaseo/protocol/messages";
type ListProviderModelsPayload = ListProviderModelsResponseMessage["payload"];
type GetProvidersSnapshotPayload = GetProvidersSnapshotResponseMessage["payload"];
type ProvidersSnapshotUpdatePayload = Extract<
SessionOutboundMessage,
{ type: "providers_snapshot_update" }
>["payload"];
// COMPAT(model-normalize): daemon normalizes at source (provider-registry) — shim covers older daemons; drop when floor >= v0.1.104
function normalizeAgentModels(
models: AgentModelDefinition[] | undefined,
): AgentModelDefinition[] | undefined {
if (!models) {
return models;
}
let changed = false;
const normalized = models.map((model) => {
const next = normalizeAgentModelDefinition(model);
changed ||= next !== model;
return next;
});
return changed ? normalized : models;
}
function normalizeProviderSnapshotEntry(entry: ProviderSnapshotEntry): ProviderSnapshotEntry {
const models = normalizeAgentModels(entry.models);
return models === entry.models ? entry : { ...entry, models };
}
function normalizeProviderSnapshotEntries(
entries: ProviderSnapshotEntry[],
): ProviderSnapshotEntry[] {
let changed = false;
const normalized = entries.map((entry) => {
const next = normalizeProviderSnapshotEntry(entry);
changed ||= next !== entry;
return next;
});
return changed ? normalized : entries;
}
export function normalizeListProviderModelsPayload(
payload: ListProviderModelsPayload,
): ListProviderModelsPayload {
const models = normalizeAgentModels(payload.models);
return models === payload.models ? payload : { ...payload, models };
}
export function normalizeProvidersSnapshotPayload<
T extends GetProvidersSnapshotPayload | ProvidersSnapshotUpdatePayload,
>(payload: T): T {
const entries = normalizeProviderSnapshotEntries(payload.entries);
return entries === payload.entries ? payload : { ...payload, entries };
}
export function normalizeProviderSnapshotUpdateMessage(
msg: SessionOutboundMessage,
): SessionOutboundMessage {
if (msg.type !== "providers_snapshot_update") {
return msg;
}
return { ...msg, payload: normalizeProvidersSnapshotPayload(msg.payload) };
}

View File

@@ -2,6 +2,7 @@ import { afterEach, expect, expectTypeOf, test, vi } from "vitest";
import { z } from "zod";
import { DaemonClient, type DaemonTransport, type Logger } from "./daemon-client";
import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities";
import { BROWSER_AUTOMATION_COMMAND_NAMES } from "@getpaseo/protocol/browser-automation/rpc-schemas";
import {
decodeFileTransferFrame,
encodeFileTransferFrame,
@@ -164,7 +165,7 @@ test("does not infer browser automation capabilities from Electron runtime", asy
capabilities: z.record(z.unknown()),
})
.parse(JSON.parse(assertStr(mock.sent[0])));
expect(hello.capabilities[CLIENT_CAPS.desktopBrowserAutomation]).toBeUndefined();
expect(hello.capabilities[CLIENT_CAPS.browserHost]).toBeUndefined();
});
test("advertises consumer-provided browser automation capabilities", async () => {
@@ -174,7 +175,12 @@ test("advertises consumer-provided browser automation capabilities", async () =>
clientId: "browser_capability_unit_test",
transportFactory: () => mock.transport,
reconnect: { enabled: false },
capabilities: { [CLIENT_CAPS.desktopBrowserAutomation]: true },
capabilities: {
[CLIENT_CAPS.browserHost]: {
supportedCommands: [...BROWSER_AUTOMATION_COMMAND_NAMES],
hostKind: "desktop app",
},
},
});
clients.push(client);
@@ -188,7 +194,10 @@ test("advertises consumer-provided browser automation capabilities", async () =>
capabilities: z.record(z.unknown()),
})
.parse(JSON.parse(assertStr(mock.sent[0])));
expect(hello.capabilities[CLIENT_CAPS.desktopBrowserAutomation]).toBe(true);
expect(hello.capabilities[CLIENT_CAPS.browserHost]).toEqual({
supportedCommands: [...BROWSER_AUTOMATION_COMMAND_NAMES],
hostKind: "desktop app",
});
});
const noopLogger: Logger = {
@@ -506,7 +515,12 @@ test("advertises client capabilities in hello", async () => {
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
capabilities: { desktop_browser_automation: true },
capabilities: {
browser_host: {
supportedCommands: ["list_tabs"],
hostKind: "desktop app",
},
},
});
clients.push(client);
@@ -524,7 +538,10 @@ test("advertises client capabilities in hello", async () => {
custom_mode_icons: true,
reasoning_merge_enum: true,
terminal_reflowable_snapshot: true,
desktop_browser_automation: true,
browser_host: {
supportedCommands: ["list_tabs"],
hostKind: "desktop app",
},
},
});
});

View File

@@ -13,8 +13,8 @@ import {
DaemonUpdateResponseSchema,
SessionInboundMessageSchema,
type ServerInfoStatusPayload,
WSOutboundMessageSchema,
} from "@getpaseo/protocol/messages";
import { validateWSOutboundMessage } from "@getpaseo/protocol/validation/ws-outbound";
import type {
AgentStreamEventPayload,
AgentSnapshotPayload,
@@ -118,6 +118,11 @@ import {
type WebSocketFactory,
} from "./daemon-client-transport.js";
import { DaemonClientRuntimeMetrics } from "./daemon-client-runtime-metrics.js";
import {
normalizeListProviderModelsPayload,
normalizeProviderSnapshotUpdateMessage,
normalizeProvidersSnapshotPayload,
} from "./compat/normalize-provider-models.js";
import { TerminalStreamRouter, type TerminalStreamEvent } from "./terminal-stream-router.js";
import type {
BrowserAutomationExecuteRequest,
@@ -252,7 +257,7 @@ export interface DaemonClientConfig {
};
runtimeMetricsIntervalMs?: number;
runtimeMetricsWindowMs?: number;
capabilities?: Partial<Record<ClientCapability, boolean>>;
capabilities?: Partial<Record<ClientCapability, unknown>>;
}
export interface SendMessageOptions {
@@ -370,6 +375,7 @@ type WriteProjectConfigPayload = Extract<
SessionOutboundMessage,
{ type: "write_project_config_response" }
>["payload"];
type ListCommandsPayload = ListCommandsResponse["payload"];
type ListCommandsDraftConfig = Pick<
AgentSessionConfig,
@@ -3728,7 +3734,7 @@ export class DaemonClient {
provider: AgentProvider,
options?: { cwd?: string; requestId?: string },
): Promise<ListProviderModelsPayload> {
return this.sendCorrelatedSessionRequest({
const payload = await this.sendCorrelatedSessionRequest({
requestId: options?.requestId,
message: {
type: "list_provider_models_request",
@@ -3739,6 +3745,7 @@ export class DaemonClient {
// Provider SDK cold starts (especially model discovery) can exceed 60s.
timeout: 90000,
});
return normalizeListProviderModelsPayload(payload);
}
async listProviderModes(
@@ -3788,7 +3795,7 @@ export class DaemonClient {
cwd?: string;
requestId?: string;
}): Promise<GetProvidersSnapshotPayload> {
return this.sendCorrelatedSessionRequest({
const payload = await this.sendCorrelatedSessionRequest({
requestId: options?.requestId,
message: {
type: "get_providers_snapshot_request",
@@ -3796,6 +3803,7 @@ export class DaemonClient {
},
responseType: "get_providers_snapshot_response",
});
return normalizeProvidersSnapshotPayload(payload);
}
async getDaemonConfig(
@@ -4754,7 +4762,7 @@ export class DaemonClient {
return;
}
const parsed = WSOutboundMessageSchema.safeParse(parsedJson);
const parsed = validateWSOutboundMessage(parsedJson);
if (!parsed.success) {
const msgType =
parsedJson != null &&
@@ -5017,8 +5025,10 @@ export class DaemonClient {
}
private handleSessionMessage(msg: SessionOutboundMessage): void {
if (msg.type === "status") {
const serverInfo = parseServerInfoStatusPayload(msg.payload);
const consumerMessage = normalizeProviderSnapshotUpdateMessage(msg);
if (consumerMessage.type === "status") {
const serverInfo = parseServerInfoStatusPayload(consumerMessage.payload);
if (serverInfo) {
this.lastServerInfoMessage = serverInfo;
if (this.connectionState.status === "connecting") {
@@ -5034,39 +5044,39 @@ export class DaemonClient {
}
}
if (msg.type === "terminal_stream_exit") {
this.terminalStreams.removeTerminal(msg.payload.terminalId);
if (consumerMessage.type === "terminal_stream_exit") {
this.terminalStreams.removeTerminal(consumerMessage.payload.terminalId);
}
if (this.rawMessageListeners.size > 0) {
for (const handler of this.rawMessageListeners) {
try {
handler(msg);
handler(consumerMessage);
} catch {
// no-op
}
}
}
const handlers = this.messageHandlers.get(msg.type);
const handlers = this.messageHandlers.get(consumerMessage.type);
if (handlers) {
for (const handler of handlers) {
try {
handler(msg);
handler(consumerMessage);
} catch {
// no-op
}
}
}
const event = this.toEvent(msg);
const event = this.toEvent(consumerMessage);
if (event) {
for (const handler of this.eventListeners) {
handler(event);
}
}
this.resolveWaiters(msg);
this.resolveWaiters(consumerMessage);
}
private resolveWaiters(msg: SessionOutboundMessage): void {

View File

@@ -629,20 +629,37 @@ test("provider actions delegate to existing provider RPCs and local snapshot upd
});
const snapshotUpdates: string[] = [];
const snapshotModelDefaults: Array<string | undefined> = [];
const unsubscribe = client.providers.subscribe((update) => {
snapshotUpdates.push(update.generatedAt);
snapshotModelDefaults.push(update.entries[0]?.models?.[0]?.defaultThinkingOptionId);
});
ws.message(
sessionMessage({
type: "providers_snapshot_update",
payload: {
cwd: "/repo/sdk",
entries: [{ provider: "codex", status: "ready", enabled: true }],
entries: [
{
provider: "codex",
status: "ready",
enabled: true,
models: [
{
provider: "codex",
id: "gpt-5.5",
label: "GPT-5.5",
thinkingOptions: [{ id: "high", label: "High", isDefault: true }],
},
],
},
],
generatedAt: "2026-05-16T01:00:00.000Z",
},
}),
);
expect(snapshotUpdates).toEqual(["2026-05-16T01:00:00.000Z"]);
expect(snapshotModelDefaults).toEqual(["high"]);
unsubscribe();
await client.close();

View File

@@ -0,0 +1,2 @@
out/
fatal-error.txt

View File

@@ -0,0 +1,89 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Paseo Capture Harness Target</title>
<style>
html,
body {
width: 1280px;
min-height: 1600px;
margin: 0;
background: #ff00ff;
overflow: auto;
font-family: Arial, Helvetica, sans-serif;
}
.label {
position: absolute;
left: 48px;
top: 48px;
color: #ffffff;
font-size: 72px;
font-weight: 800;
line-height: 1;
text-shadow:
5px 5px 0 #000000,
-2px -2px 0 #000000;
}
.sub {
position: absolute;
left: 54px;
top: 150px;
color: #000000;
background: #ffffff;
font-size: 34px;
font-weight: 700;
padding: 10px 18px;
}
.block {
position: absolute;
left: 56px;
top: 220px;
width: 360px;
height: 92px;
background: linear-gradient(
90deg,
#000000 0 25%,
#ffffff 25% 50%,
#000000 50% 75%,
#ffffff 75% 100%
);
border: 8px solid #000000;
}
.bottom {
position: absolute;
left: 48px;
top: 1370px;
color: #ffffff;
font-size: 60px;
font-weight: 800;
text-shadow: 5px 5px 0 #000000;
}
</style>
</head>
<body>
<div class="label">PASEO CAPTURE HARNESS</div>
<div class="sub">LOCAL MAGENTA TARGET 1280x800 VIEWPORT</div>
<div class="block"></div>
<div class="bottom">FULL PAGE MARKER AT Y=1370</div>
<script>
const params = new URLSearchParams(window.location.search);
const label = params.get("label");
const sub = params.get("sub");
const bottom = params.get("bottom");
if (label) {
document.querySelector(".label").textContent = label;
}
if (sub) {
document.querySelector(".sub").textContent = sub;
}
if (bottom) {
document.querySelector(".bottom").textContent = bottom;
}
</script>
</body>
</html>

View File

@@ -0,0 +1,93 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Paseo Delayed Capture Harness Target</title>
<style>
html,
body {
width: 1280px;
min-height: 1600px;
margin: 0;
background: #202020;
overflow: auto;
font-family: Arial, Helvetica, sans-serif;
}
.content {
opacity: 0;
}
body.ready {
background: #ff00ff;
}
body.ready .content {
opacity: 1;
}
.label {
position: absolute;
left: 48px;
top: 48px;
color: #ffffff;
font-size: 72px;
font-weight: 800;
line-height: 1;
text-shadow:
5px 5px 0 #000000,
-2px -2px 0 #000000;
}
.sub {
position: absolute;
left: 54px;
top: 150px;
color: #000000;
background: #ffffff;
font-size: 34px;
font-weight: 700;
padding: 10px 18px;
}
.block {
position: absolute;
left: 56px;
top: 220px;
width: 360px;
height: 92px;
background: linear-gradient(
90deg,
#000000 0 25%,
#ffffff 25% 50%,
#000000 50% 75%,
#ffffff 75% 100%
);
border: 8px solid #000000;
}
.bottom {
position: absolute;
left: 48px;
top: 1370px;
color: #ffffff;
font-size: 60px;
font-weight: 800;
text-shadow: 5px 5px 0 #000000;
}
</style>
<script>
setTimeout(() => {
document.body.classList.add("ready");
}, 1500);
</script>
</head>
<body>
<div class="content">
<div class="label">PASEO CAPTURE HARNESS</div>
<div class="sub">DELAYED FIRST PAINT TARGET</div>
<div class="block"></div>
<div class="bottom">FULL PAGE MARKER AT Y=1370</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,376 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Paseo Browser Capture Harness</title>
<style>
html,
body {
width: 100%;
height: 100%;
margin: 0;
background: #202020;
overflow: hidden;
font-family: Arial, sans-serif;
}
#paseo-browser-resident-webviews {
position: fixed;
left: 0;
top: 0;
width: 1px;
height: 1px;
overflow: hidden;
opacity: 1;
pointer-events: none;
display: block;
visibility: visible;
}
.capture-harness-webview {
display: inline-flex;
flex: 0 0 auto;
width: 1280px;
height: 800px;
border: 0;
background: transparent;
}
</style>
</head>
<body>
<div id="paseo-browser-resident-webviews" aria-hidden="true"></div>
<script>
const RESIDENT_VIEWPORT_WIDTH = 1280;
const RESIDENT_VIEWPORT_HEIGHT = 800;
const host = document.getElementById("paseo-browser-resident-webviews");
const params = new URLSearchParams(window.location.search);
const requestedWebviewCount = params.has("webviewCount")
? Number(params.get("webviewCount"))
: 2;
const webviewCount = Number.isFinite(requestedWebviewCount)
? Math.max(0, requestedWebviewCount)
: 2;
const webviews = [];
const activeTokens = new Set();
let nextTokenId = 0;
let permanentParkingState = params.get("permanentParkingState") || "";
function appendHarnessWebview(sourceUrl) {
const index = webviews.length;
const webview = document.createElement("webview");
webview.id = `target-webview-${index + 1}`;
webview.className = "capture-harness-webview";
webview.setAttribute("data-paseo-browser-id", `capture-harness-${index + 1}`);
webview.setAttribute("partition", `persist:paseo-capture-harness-${index + 1}`);
webview.setAttribute("allowpopups", "true");
webview.setAttribute("spellcheck", "false");
webview.setAttribute("autosize", "on");
webview.src = sourceUrl;
applyStackedWebviewStyle(webview);
host.appendChild(webview);
webviews.push(webview);
return index;
}
for (let index = 0; index < webviewCount; index += 1) {
appendHarnessWebview(params.get("targetUrl") || "bright.html");
}
function applyHostParking() {
host.style.position = "fixed";
host.style.left = "-20000px";
host.style.top = "0";
host.style.width = `${RESIDENT_VIEWPORT_WIDTH}px`;
host.style.height = `${RESIDENT_VIEWPORT_HEIGHT}px`;
host.style.overflow = "hidden";
host.style.opacity = "0";
host.style.pointerEvents = "none";
host.style.zIndex = "";
host.style.clipPath = "";
host.style.visibility = "";
host.style.transform = "";
}
function applyWebviewBaseStyle(webview) {
webview.style.display = "inline-flex";
webview.style.flex = "0 0 auto";
webview.style.width = `${RESIDENT_VIEWPORT_WIDTH}px`;
webview.style.height = `${RESIDENT_VIEWPORT_HEIGHT}px`;
webview.style.border = "0";
webview.style.background = "transparent";
}
function applyLegacyVerticalWebviewStyle(webview, index) {
applyWebviewBaseStyle(webview);
webview.style.position = "";
webview.style.left = "";
webview.style.top = "";
webview.style.marginTop = index === 0 ? "0" : "4px";
webview.style.zIndex = "";
}
function applyStackedWebviewStyle(webview, zIndex = "0") {
applyWebviewBaseStyle(webview);
webview.style.position = "absolute";
webview.style.left = "0";
webview.style.top = "0";
webview.style.marginTop = "0";
webview.style.zIndex = zIndex;
}
function applyParking() {
permanentParkingState = "";
applyHostParking();
webviews.forEach((webview) => {
applyStackedWebviewStyle(webview);
});
}
function applyLegacyVerticalParking() {
applyHostParking();
webviews.forEach((webview, index) => {
applyLegacyVerticalWebviewStyle(webview, index);
});
}
function applyCapturePrep() {
permanentParkingState = "";
host.style.position = "fixed";
host.style.left = "0";
host.style.top = "0";
host.style.width = "1px";
host.style.height = "1px";
host.style.overflow = "hidden";
host.style.opacity = "1";
host.style.pointerEvents = "none";
host.style.zIndex = "1";
host.style.clipPath = "";
host.style.visibility = "";
host.style.transform = "";
}
function applyHostPermanentBase() {
host.style.position = "fixed";
host.style.left = "0";
host.style.top = "0";
host.style.width = "1px";
host.style.height = "1px";
host.style.overflow = "hidden";
host.style.opacity = "1";
host.style.pointerEvents = "none";
host.style.display = "block";
host.style.zIndex = "";
host.style.clipPath = "";
host.style.visibility = "visible";
host.style.transform = "";
host.style.transformOrigin = "";
}
function applyPermanentWebviewBase(webview, index) {
applyStackedWebviewStyle(webview);
webview.style.opacity = "";
webview.style.transform = "";
webview.style.pointerEvents = "";
webview.style.zIndex = "0";
webview.style.width = `${RESIDENT_VIEWPORT_WIDTH}px`;
webview.style.height = `${RESIDENT_VIEWPORT_HEIGHT}px`;
webview.style.left = "0";
webview.style.top = "0";
}
function applyPermanentParkingState(stateName) {
permanentParkingState = stateName;
activeTokens.clear();
applyHostPermanentBase();
webviews.forEach(applyPermanentWebviewBase);
if (stateName === "p1-overflow-1x1") {
return state();
}
if (stateName === "p2-clip-path-1x1") {
host.style.width = "1px";
host.style.height = "1px";
host.style.overflow = "visible";
host.style.clipPath = "inset(1px)";
return state();
}
if (stateName === "p3-opacity-0") {
host.style.opacity = "0";
return state();
}
if (stateName === "p4-transform-scale-0") {
host.style.transform = "scale(0)";
return state();
}
if (stateName === "p4-transform-scale-0001") {
host.style.transform = "scale(0.001)";
return state();
}
if (stateName === "p5-webview-0x0") {
webviews.forEach((webview) => {
webview.style.width = "0";
webview.style.height = "0";
});
return state();
}
if (stateName === "p5-webview-1x1") {
webviews.forEach((webview) => {
webview.style.width = "1px";
webview.style.height = "1px";
});
return state();
}
if (stateName === "p6-z-index-negative") {
host.style.zIndex = "-1";
return state();
}
if (stateName === "p7-opacity-001") {
host.style.opacity = "0.01";
return state();
}
throw new Error(`Unknown permanent parking state: ${stateName}`);
}
function applyTargetStacking(targetIndex) {
webviews.forEach((webview, index) => {
applyStackedWebviewStyle(webview, index === targetIndex ? "2" : "0");
});
}
function waitFrames(count) {
return new Promise((resolve) => {
function step(remaining) {
if (remaining <= 0) {
resolve();
return;
}
requestAnimationFrame(() => step(remaining - 1));
}
step(count);
});
}
function state(targetIndex = 0) {
const hostRect = host.getBoundingClientRect();
const targetWebview = webviews[targetIndex] || webviews[0];
const webviewRect = targetWebview?.getBoundingClientRect();
return {
hostStyle: {
left: host.style.left,
top: host.style.top,
width: host.style.width,
height: host.style.height,
overflow: host.style.overflow,
opacity: host.style.opacity,
pointerEvents: host.style.pointerEvents,
zIndex: host.style.zIndex,
clipPath: host.style.clipPath,
transform: host.style.transform,
},
hostRect: {
x: hostRect.x,
y: hostRect.y,
width: hostRect.width,
height: hostRect.height,
},
webviewRect: {
x: webviewRect?.x ?? 0,
y: webviewRect?.y ?? 0,
width: webviewRect?.width ?? 0,
height: webviewRect?.height ?? 0,
},
webviews: webviews.map((webview) => {
const rect = webview.getBoundingClientRect();
return {
id: webview.id,
zIndex: webview.style.zIndex,
position: webview.style.position,
opacity: webview.style.opacity,
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
};
}),
};
}
window.captureHarness = {
async waitForFrames(count = 2) {
await waitFrames(count);
return state();
},
webContentsIds() {
return webviews.map((webview) =>
typeof webview.getWebContentsId === "function" ? webview.getWebContentsId() : null,
);
},
addWebview(sourceUrl) {
return appendHarnessWebview(sourceUrl || params.get("targetUrl") || "bright.html");
},
addPermanentWebview(sourceUrl, stateName) {
const index = appendHarnessWebview(sourceUrl || params.get("targetUrl") || "bright.html");
applyPermanentParkingState(stateName || permanentParkingState);
return index;
},
async applyPermanentParkingState(stateName) {
const nextState = applyPermanentParkingState(stateName);
await waitFrames(2);
return nextState;
},
async prepareForPixelCapture(targetIndex = 0) {
const token = `capture-${++nextTokenId}`;
activeTokens.add(token);
applyCapturePrep();
applyTargetStacking(targetIndex);
await waitFrames(2);
webviews[targetIndex].getBoundingClientRect();
return { token, state: state(targetIndex) };
},
async prepareLegacyVerticalPixelCapture(targetIndex = 0) {
const token = `legacy-capture-${++nextTokenId}`;
activeTokens.add(token);
applyLegacyVerticalParking();
applyCapturePrep();
await waitFrames(2);
webviews[targetIndex].getBoundingClientRect();
return { token, state: state(targetIndex) };
},
async restorePixelCapture(token) {
activeTokens.delete(token);
if (activeTokens.size === 0) {
applyParking();
}
await waitFrames(2);
return state();
},
async restoreLegacyVerticalParking(token) {
activeTokens.delete(token);
applyLegacyVerticalParking();
await waitFrames(2);
return state();
},
async restoreParking() {
activeTokens.clear();
applyParking();
await waitFrames(2);
return state();
},
};
if (permanentParkingState) {
applyPermanentParkingState(permanentParkingState);
}
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
#!/bin/sh
set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/../../.." && pwd)
exec "$REPO_ROOT/node_modules/.bin/electron" "$SCRIPT_DIR/main.js"

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.1.103",
"version": "0.1.104-beta.4",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",
@@ -17,6 +17,7 @@
"scripts": {
"build": "npm --prefix ../.. run build:server:clean && npm run build:main && electron-builder --config electron-builder.yml",
"build:main": "tsc -p tsconfig.json",
"capture-harness": "./capture-harness/run.sh",
"dev": "./scripts/dev.sh",
"dev:win": "powershell ./scripts/dev.ps1",
"test": "vitest run",

View File

@@ -0,0 +1,198 @@
import type { SnapshotPage } from "./snapshot-engine.js";
export interface ActionablePoint {
x: number;
y: number;
}
export interface ActionableTarget {
point: ActionablePoint;
rect: {
x: number;
y: number;
width: number;
height: number;
};
}
export type ActionabilityResult =
| { ok: true; target: ActionableTarget }
| { ok: false; reason: "stale_ref" | "timeout"; detail?: string };
const DEFAULT_ACTIONABILITY_TIMEOUT_MS = 5_000;
export async function waitForActionableTarget(input: {
page: SnapshotPage;
elementExpression: string;
editable?: boolean;
timeoutMs?: number;
}): Promise<ActionabilityResult> {
const result = await input.page.executeJavaScript(
buildActionabilityScript({
elementExpression: input.elementExpression,
editable: input.editable === true,
timeoutMs: input.timeoutMs ?? DEFAULT_ACTIONABILITY_TIMEOUT_MS,
}),
);
return readActionabilityResult(result);
}
function readActionabilityResult(value: unknown): ActionabilityResult {
if (!value || typeof value !== "object") {
return { ok: false, reason: "timeout" };
}
const record = value as Record<string, unknown>;
if (record.ok === true && isActionableTarget(record.target)) {
return { ok: true, target: record.target };
}
if (record.ok === false) {
const reason = record.reason;
if (reason === "stale_ref" || reason === "timeout") {
return {
ok: false,
reason,
...(typeof record.detail === "string" ? { detail: record.detail } : {}),
};
}
}
return { ok: false, reason: "timeout" };
}
function isActionableTarget(value: unknown): value is ActionableTarget {
if (!value || typeof value !== "object") {
return false;
}
const record = value as Record<string, unknown>;
return isPoint(record.point) && isRect(record.rect);
}
function isPoint(value: unknown): value is ActionablePoint {
if (!value || typeof value !== "object") {
return false;
}
const record = value as Record<string, unknown>;
return isFiniteNumber(record.x) && isFiniteNumber(record.y);
}
function isRect(value: unknown): value is ActionableTarget["rect"] {
if (!value || typeof value !== "object") {
return false;
}
const record = value as Record<string, unknown>;
return (
isFiniteNumber(record.x) &&
isFiniteNumber(record.y) &&
isFiniteNumber(record.width) &&
isFiniteNumber(record.height)
);
}
function isFiniteNumber(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value);
}
function buildActionabilityScript(input: {
elementExpression: string;
editable: boolean;
timeoutMs: number;
}): string {
return String.raw`(async () => {
const deadline = performance.now() + ${JSON.stringify(input.timeoutMs)};
const requiresEditable = ${JSON.stringify(input.editable)};
const nextFrame = () => new Promise((resolve) => requestAnimationFrame(resolve));
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const nearlyEqual = (a, b) => Math.abs(a - b) < 0.25;
const sameRect = (a, b) =>
nearlyEqual(a.x, b.x) &&
nearlyEqual(a.y, b.y) &&
nearlyEqual(a.width, b.width) &&
nearlyEqual(a.height, b.height);
const rectPayload = (rect) => ({
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
});
const centerPoint = (rect) => ({
x: Math.min(Math.max(rect.left + rect.width / 2, 0), Math.max(window.innerWidth - 1, 0)),
y: Math.min(Math.max(rect.top + rect.height / 2, 0), Math.max(window.innerHeight - 1, 0)),
});
const isDisabled = (element) => {
if (element.closest?.('[aria-disabled="true"]')) return true;
if ('disabled' in element && element.disabled) return true;
const fieldset = element.closest?.('fieldset[disabled]');
return Boolean(fieldset);
};
const isEditable = (element) => {
if (element.isContentEditable) return true;
const tag = element.tagName?.toLowerCase();
if (tag === 'textarea' || tag === 'select') return !element.readOnly && !isDisabled(element);
if (tag !== 'input') return false;
const type = (element.getAttribute('type') || 'text').toLowerCase();
if (['button', 'checkbox', 'color', 'file', 'hidden', 'image', 'radio', 'range', 'reset', 'submit'].includes(type)) return false;
return !element.readOnly && !isDisabled(element);
};
const isVisible = (element, rect) => {
const style = getComputedStyle(element);
return (
rect.width > 0 &&
rect.height > 0 &&
style.visibility !== 'hidden' &&
style.display !== 'none' &&
Number(style.opacity || '1') !== 0
);
};
const hitTargetReceivesEvents = (element, point) => {
const hit = document.elementFromPoint(point.x, point.y);
return Boolean(hit && (hit === element || element.contains(hit)));
};
const resolveElement = () => (${input.elementExpression});
let detail = 'not actionable';
while (performance.now() <= deadline) {
const element = resolveElement();
if (!element || !element.isConnected) {
return { ok: false, reason: 'stale_ref', detail: 'ref no longer resolves' };
}
const rect = element.getBoundingClientRect();
if (!isVisible(element, rect)) {
detail = 'not visible';
await sleep(25);
continue;
}
if (isDisabled(element)) {
detail = 'disabled';
await sleep(25);
continue;
}
if (requiresEditable && !isEditable(element)) {
detail = 'not editable';
await sleep(25);
continue;
}
element.scrollIntoView?.({ block: 'center', inline: 'center' });
await nextFrame();
const firstRect = element.getBoundingClientRect();
await nextFrame();
const secondRect = element.getBoundingClientRect();
if (!sameRect(firstRect, secondRect)) {
detail = 'moving';
continue;
}
const point = centerPoint(secondRect);
if (!hitTargetReceivesEvents(element, point)) {
detail = 'covered';
await sleep(25);
continue;
}
return { ok: true, target: { point, rect: rectPayload(secondRect) } };
}
return { ok: false, reason: 'timeout', detail };
})()`;
}

View File

@@ -0,0 +1,311 @@
export const ARIA_SNAPSHOT_SCRIPT_MARKER = "__PASEO_ARIA_SNAPSHOT__";
// Adapted from Playwright's injected ARIA snapshot model.
// Copyright (c) Microsoft Corporation. Licensed under the Apache License, Version 2.0.
export const ARIA_SNAPSHOT_SCRIPT = String.raw`(() => {
const MARKER = ${JSON.stringify(ARIA_SNAPSHOT_SCRIPT_MARKER)};
const MAX_NODES = 1500;
const MAX_REFS = 500;
const MAX_TEXT_LENGTH = 80000;
const ACTIONABLE_ROLES = new Set([
'button',
'checkbox',
'combobox',
'link',
'menuitem',
'option',
'radio',
'searchbox',
'slider',
'spinbutton',
'switch',
'tab',
'textbox',
'treeitem'
]);
function normalizeText(value) {
return String(value || '').replace(/[\u200b\u00ad]/g, '').replace(/[\r\n\s\t]+/g, ' ').trim();
}
function visibilityFor(element) {
if (!(element instanceof Element)) return false;
const style = window.getComputedStyle(element);
if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) return false;
const rect = element.getBoundingClientRect();
return rect.width > 0 && rect.height > 0 ? 'box' : 'boxless';
}
function explicitRole(element) {
const role = element.getAttribute('role');
if (!role || role === 'presentation' || role === 'none') return null;
return role.split(/\s+/)[0].toLowerCase();
}
function implicitRole(element) {
const tag = element.tagName.toLowerCase();
if (/^h[1-6]$/.test(tag)) return 'heading';
if (tag === 'a' && element.hasAttribute('href')) return 'link';
if (tag === 'button') return 'button';
if (tag === 'select') return 'combobox';
if (tag === 'textarea') return 'textbox';
if (element instanceof HTMLElement && element.isContentEditable) return 'textbox';
if (tag === 'summary') return 'button';
if (tag === 'main') return 'main';
if (tag === 'nav') return 'navigation';
if (tag === 'header') return 'banner';
if (tag === 'footer') return 'contentinfo';
if (tag === 'section') return element.getAttribute('aria-label') ? 'region' : null;
if (tag === 'ul' || tag === 'ol') return 'list';
if (tag === 'li') return 'listitem';
if (tag === 'table') return 'table';
if (tag === 'tr') return 'row';
if (tag === 'th') return 'columnheader';
if (tag === 'td') return 'cell';
if (tag === 'iframe') return 'iframe';
if (tag === 'input') {
const type = (element.getAttribute('type') || 'text').toLowerCase();
if (type === 'checkbox') return 'checkbox';
if (type === 'radio') return 'radio';
if (type === 'button' || type === 'submit' || type === 'reset') return 'button';
if (type === 'range') return 'slider';
if (type === 'number') return 'spinbutton';
if (type === 'search') return 'searchbox';
if (type === 'hidden') return null;
return 'textbox';
}
return null;
}
function roleFor(element) {
return explicitRole(element) || implicitRole(element);
}
function labelText(element) {
if (!(element instanceof HTMLElement)) return '';
if (element.id) {
const escapedId = window.CSS && typeof window.CSS.escape === 'function'
? window.CSS.escape(element.id)
: String(element.id).replace(/"/g, '\\"');
const label = document.querySelector('label[for="' + escapedId + '"]');
if (label) return normalizeText(label.textContent);
}
const closestLabel = element.closest('label');
return closestLabel ? normalizeText(closestLabel.textContent) : '';
}
function nameFor(element, role) {
const tag = element.tagName.toLowerCase();
const labelledBy = element.getAttribute('aria-labelledby');
if (labelledBy) {
const text = labelledBy.split(/\s+/).map((id) => document.getElementById(id)?.textContent || '').join(' ');
const normalized = normalizeText(text);
if (normalized) return normalized;
}
const pieces = [
element.getAttribute('aria-label'),
labelText(element),
element.getAttribute('alt'),
element.getAttribute('title'),
tag === 'input' || tag === 'textarea' ? element.getAttribute('placeholder') : null,
tag === 'input' || tag === 'textarea' ? element.value : null,
role === 'button' || role === 'link' || role === 'heading' || ACTIONABLE_ROLES.has(role)
? element.textContent
: null
];
return normalizeText(pieces.find((piece) => normalizeText(piece).length > 0) || '');
}
function fingerprintNameFor(element, role, name) {
const tag = element.tagName.toLowerCase();
if (tag !== 'input' && tag !== 'textarea') return name;
const mutableValue = normalizeText(element.value);
if (!mutableValue || name !== mutableValue) return name;
return '';
}
function inheritedDisabled(element) {
if (!(element instanceof Element)) return false;
if (element.hasAttribute('disabled') || element.getAttribute('aria-disabled') === 'true') return true;
if (element.closest('fieldset[disabled]')) return true;
return element.closest('[aria-disabled="true"]') !== null;
}
function isActionable(element, role) {
if (!role || !ACTIONABLE_ROLES.has(role)) return false;
if (visibilityFor(element) !== 'box') return false;
if (inheritedDisabled(element)) return false;
return true;
}
function fingerprintFor(element, role, name) {
return {
role,
name: fingerprintNameFor(element, role, name),
tagName: element.tagName.toLowerCase(),
type: element.getAttribute('type') || '',
ariaLabel: element.getAttribute('aria-label') || ''
};
}
function fingerprintMatches(element, fingerprint) {
const role = roleFor(element) || 'generic';
const name = nameFor(element, role);
const current = fingerprintFor(element, role, name);
return current.role === fingerprint.role &&
current.name === fingerprint.name &&
current.tagName === fingerprint.tagName &&
current.type === fingerprint.type &&
current.ariaLabel === fingerprint.ariaLabel;
}
function ensureRuntime() {
const runtime = {
refs: new Map(),
resolve(ref, fingerprint) {
const element = this.refs.get(ref);
if (!element || !element.isConnected || !fingerprintMatches(element, fingerprint)) {
return { ok: false, reason: 'stale_ref' };
}
return { ok: true, element };
}
};
Object.defineProperty(window, '__PASEO_BROWSER_AUTOMATION__', {
configurable: true,
enumerable: false,
value: runtime
});
return runtime;
}
function stateAttributes(element, role) {
const attrs = [];
if (role === 'heading') {
const tag = element.tagName.toLowerCase();
const level = /^h[1-6]$/.test(tag) ? Number(tag.slice(1)) : Number(element.getAttribute('aria-level') || 0);
if (level) attrs.push('level=' + level);
}
if (element.matches?.('input[type="checkbox"], input[type="radio"]')) {
attrs.push('checked=' + (element.checked ? 'true' : 'false'));
}
if (element.getAttribute('aria-checked')) attrs.push('checked=' + element.getAttribute('aria-checked'));
if (element.getAttribute('aria-expanded')) attrs.push('expanded=' + element.getAttribute('aria-expanded'));
if (element.getAttribute('aria-pressed')) attrs.push('pressed=' + element.getAttribute('aria-pressed'));
if (element.getAttribute('aria-selected')) attrs.push('selected=' + element.getAttribute('aria-selected'));
return attrs;
}
function textNode(text) {
return { kind: 'text', text };
}
function elementNode(element, role, name) {
return {
kind: 'role',
role: role || 'generic',
name,
tagName: element.tagName.toLowerCase(),
attributes: stateAttributes(element, role),
children: []
};
}
let nodeCount = 0;
let refCount = 0;
let iframeCount = 0;
let maxDepth = 0;
let truncated = false;
let textBudget = MAX_TEXT_LENGTH;
const runtime = ensureRuntime();
function countNode(depth) {
if (nodeCount >= MAX_NODES) {
truncated = true;
return false;
}
nodeCount += 1;
maxDepth = Math.max(maxDepth, depth);
return true;
}
function cappedText(text) {
if (text.length <= textBudget) {
textBudget -= text.length;
return text;
}
truncated = true;
const capped = text.slice(0, Math.max(0, textBudget));
textBudget = 0;
return capped;
}
function visitNode(domNode, depth) {
if (!countNode(depth)) return null;
if (domNode.nodeType === Node.TEXT_NODE) {
const text = cappedText(normalizeText(domNode.textContent));
if (!text) return null;
return textNode(text);
}
if (!(domNode instanceof Element)) return null;
const visibility = visibilityFor(domNode);
if (!visibility) return null;
if (domNode.getAttribute('aria-hidden') === 'true') return null;
const role = roleFor(domNode);
const name = role ? nameFor(domNode, role) : '';
const children = [];
for (const child of Array.from(domNode.childNodes)) {
const childSnapshot = visitNode(child, depth + 1);
if (childSnapshot) children.push(childSnapshot);
if (truncated) break;
}
if (domNode.tagName.toLowerCase() === 'iframe') {
iframeCount += 1;
}
if (visibility === 'boxless') {
return children.length > 0 ? { kind: 'group', children } : null;
}
if (!role && children.length === 0) return null;
const snapshotNode = role ? elementNode(domNode, role, name) : { kind: 'group', children: [] };
snapshotNode.children = children;
if (role && isActionable(domNode, role) && refCount < MAX_REFS) {
const ref = '@e' + (refCount + 1);
const fingerprint = fingerprintFor(domNode, role, name);
refCount += 1;
runtime.refs.set(ref, domNode);
snapshotNode.ref = ref;
snapshotNode.fingerprint = fingerprint;
} else if (role && isActionable(domNode, role)) {
truncated = true;
}
return snapshotNode;
}
const root = {
kind: 'role',
role: 'document',
name: normalizeText(document.title),
tagName: 'document',
attributes: [],
children: []
};
for (const child of Array.from(document.body ? document.body.childNodes : document.documentElement.childNodes)) {
const childSnapshot = visitNode(child, 1);
if (childSnapshot) root.children.push(childSnapshot);
if (truncated) break;
}
return JSON.stringify({
marker: MARKER,
root,
refs: Array.from(runtime.refs.entries()).map(([ref, element]) => {
const role = roleFor(element) || 'generic';
const name = nameFor(element, role);
return { ref, fingerprint: fingerprintFor(element, role, name) };
}),
truncated,
stats: { nodeCount, refCount, textLength: 0, iframeCount, maxDepth }
});
})()`;

View File

@@ -0,0 +1,28 @@
export type CdpCommandSender = (
command: string,
params?: Record<string, unknown>,
) => Promise<unknown>;
export class CdpSessionQueue {
private queue: Promise<void> = Promise.resolve();
public async run<T>(task: () => Promise<T>): Promise<T> {
const previous = this.queue;
let releaseCurrent = () => {};
const current = new Promise<void>((resolve) => {
releaseCurrent = resolve;
});
const tail = previous.catch(() => {}).then(() => current);
this.queue = tail;
await previous.catch(() => {});
try {
return await task();
} finally {
releaseCurrent();
if (this.queue === tail) {
this.queue = Promise.resolve();
}
}
}
}

View File

@@ -0,0 +1,89 @@
import type { BrowserAutomationDialogEvent } from "@getpaseo/protocol/browser-automation/rpc-schemas";
export const MAX_DIALOGS_PER_COMMAND = 20;
export interface JavaScriptDialogOpening {
type?: unknown;
message?: unknown;
defaultPrompt?: unknown;
}
interface DialogPolicy {
readonly action: BrowserAutomationDialogEvent["action"];
readonly accept: boolean;
}
const DIALOG_POLICIES: Record<BrowserAutomationDialogEvent["type"], DialogPolicy> = {
alert: { action: "accepted", accept: true },
confirm: { action: "dismissed", accept: false },
prompt: { action: "dismissed", accept: false },
beforeunload: { action: "dismissed", accept: false },
};
export function handledDialogEvent(opening: JavaScriptDialogOpening): BrowserAutomationDialogEvent {
const type = normalizeDialogType(opening.type);
const policy = DIALOG_POLICIES[type];
return {
type,
message: typeof opening.message === "string" ? opening.message : String(opening.message ?? ""),
...(typeof opening.defaultPrompt === "string" ? { defaultValue: opening.defaultPrompt } : {}),
action: policy.action,
timestamp: Date.now(),
};
}
export function dialogAcceptValue(type: BrowserAutomationDialogEvent["type"]): boolean {
return DIALOG_POLICIES[type].accept;
}
export function promptShimInstallScript(): string {
return String.raw`(() => {
const stateKey = "__PASEO_BROWSER_AUTOMATION_DIALOG_STATE__";
const state = window[stateKey] || { prompts: [], installed: false };
window[stateKey] = state;
if (state.installed) return true;
const originalPrompt = window.prompt;
Object.defineProperty(state, "originalPrompt", { configurable: true, value: originalPrompt });
const promptShim = (message = "", defaultValue = "") => {
state.prompts.push({
type: "prompt",
message: String(message ?? ""),
defaultValue: String(defaultValue ?? ""),
action: "dismissed",
timestamp: Date.now(),
});
return null;
};
Object.defineProperty(state, "promptShim", { configurable: true, value: promptShim });
window.prompt = promptShim;
state.installed = true;
return true;
})()`;
}
export function promptShimDrainScript(): string {
return String.raw`(() => {
const state = window.__PASEO_BROWSER_AUTOMATION_DIALOG_STATE__;
if (!state || !Array.isArray(state.prompts)) return [];
return state.prompts.splice(0);
})()`;
}
export function promptShimRestoreScript(): string {
return String.raw`(() => {
const stateKey = "__PASEO_BROWSER_AUTOMATION_DIALOG_STATE__";
const state = window[stateKey];
if (!state || !state.installed) return true;
if (window.prompt === state.promptShim && typeof state.originalPrompt === "function") {
window.prompt = state.originalPrompt;
}
delete window[stateKey];
return true;
})()`;
}
function normalizeDialogType(value: unknown): BrowserAutomationDialogEvent["type"] {
return value === "alert" || value === "confirm" || value === "prompt" || value === "beforeunload"
? value
: "alert";
}

View File

@@ -1,18 +1,552 @@
import { describe, expect, it } from "vitest";
import type { Rectangle } from "electron";
import { describe, expect, test, vi } from "vitest";
import type { TabImage } from "./service.js";
import { adaptWebContents } from "./ipc.js";
import { sanitizeDownloadFileName } from "./ipc.js";
class FakeImage implements TabImage {
public toPNG(): Uint8Array {
return new Uint8Array([137, 80, 78, 71]);
}
describe("browser automation IPC", () => {
it("strips directories from agent-supplied download filenames", () => {
expect(
sanitizeDownloadFileName({
url: "https://example.com/fallback.txt",
fileName: "../../.ssh/authorized_keys",
}),
).toBe("authorized_keys");
public getSize(): { width: number; height: number } {
return { width: 640, height: 480 };
}
}
class FakeDebugger {
public attachedProtocolVersions: string[] = [];
public commands: Array<{ command: string; params: Record<string, unknown> }> = [];
public blockCommands = false;
public readonly blockedCommandNames = new Set<string>();
public readonly failedCommandNames = new Set<string>();
public readonly promptDialogs: unknown[] = [];
public failPromptDrain = false;
private messageListener:
| ((event: unknown, method: string, params?: Record<string, unknown>) => void)
| null = null;
private readonly blockedCommands: Array<() => void> = [];
public isAttached(): boolean {
return this.attachedProtocolVersions.length > 0;
}
public attach(protocolVersion?: string): void {
this.attachedProtocolVersions.push(protocolVersion ?? "");
}
public async sendCommand(command: string, params?: Record<string, unknown>): Promise<unknown> {
this.commands.push({ command, params: params ?? {} });
if (this.failedCommandNames.has(command)) {
throw new Error(`${command} failed`);
}
if (this.blockCommands || this.blockedCommandNames.has(command)) {
await new Promise<void>((resolve) => {
this.blockedCommands.push(resolve);
});
}
if (command === "Runtime.evaluate" && typeof params?.expression === "string") {
if (params.expression.includes("state.prompts.splice(0)")) {
if (this.failPromptDrain) {
throw new Error("execution context destroyed");
}
return { result: { value: this.promptDialogs.splice(0) } };
}
return { result: { value: true } };
}
return { ok: true };
}
public on(
event: "message",
listener: (event: unknown, method: string, params?: Record<string, unknown>) => void,
): void {
expect(event).toBe("message");
this.messageListener = listener;
}
public emitMessage(method: string, params?: Record<string, unknown>): void {
if (!this.messageListener) {
throw new Error("Debugger message listener was not registered");
}
this.messageListener({}, method, params);
}
public finishNextCommand(): void {
const resolve = this.blockedCommands.shift();
if (!resolve) {
throw new Error("No command is blocked");
}
resolve();
}
}
type ConsoleMessageListener = (
event: unknown,
level: unknown,
message: unknown,
line: unknown,
sourceId: unknown,
) => void;
class FakeWebContents {
public readonly debugger = new FakeDebugger();
public readonly captures: Array<{
rect: Rectangle | undefined;
options: { stayHidden?: boolean } | undefined;
}> = [];
public readonly invalidations: string[] = [];
private consoleMessageListener: ConsoleMessageListener | null = null;
private destroyedListener: (() => void) | null = null;
public destroyed = false;
public constructor(public readonly id: number) {}
public getURL(): string {
return "https://example.com";
}
public getTitle(): string {
return "Example";
}
public canGoBack(): boolean {
return false;
}
public canGoForward(): boolean {
return false;
}
public isLoading(): boolean {
return false;
}
public isDestroyed(): boolean {
return this.destroyed;
}
public async executeJavaScript(): Promise<unknown> {
return null;
}
public async loadURL(): Promise<void> {}
public goBack(): void {}
public goForward(): void {}
public reload(): void {}
public async capturePage(
rect?: Rectangle,
options?: { stayHidden?: boolean },
): Promise<TabImage> {
this.captures.push({ rect, options });
return new FakeImage();
}
public invalidate(): void {
this.invalidations.push("invalidate");
}
public on(event: "console-message", listener: ConsoleMessageListener): void {
expect(event).toBe("console-message");
this.consoleMessageListener = listener;
}
public once(event: "destroyed", listener: () => void): void {
expect(event).toBe("destroyed");
this.destroyedListener = listener;
}
public emitConsoleMessage(input: {
level: unknown;
message: unknown;
line: unknown;
sourceId: unknown;
}): void {
if (!this.consoleMessageListener) {
throw new Error("Console listener was not registered");
}
this.consoleMessageListener({}, input.level, input.message, input.line, input.sourceId);
}
public destroy(): void {
this.destroyed = true;
this.destroyedListener?.();
}
}
describe("browser automation IPC adapter", () => {
test("delegates viewport capture to the guest without a renderer prep bridge", async () => {
const contents = new FakeWebContents(20);
const tab = adaptWebContents(contents);
const image = await tab.capturePage({ stayHidden: false });
tab.invalidate();
expect(image.getSize()).toEqual({ width: 640, height: 480 });
expect(contents.captures).toEqual([{ rect: undefined, options: { stayHidden: false } }]);
expect(contents.invalidations).toEqual(["invalidate"]);
});
it("falls back to a safe filename when the URL has no basename", () => {
expect(sanitizeDownloadFileName({ url: "https://example.com/" })).toBe("download");
test("collects console messages until the guest is destroyed", () => {
const contents = new FakeWebContents(21);
const tab = adaptWebContents(contents);
contents.emitConsoleMessage({
level: "warning",
message: "hello",
line: 12,
sourceId: "https://example.com/app.js",
});
expect(tab.getConsoleMessages?.()).toEqual([
{
level: "warning",
message: "hello",
line: 12,
source: "https://example.com/app.js",
timestamp: expect.any(Number),
},
]);
contents.destroy();
expect(tab.getConsoleMessages?.()).toEqual([]);
});
test("attaches the debugger before sending a CDP command", async () => {
const contents = new FakeWebContents(22);
const tab = adaptWebContents(contents);
const result = await tab.sendDebugCommand?.("Page.captureScreenshot", {
format: "png",
});
expect(result).toEqual({ ok: true });
expect(contents.debugger.attachedProtocolVersions).toEqual(["1.3"]);
expect(contents.debugger.commands).toEqual([
{ command: "Page.captureScreenshot", params: { format: "png" } },
]);
});
test("serializes CDP commands per guest contents", async () => {
const contents = new FakeWebContents(23);
contents.debugger.blockCommands = true;
const tab = adaptWebContents(contents);
const first = tab.sendDebugCommand?.("Input.dispatchMouseEvent", { type: "mouseMoved" });
const second = tab.sendDebugCommand?.("Page.captureScreenshot", { format: "png" });
await flushMicrotasks();
expect(contents.debugger.commands).toEqual([
{ command: "Input.dispatchMouseEvent", params: { type: "mouseMoved" } },
]);
contents.debugger.finishNextCommand();
await flushMicrotasks();
expect(contents.debugger.commands).toEqual([
{ command: "Input.dispatchMouseEvent", params: { type: "mouseMoved" } },
{ command: "Page.captureScreenshot", params: { format: "png" } },
]);
contents.debugger.finishNextCommand();
await expect(first).resolves.toEqual({ ok: true });
await expect(second).resolves.toEqual({ ok: true });
});
test("handles JavaScript dialogs through the per-tab CDP queue", async () => {
const contents = new FakeWebContents(24);
const tab = adaptWebContents(contents);
const captured = tab.captureDialogs?.(async () => {
contents.debugger.blockCommands = true;
const input = tab.sendDebugCommand?.("Input.dispatchMouseEvent", { type: "mouseReleased" });
await flushMicrotasks();
contents.debugger.emitMessage("Page.javascriptDialogOpening", {
type: "confirm",
message: "Delete item?",
});
await flushMicrotasks();
expect(contents.debugger.commands).toEqual([
{ command: "Page.enable", params: {} },
{
command: "Runtime.evaluate",
params: { expression: expect.any(String), returnByValue: true },
},
{ command: "Input.dispatchMouseEvent", params: { type: "mouseReleased" } },
{ command: "Page.handleJavaScriptDialog", params: { accept: false } },
]);
contents.debugger.blockCommands = false;
contents.debugger.finishNextCommand();
await input;
await flushMicrotasks();
return "done";
});
await expect(captured).resolves.toEqual({
result: "done",
dialogs: [
{
type: "confirm",
message: "Delete item?",
action: "dismissed",
timestamp: expect.any(Number),
},
],
});
expect(contents.debugger.commands).toEqual([
{ command: "Page.enable", params: {} },
{
command: "Runtime.evaluate",
params: { expression: expect.any(String), returnByValue: true },
},
{ command: "Input.dispatchMouseEvent", params: { type: "mouseReleased" } },
{ command: "Page.handleJavaScriptDialog", params: { accept: false } },
{
command: "Runtime.evaluate",
params: { expression: expect.any(String), returnByValue: true },
},
{
command: "Runtime.evaluate",
params: { expression: expect.any(String), returnByValue: true },
},
]);
});
test("handles JavaScript dialogs while the triggering CDP input command is still in flight", async () => {
const contents = new FakeWebContents(25);
const tab = adaptWebContents(contents);
const captured = tab.captureDialogs?.(async () => {
contents.debugger.blockedCommandNames.add("Input.dispatchMouseEvent");
const input = tab.sendDebugCommand?.("Input.dispatchMouseEvent", { type: "mousePressed" });
await flushMicrotasks();
contents.debugger.emitMessage("Page.javascriptDialogOpening", {
type: "alert",
message: "Saved",
});
await flushMicrotasks();
expect(contents.debugger.commands).toEqual([
{ command: "Page.enable", params: {} },
{
command: "Runtime.evaluate",
params: { expression: expect.any(String), returnByValue: true },
},
{ command: "Input.dispatchMouseEvent", params: { type: "mousePressed" } },
{ command: "Page.handleJavaScriptDialog", params: { accept: true } },
]);
contents.debugger.blockedCommandNames.clear();
contents.debugger.finishNextCommand();
await input;
return "done";
});
await expect(captured).resolves.toEqual({
result: "done",
dialogs: [
{
type: "alert",
message: "Saved",
action: "accepted",
timestamp: expect.any(Number),
},
],
});
expect(contents.debugger.commands.at(-1)).toEqual({
command: "Runtime.evaluate",
params: {
expression: expect.stringContaining("delete window[stateKey]"),
returnByValue: true,
},
});
});
test("keeps the prompt shim installed until overlapping captures finish", async () => {
const contents = new FakeWebContents(26);
const tab = adaptWebContents(contents);
const firstStarted = deferred<void>();
const secondStarted = deferred<void>();
const finishFirst = deferred<void>();
const finishSecond = deferred<void>();
const first = tab.captureDialogs?.(async () => {
firstStarted.resolve();
await finishFirst.promise;
return "first";
});
await firstStarted.promise;
const second = tab.captureDialogs?.(async () => {
secondStarted.resolve();
await finishSecond.promise;
return "second";
});
await secondStarted.promise;
contents.debugger.promptDialogs.push(
{
type: "prompt",
message: "First?",
defaultValue: "one",
action: "dismissed",
timestamp: 1,
},
{
type: "prompt",
message: "Second?",
defaultValue: "two",
action: "dismissed",
timestamp: 2,
},
);
finishFirst.resolve();
await expect(first).resolves.toEqual({
result: "first",
dialogs: [
{
type: "prompt",
message: "First?",
defaultValue: "one",
action: "dismissed",
timestamp: 1,
},
{
type: "prompt",
message: "Second?",
defaultValue: "two",
action: "dismissed",
timestamp: 2,
},
],
});
expect(
contents.debugger.commands.some(
(entry) =>
entry.command === "Runtime.evaluate" &&
typeof entry.params.expression === "string" &&
entry.params.expression.includes("delete window[stateKey]"),
),
).toBe(false);
finishSecond.resolve();
await expect(second).resolves.toEqual({
result: "second",
dialogs: [
{
type: "prompt",
message: "First?",
defaultValue: "one",
action: "dismissed",
timestamp: 1,
},
{
type: "prompt",
message: "Second?",
defaultValue: "two",
action: "dismissed",
timestamp: 2,
},
],
});
expect(contents.debugger.commands.at(-1)).toEqual({
command: "Runtime.evaluate",
params: {
expression: expect.stringContaining("delete window[stateKey]"),
returnByValue: true,
},
});
});
test("leaves JavaScript dialogs alone when no capture is active", async () => {
const contents = new FakeWebContents(28);
const tab = adaptWebContents(contents);
await expect(tab.captureDialogs?.(async () => "done")).resolves.toEqual({
result: "done",
dialogs: [],
});
contents.debugger.emitMessage("Page.javascriptDialogOpening", {
type: "confirm",
message: "Unsaved changes?",
});
await flushMicrotasks();
expect(contents.debugger.commands).not.toContainEqual({
command: "Page.handleJavaScriptDialog",
params: { accept: false },
});
});
test("treats prompt shim drain failures after navigation as no dialogs", async () => {
const contents = new FakeWebContents(29);
contents.debugger.failPromptDrain = true;
const tab = adaptWebContents(contents);
await expect(tab.captureDialogs?.(async () => "navigated")).resolves.toEqual({
result: "navigated",
dialogs: [],
});
expect(contents.debugger.commands).toEqual([
{ command: "Page.enable", params: {} },
{
command: "Runtime.evaluate",
params: { expression: expect.any(String), returnByValue: true },
},
{
command: "Runtime.evaluate",
params: { expression: expect.any(String), returnByValue: true },
},
{
command: "Runtime.evaluate",
params: {
expression: expect.stringContaining("delete window[stateKey]"),
returnByValue: true,
},
},
]);
});
test("runs the command without dialog capture when CDP setup fails", async () => {
const contents = new FakeWebContents(30);
contents.debugger.failedCommandNames.add("Page.enable");
const tab = adaptWebContents(contents);
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
await expect(tab.captureDialogs?.(async () => "done")).resolves.toEqual({
result: "done",
dialogs: [],
});
expect(warn).toHaveBeenCalledWith(
"[browser-automation] Dialog capture unavailable; running command without it",
{ contentsId: 30, error: expect.any(Error) },
);
warn.mockRestore();
});
});
async function flushMicrotasks(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
}
function deferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}

View File

@@ -1,35 +1,85 @@
import { mkdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { basename, join } from "node:path";
import type { WebContents } from "electron";
import type { Rectangle } from "electron";
import { ipcMain } from "electron";
import { BrowserAutomationExecuteRequestSchema } from "@getpaseo/protocol/browser-automation/rpc-schemas";
import type {
BrowserAutomationConsoleLogEntry,
BrowserAutomationCookieEntry,
BrowserAutomationDialogEvent,
} from "@getpaseo/protocol/browser-automation/rpc-schemas";
import type { TabContents, BrowserRegistry } from "./service.js";
import type { TabContents, BrowserRegistry, TabImage } from "./service.js";
import { CdpSessionQueue } from "./cdp-session-queue.js";
import {
dialogAcceptValue,
handledDialogEvent,
MAX_DIALOGS_PER_COMMAND,
promptShimDrainScript,
promptShimInstallScript,
promptShimRestoreScript,
} from "./dialog-handling.js";
import { executeAutomationCommand } from "./service.js";
import {
listRegisteredPaseoBrowserIds,
listRegisteredPaseoBrowserIdsForWorkspace,
getPaseoBrowserWebContents,
getWorkspaceActivePaseoBrowserWebContents,
getWorkspaceActivePaseoBrowserId,
getAgentActivePaseoBrowserId,
getPaseoBrowserWorkspaceId,
} from "../browser-webviews/index.js";
const MAX_CONSOLE_MESSAGES_PER_TAB = 200;
const consoleMessagesByContentsId = new Map<number, BrowserAutomationConsoleLogEntry[]>();
const cdpQueuesByContentsId = new Map<number, CdpSessionQueue>();
const dialogMonitorsByContentsId = new Map<number, DialogMonitor>();
const observedContentsIds = new Set<number>();
interface IpcHandlerRegistry {
handle(channel: string, listener: (event: unknown, ...args: unknown[]) => unknown): void;
}
function adaptWebContents(contents: WebContents): TabContents {
interface WebContentsDebugger {
isAttached(): boolean;
attach(protocolVersion?: string): void;
sendCommand(command: string, params?: Record<string, unknown>): Promise<unknown>;
on?(
event: "message",
listener: (event: unknown, method: string, params?: Record<string, unknown>) => void,
): void;
}
interface ConsoleMessageEmitter {
on(
event: "console-message",
listener: (
event: unknown,
level: unknown,
message: unknown,
line: unknown,
sourceId: unknown,
) => void,
): void;
once(event: "destroyed", listener: () => void): void;
}
interface BrowserAutomationWebContents extends ConsoleMessageEmitter {
readonly id: number;
readonly debugger: WebContentsDebugger;
getURL(): string;
getTitle(): string;
canGoBack(): boolean;
canGoForward(): boolean;
isLoading(): boolean;
isDestroyed(): boolean;
executeJavaScript(code: string): Promise<unknown>;
loadURL(url: string): Promise<void>;
goBack(): void;
goForward(): void;
reload(): void;
capturePage(rect?: Rectangle, options?: { stayHidden?: boolean }): Promise<TabImage>;
invalidate(): void;
}
export function adaptWebContents(contents: BrowserAutomationWebContents): TabContents {
observeConsoleMessages(contents);
const cdpQueue = getCdpQueue(contents.id);
const dialogMonitor = getDialogMonitor(contents, cdpQueue);
return {
id: contents.id,
getURL: () => contents.getURL(),
@@ -43,67 +93,31 @@ function adaptWebContents(contents: WebContents): TabContents {
goBack: () => contents.goBack(),
goForward: () => contents.goForward(),
reload: () => contents.reload(),
capturePage: () => contents.capturePage(),
capturePage: (captureOptions) => contents.capturePage(undefined, captureOptions),
invalidate: () => contents.invalidate(),
getConsoleMessages: () => consoleMessagesByContentsId.get(contents.id) ?? [],
getCookies: async (url: string) =>
(await contents.session.cookies.get({ url })).map(normalizeCookie),
sendDebugCommand: async (command: string, params?: Record<string, unknown>) => {
if (!contents.debugger.isAttached()) {
contents.debugger.attach("1.3");
}
return contents.debugger.sendCommand(command, params ?? {});
},
printToPDF: async (options?: Record<string, unknown>) => contents.printToPDF(options ?? {}),
downloadURL: (input) => downloadWithContents(contents, input),
captureDialogs: (task) => dialogMonitor.capture(task),
sendDebugCommand: (command: string, params?: Record<string, unknown>) =>
cdpQueue.run(async () => {
if (!contents.debugger.isAttached()) {
contents.debugger.attach("1.3");
}
return contents.debugger.sendCommand(command, params ?? {});
}),
};
}
function downloadWithContents(
contents: WebContents,
input: { url: string; fileName?: string },
): Promise<{ filePath: string; totalBytes?: number; state: string }> {
const downloadDir = join(tmpdir(), "paseo-browser-downloads");
mkdirSync(downloadDir, { recursive: true });
const filePath = join(downloadDir, sanitizeDownloadFileName(input));
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
contents.session.off("will-download", onDownload);
reject(new Error(`Timed out waiting for browser download: ${input.url}`));
}, 30_000);
function onDownload(_event: Electron.Event, item: Electron.DownloadItem): void {
if (item.getURL() !== input.url) {
return;
}
clearTimeout(timeout);
contents.session.off("will-download", onDownload);
item.setSavePath(filePath);
item.once("done", (_doneEvent, state) => {
resolve({ filePath, totalBytes: item.getTotalBytes(), state });
});
}
contents.session.on("will-download", onDownload);
contents.downloadURL(input.url);
});
function getCdpQueue(contentsId: number): CdpSessionQueue {
const existing = cdpQueuesByContentsId.get(contentsId);
if (existing) {
return existing;
}
const queue = new CdpSessionQueue();
cdpQueuesByContentsId.set(contentsId, queue);
return queue;
}
export function sanitizeDownloadFileName(input: { url: string; fileName?: string }): string {
const requestedName = input.fileName ?? basename(new URL(input.url).pathname);
return basename(requestedName) || "download";
}
function normalizeCookie(cookie: Electron.Cookie): BrowserAutomationCookieEntry {
return {
name: cookie.name,
value: cookie.value,
...(cookie.domain ? { domain: cookie.domain } : {}),
...(cookie.path ? { path: cookie.path } : {}),
secure: cookie.secure,
httpOnly: cookie.httpOnly,
...(typeof cookie.expirationDate === "number" ? { expirationDate: cookie.expirationDate } : {}),
};
}
function observeConsoleMessages(contents: WebContents): void {
function observeConsoleMessages(contents: BrowserAutomationWebContents): void {
if (observedContentsIds.has(contents.id)) {
return;
}
@@ -117,6 +131,192 @@ function observeConsoleMessages(contents: WebContents): void {
contents.once("destroyed", () => {
observedContentsIds.delete(contents.id);
consoleMessagesByContentsId.delete(contents.id);
cdpQueuesByContentsId.delete(contents.id);
dialogMonitorsByContentsId.delete(contents.id);
});
}
function getDialogMonitor(
contents: BrowserAutomationWebContents,
cdpQueue: CdpSessionQueue,
): DialogMonitor {
const existing = dialogMonitorsByContentsId.get(contents.id);
if (existing) {
return existing;
}
const monitor = new DialogMonitor(contents, cdpQueue);
dialogMonitorsByContentsId.set(contents.id, monitor);
return monitor;
}
class DialogMonitor {
private enabled = false;
private listenerRegistered = false;
private readonly activeCollectors: DialogCollector[] = [];
public constructor(
private readonly contents: BrowserAutomationWebContents,
private readonly cdpQueue: CdpSessionQueue,
) {}
public async capture<T>(
task: () => Promise<T>,
): Promise<{ result: T; dialogs: BrowserAutomationDialogEvent[] }> {
const collector: DialogCollector = { dialogs: [] };
try {
await this.enable();
await this.installPromptShim();
} catch (error) {
console.warn("[browser-automation] Dialog capture unavailable; running command without it", {
contentsId: this.contents.id,
error,
});
return { result: await task(), dialogs: [] };
}
this.activeCollectors.push(collector);
try {
const result = await task();
this.recordPromptShimDialogs(await this.drainPromptShim());
return { result, dialogs: collector.dialogs };
} finally {
const index = this.activeCollectors.indexOf(collector);
if (index >= 0) {
this.activeCollectors.splice(index, 1);
}
if (this.activeCollectors.length === 0) {
await this.restorePromptShim();
}
}
}
private async enable(): Promise<void> {
if (this.enabled) {
return;
}
if (!this.contents.debugger.on) {
return;
}
if (!this.listenerRegistered) {
this.listenerRegistered = true;
this.contents.debugger.on("message", (_event, method, params) => {
if (method !== "Page.javascriptDialogOpening") {
return;
}
if (this.activeCollectors.length === 0) {
return;
}
void this.handleOpening(params ?? {});
});
}
await this.sendDebugCommand("Page.enable");
this.enabled = true;
}
private async handleOpening(params: Record<string, unknown>): Promise<void> {
const event = handledDialogEvent(params);
for (const collector of this.activeCollectors) {
this.recordDialogs(collector, [event]);
}
await this.sendDialogResponseCommand("Page.handleJavaScriptDialog", {
accept: dialogAcceptValue(event.type),
});
}
private async installPromptShim(): Promise<void> {
await this.sendDebugCommand("Runtime.evaluate", {
expression: promptShimInstallScript(),
returnByValue: true,
});
}
private async drainPromptShim(): Promise<BrowserAutomationDialogEvent[]> {
try {
const result = (await this.sendDebugCommand("Runtime.evaluate", {
expression: promptShimDrainScript(),
returnByValue: true,
})) as { result?: { value?: unknown } };
return parsePromptShimDialogs(result.result?.value);
} catch {
return [];
}
}
private async restorePromptShim(): Promise<void> {
try {
await this.sendDebugCommand("Runtime.evaluate", {
expression: promptShimRestoreScript(),
returnByValue: true,
});
} catch {
// Navigation can destroy the execution context before cleanup runs; the next page has no shim.
}
}
private recordDialogs(collector: DialogCollector, dialogs: BrowserAutomationDialogEvent[]): void {
for (const dialog of dialogs) {
if (collector.dialogs.length >= MAX_DIALOGS_PER_COMMAND) {
return;
}
collector.dialogs.push(dialog);
}
}
private recordPromptShimDialogs(dialogs: BrowserAutomationDialogEvent[]): void {
for (const collector of this.activeCollectors) {
this.recordDialogs(collector, dialogs);
}
}
private async sendDebugCommand(
command: string,
params?: Record<string, unknown>,
): Promise<unknown> {
return this.cdpQueue.run(async () => {
if (!this.contents.debugger.isAttached()) {
this.contents.debugger.attach("1.3");
}
return this.contents.debugger.sendCommand(command, params ?? {});
});
}
private async sendDialogResponseCommand(
command: string,
params?: Record<string, unknown>,
): Promise<unknown> {
// Dialogs can block the CDP command that opened them, so the unblocker must not wait behind
// the per-tab command queue.
if (!this.contents.debugger.isAttached()) {
this.contents.debugger.attach("1.3");
}
return this.contents.debugger.sendCommand(command, params ?? {});
}
}
interface DialogCollector {
dialogs: BrowserAutomationDialogEvent[];
}
function parsePromptShimDialogs(value: unknown): BrowserAutomationDialogEvent[] {
if (!Array.isArray(value)) {
return [];
}
return value.flatMap((entry): BrowserAutomationDialogEvent[] => {
if (!entry || typeof entry !== "object") {
return [];
}
const record = entry as Record<string, unknown>;
if (record.type !== "prompt" || record.action !== "dismissed") {
return [];
}
return [
{
type: "prompt",
message: typeof record.message === "string" ? record.message : "",
...(typeof record.defaultValue === "string" ? { defaultValue: record.defaultValue } : {}),
action: "dismissed",
timestamp: typeof record.timestamp === "number" ? record.timestamp : Date.now(),
},
];
});
}
@@ -146,12 +346,7 @@ function createRegistry(): BrowserRegistry {
return contents ? adaptWebContents(contents) : null;
},
getBrowserWorkspaceId: getPaseoBrowserWorkspaceId,
getWorkspaceActiveTabContents(workspaceId: string): TabContents | null {
const contents = getWorkspaceActivePaseoBrowserWebContents(workspaceId);
return contents ? adaptWebContents(contents) : null;
},
getWorkspaceActiveBrowserId: getWorkspaceActivePaseoBrowserId,
getAgentActiveBrowserId: getAgentActivePaseoBrowserId,
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -3,77 +3,162 @@ import { BrowserSnapshotEngine, type SnapshotPage } from "./snapshot-engine.js";
class SnapshotFixture implements SnapshotPage {
public currentUrl = "https://example.com/form";
public actionResult: unknown = true;
public actionResult: unknown = { ok: true };
public alreadyTruncated = false;
public snapshotNodes: unknown[] = [
{
kind: "role",
role: "heading",
name: "Settings",
tagName: "h1",
attributes: ["level=1"],
children: [],
},
{ kind: "text", text: "Connected as Maya" },
{
kind: "role",
role: "button",
name: "Save changes",
tagName: "button",
attributes: [],
ref: "@e1",
fingerprint: {
role: "button",
name: "Save changes",
tagName: "button",
type: "",
ariaLabel: "",
},
children: [],
},
];
public getURL(): string {
return this.currentUrl;
}
public async executeJavaScript(code: string): Promise<unknown> {
if (code.includes("CANDIDATE_SELECTOR")) {
return JSON.stringify([
{
role: "textbox",
tagName: "input",
text: "Name",
selector: "#name",
attributes: { id: "name", type: "text" },
if (code.includes("__PASEO_ARIA_SNAPSHOT__")) {
return JSON.stringify({
marker: "__PASEO_ARIA_SNAPSHOT__",
root: {
kind: "role",
role: "document",
name: "Fixture",
tagName: "document",
attributes: [],
children: this.snapshotNodes,
},
{
role: "button",
tagName: "button",
text: "Drop",
selector: "#drop",
attributes: { id: "drop" },
},
]);
refs: [
{
ref: "@e1",
fingerprint: {
role: "button",
name: "Save changes",
tagName: "button",
type: "",
ariaLabel: "",
},
},
],
truncated: this.alreadyTruncated,
stats: { nodeCount: 4, refCount: 1, textLength: 0, iframeCount: 0, maxDepth: 1 },
});
}
return this.actionResult;
}
}
describe("BrowserSnapshotEngine", () => {
it("treats a false result from a ref action script as a stale ref", async () => {
it("renders a hierarchical ARIA YAML snapshot with static text and actionable refs", async () => {
const page = new SnapshotFixture();
const engine = new BrowserSnapshotEngine();
await engine.snapshot({ browserId: "browser-1", page });
page.actionResult = false;
await expect(engine.click({ browserId: "browser-1", page, ref: "@e1" })).resolves.toEqual({
ok: false,
reason: "stale_ref",
});
await expect(engine.focus({ browserId: "browser-1", page, ref: "@e1" })).resolves.toEqual({
ok: false,
reason: "stale_ref",
await expect(engine.snapshot({ browserId: "browser-1", page })).resolves.toEqual({
format: "aria-yaml",
snapshot: [
'- document "Fixture"',
' - heading "Settings" [level=1]',
' - text: "Connected as Maya"',
' - button "Save changes" [ref=@e1]',
].join("\n"),
truncated: false,
stats: { nodeCount: 4, refCount: 1, textLength: 119, iframeCount: 0, maxDepth: 1 },
});
});
it("treats a false result from optional ref text/key actions as a stale ref", async () => {
it("builds a runtime ref expression with the snapshot fingerprint", async () => {
const page = new SnapshotFixture();
const engine = new BrowserSnapshotEngine();
await engine.snapshot({ browserId: "browser-1", page });
page.actionResult = false;
page.currentUrl = "https://example.com/form?panel=advanced";
await expect(
engine.typeText({ browserId: "browser-1", page, ref: "@e1", text: "Ada" }),
).resolves.toEqual({ ok: false, reason: "stale_ref" });
await expect(
engine.keypress({ browserId: "browser-1", page, ref: "@e1", key: "Enter" }),
).resolves.toEqual({ ok: false, reason: "stale_ref" });
expect(engine.runtimeElementExpression({ browserId: "browser-1", ref: "@e1" })).toContain(
'"name":"Save changes"',
);
});
it("treats a false result from drag as a stale ref", async () => {
it("treats missing host-side ref metadata as a stale ref", async () => {
const page = new SnapshotFixture();
const engine = new BrowserSnapshotEngine();
await engine.snapshot({ browserId: "browser-1", page });
page.actionResult = false;
expect(engine.runtimeElementExpression({ browserId: "browser-1", ref: "@e2" })).toEqual({
ok: false,
reason: "missing_ref",
});
});
await expect(
engine.drag({ browserId: "browser-1", page, sourceRef: "@e1", targetRef: "@e2" }),
).resolves.toEqual({ ok: false, reason: "stale_ref" });
it("marks rendered output truncation explicitly and deterministically", async () => {
const page = new SnapshotFixture();
page.snapshotNodes = [
{
kind: "text",
text: "A".repeat(81_000),
},
];
const engine = new BrowserSnapshotEngine();
const snapshot = await engine.snapshot({ browserId: "browser-1", page });
expect(snapshot.truncated).toBe(true);
expect(snapshot.snapshot.endsWith('- text: "Snapshot truncated."')).toBe(true);
expect(snapshot.stats.textLength).toBeLessThanOrEqual(80_000);
});
it("keeps the last rendered node when a short snapshot was capped by node count", async () => {
const page = new SnapshotFixture();
page.alreadyTruncated = true;
page.snapshotNodes = [
{
kind: "role",
role: "button",
name: "Final action",
tagName: "button",
attributes: [],
ref: "@e1",
fingerprint: {
role: "button",
name: "Final action",
tagName: "button",
type: "",
ariaLabel: "",
},
children: [],
},
];
const engine = new BrowserSnapshotEngine();
const snapshot = await engine.snapshot({ browserId: "browser-1", page });
expect(snapshot.truncated).toBe(true);
expect(snapshot.snapshot).toBe(
[
'- document "Fixture"',
' - button "Final action" [ref=@e1]',
'- text: "Snapshot truncated."',
].join("\n"),
);
});
});

View File

@@ -1,24 +1,60 @@
import { ARIA_SNAPSHOT_SCRIPT, ARIA_SNAPSHOT_SCRIPT_MARKER } from "./aria-snapshot-script.js";
export interface SnapshotPage {
getURL(): string;
executeJavaScript(code: string): Promise<unknown>;
}
export interface BrowserSnapshotElement extends RawSnapshotElement {
ref: string;
export interface BrowserAriaSnapshot {
format: "aria-yaml";
snapshot: string;
truncated: boolean;
stats: BrowserAriaSnapshotStats;
}
interface RawSnapshotElement {
export interface BrowserAriaSnapshotStats {
nodeCount: number;
refCount: number;
textLength: number;
iframeCount?: number;
maxDepth?: number;
}
interface SnapshotNode {
kind: "role" | "text" | "group";
role?: string;
name?: string;
text?: string;
tagName?: string;
attributes?: string[];
ref?: string;
fingerprint?: BrowserRefFingerprint;
children?: SnapshotNode[];
}
interface BrowserRefFingerprint {
role: string;
name: string;
tagName: string;
text: string;
selector: string;
attributes: Record<string, string>;
type: string;
ariaLabel: string;
}
interface RawAriaSnapshot {
marker: string;
root: SnapshotNode;
refs: BrowserRefMetadata[];
truncated: boolean;
stats: BrowserAriaSnapshotStats;
}
interface BrowserRefMetadata {
ref: string;
fingerprint: BrowserRefFingerprint;
}
interface BrowserRefState {
nextRefNumber: number;
url: string;
refs: Map<string, RawSnapshotElement>;
refs: Map<string, BrowserRefMetadata>;
}
export type BrowserRefActionResult =
@@ -26,45 +62,31 @@ export type BrowserRefActionResult =
| { ok: false; reason: "stale_ref" | "missing_ref" };
type BrowserRefFailure = Extract<BrowserRefActionResult, { ok: false }>;
type BrowserRefResolveResult = { ok: true; element: RawSnapshotElement } | BrowserRefFailure;
type BrowserRefResolveResult = { ok: true; metadata: BrowserRefMetadata } | BrowserRefFailure;
const TRUNCATION_MARKER = '- text: "Snapshot truncated."';
const MAX_RENDERED_TEXT_LENGTH = 80_000;
export class BrowserSnapshotEngine {
private readonly statesByBrowserId = new Map<string, BrowserRefState>();
async snapshot(input: {
browserId: string;
page: SnapshotPage;
}): Promise<BrowserSnapshotElement[]> {
const rawElements = parseRawSnapshotElements(
await input.page.executeJavaScript(SNAPSHOT_SCRIPT),
);
const state = {
nextRefNumber: 1,
url: input.page.getURL(),
refs: new Map<string, RawSnapshotElement>(),
};
const elements = rawElements.map((element) => {
const ref = `@e${state.nextRefNumber++}`;
state.refs.set(ref, element);
return {
ref,
role: element.role,
tagName: element.tagName,
text: element.text,
selector: element.selector,
attributes: element.attributes,
};
async snapshot(input: { browserId: string; page: SnapshotPage }): Promise<BrowserAriaSnapshot> {
const rawSnapshot = parseAriaSnapshot(await input.page.executeJavaScript(ARIA_SNAPSHOT_SCRIPT));
const rendered = renderSnapshot(rawSnapshot.root);
const capped = capRenderedSnapshot(rendered, rawSnapshot.truncated);
this.statesByBrowserId.set(input.browserId, {
refs: new Map(rawSnapshot.refs.map((ref) => [ref.ref, ref])),
});
this.statesByBrowserId.set(input.browserId, state);
return elements;
}
async click(input: {
browserId: string;
page: SnapshotPage;
ref: string;
}): Promise<BrowserRefActionResult> {
return this.runRefScript(input, (selector) => buildClickScript(selector));
return {
format: "aria-yaml",
snapshot: capped.snapshot,
truncated: capped.truncated,
stats: {
...rawSnapshot.stats,
refCount: rawSnapshot.refs.length,
textLength: capped.snapshot.length,
},
};
}
async fill(input: {
@@ -73,64 +95,7 @@ export class BrowserSnapshotEngine {
ref: string;
value: string;
}): Promise<BrowserRefActionResult> {
return this.runRefScript(input, (selector) => buildFillScript(selector, input.value));
}
async typeText(input: {
browserId: string;
page: SnapshotPage;
ref?: string;
text: string;
}): Promise<BrowserRefActionResult> {
const selector = this.resolveOptionalRef(input);
if (!selector.ok) {
return selector;
}
const result = await input.page.executeJavaScript(
buildTypeScript(selector.selector, input.text),
);
return input.ref && result === false ? { ok: false, reason: "stale_ref" } : { ok: true };
}
async keypress(input: {
browserId: string;
page: SnapshotPage;
ref?: string;
key: string;
}): Promise<BrowserRefActionResult> {
const selector = this.resolveOptionalRef(input);
if (!selector.ok) {
return selector;
}
const result = await input.page.executeJavaScript(
buildKeypressScript(selector.selector, input.key),
);
return input.ref && result === false ? { ok: false, reason: "stale_ref" } : { ok: true };
}
async focus(input: {
browserId: string;
page: SnapshotPage;
ref: string;
}): Promise<BrowserRefActionResult> {
return this.runRefScript(input, (selector) => buildFocusScript(selector));
}
async clear(input: {
browserId: string;
page: SnapshotPage;
ref: string;
}): Promise<BrowserRefActionResult> {
return this.runRefScript(input, (selector) => buildClearScript(selector));
}
async check(input: {
browserId: string;
page: SnapshotPage;
ref: string;
checked: boolean;
}): Promise<BrowserRefActionResult> {
return this.runRefScript(input, (selector) => buildCheckScript(selector, input.checked));
return this.runRefScript(input, (ref) => buildFillScript(ref, input.value));
}
async select(input: {
@@ -139,123 +104,214 @@ export class BrowserSnapshotEngine {
ref: string;
value: string;
}): Promise<BrowserRefActionResult> {
return this.runRefScript(input, (selector) => buildSelectScript(selector, input.value));
}
async hover(input: {
browserId: string;
page: SnapshotPage;
ref: string;
}): Promise<BrowserRefActionResult> {
return this.runRefScript(input, (selector) => buildHoverScript(selector));
}
async drag(input: {
browserId: string;
page: SnapshotPage;
sourceRef: string;
targetRef: string;
}): Promise<BrowserRefActionResult> {
const source = this.resolveRef({
browserId: input.browserId,
page: input.page,
ref: input.sourceRef,
});
if (!source.ok) {
return source;
}
const target = this.resolveRef({
browserId: input.browserId,
page: input.page,
ref: input.targetRef,
});
if (!target.ok) {
return target;
}
const result = await input.page.executeJavaScript(
buildDragScript(source.element.selector, target.element.selector),
);
return result === false ? { ok: false, reason: "stale_ref" } : { ok: true };
return this.runRefScript(input, (ref) => buildSelectScript(ref, input.value));
}
clearBrowser(browserId: string): void {
this.statesByBrowserId.delete(browserId);
}
selectorForRef(input: {
browserId: string;
page: SnapshotPage;
ref: string;
}): { ok: true; selector: string } | BrowserRefFailure {
runtimeElementExpression(input: { browserId: string; ref: string }): string | BrowserRefFailure {
const resolved = this.resolveRef(input);
if (!resolved.ok) {
return resolved;
}
return { ok: true, selector: resolved.element.selector };
return buildRuntimeElementExpression(resolved.metadata);
}
private async runRefScript(
input: { browserId: string; page: SnapshotPage; ref: string },
buildScript: (selector: string) => string,
buildScript: (ref: BrowserRefMetadata) => string,
): Promise<BrowserRefActionResult> {
const resolved = this.resolveRef(input);
if (!resolved.ok) {
return resolved;
}
const result = await input.page.executeJavaScript(buildScript(resolved.element.selector));
return result === false ? { ok: false, reason: "stale_ref" } : { ok: true };
const result = await input.page.executeJavaScript(buildScript(resolved.metadata));
return readActionResult(result, true);
}
private resolveRef(input: {
browserId: string;
page: SnapshotPage;
ref: string;
}): BrowserRefResolveResult {
private resolveRef(input: { browserId: string; ref: string }): BrowserRefResolveResult {
const state = this.statesByBrowserId.get(input.browserId);
if (!state || state.url !== input.page.getURL()) {
if (!state) {
return { ok: false, reason: "stale_ref" };
}
const element = state.refs.get(input.ref);
if (!element) {
const metadata = state.refs.get(input.ref);
if (!metadata) {
return { ok: false, reason: "missing_ref" };
}
return { ok: true, element };
}
private resolveOptionalRef(input: {
browserId: string;
page: SnapshotPage;
ref?: string;
}): { ok: true; selector: string | undefined } | BrowserRefFailure {
if (!input.ref) {
return { ok: true, selector: undefined };
}
const resolved = this.resolveRef({
browserId: input.browserId,
page: input.page,
ref: input.ref,
});
if (!resolved.ok) {
return resolved;
}
return { ok: true, selector: resolved.element.selector };
return { ok: true, metadata };
}
}
function buildClickScript(selector: string): string {
return String.raw`(() => {
const element = document.querySelector(${JSON.stringify(selector)});
if (!element) return false;
element.scrollIntoView({ block: 'center', inline: 'center' });
element.click();
return true;
})()`;
function readActionResult(value: unknown, staleWhenFalse: boolean): BrowserRefActionResult {
if (value === false && staleWhenFalse) {
return { ok: false, reason: "stale_ref" };
}
if (isActionFailure(value)) {
return { ok: false, reason: value.reason };
}
return { ok: true };
}
function buildFillScript(selector: string, value: string): string {
function isActionFailure(value: unknown): value is BrowserRefFailure {
if (!value || typeof value !== "object") {
return false;
}
const reason = (value as Record<string, unknown>).reason;
return reason === "stale_ref" || reason === "missing_ref";
}
function parseAriaSnapshot(value: unknown): RawAriaSnapshot {
const parsed = typeof value === "string" ? JSON.parse(value) : value;
if (!parsed || typeof parsed !== "object") {
return emptySnapshot();
}
const record = parsed as Record<string, unknown>;
if (record.marker !== ARIA_SNAPSHOT_SCRIPT_MARKER) {
return emptySnapshot();
}
const root = parseSnapshotNode(record.root);
return {
marker: ARIA_SNAPSHOT_SCRIPT_MARKER,
root: root ?? emptySnapshot().root,
refs: parseRefs(record.refs),
truncated: record.truncated === true,
stats: parseStats(record.stats),
};
}
function emptySnapshot(): RawAriaSnapshot {
return {
marker: ARIA_SNAPSHOT_SCRIPT_MARKER,
root: { kind: "role", role: "document", name: "", tagName: "document", children: [] },
refs: [],
truncated: false,
stats: { nodeCount: 0, refCount: 0, textLength: 0, iframeCount: 0, maxDepth: 0 },
};
}
function parseSnapshotNode(value: unknown): SnapshotNode | null {
if (!value || typeof value !== "object") {
return null;
}
const record = value as Record<string, unknown>;
const kind = record.kind;
if (kind !== "role" && kind !== "text" && kind !== "group") {
return null;
}
return {
kind,
...(readString(record.role) ? { role: readString(record.role) ?? undefined } : {}),
...(readString(record.name) ? { name: readString(record.name) ?? undefined } : {}),
...(readString(record.text) ? { text: readString(record.text) ?? undefined } : {}),
...(readString(record.tagName) ? { tagName: readString(record.tagName) ?? undefined } : {}),
...(readString(record.ref) ? { ref: readString(record.ref) ?? undefined } : {}),
...(parseFingerprint(record.fingerprint)
? { fingerprint: parseFingerprint(record.fingerprint) ?? undefined }
: {}),
attributes: readStringArray(record.attributes),
children: Array.isArray(record.children)
? record.children.flatMap((child): SnapshotNode[] => {
const parsed = parseSnapshotNode(child);
return parsed ? [parsed] : [];
})
: [],
};
}
function parseRefs(value: unknown): BrowserRefMetadata[] {
if (!Array.isArray(value)) {
return [];
}
return value.flatMap((item): BrowserRefMetadata[] => {
if (!item || typeof item !== "object") {
return [];
}
const record = item as Record<string, unknown>;
const ref = readString(record.ref);
const fingerprint = parseFingerprint(record.fingerprint);
return ref && fingerprint ? [{ ref, fingerprint }] : [];
});
}
function parseFingerprint(value: unknown): BrowserRefFingerprint | null {
if (!value || typeof value !== "object") {
return null;
}
const record = value as Record<string, unknown>;
const role = readString(record.role);
const name = readString(record.name);
const tagName = readString(record.tagName);
const type = readString(record.type);
const ariaLabel = readString(record.ariaLabel);
if (role === null || name === null || tagName === null || type === null || ariaLabel === null) {
return null;
}
return { role, name, tagName, type, ariaLabel };
}
function parseStats(value: unknown): BrowserAriaSnapshotStats {
if (!value || typeof value !== "object") {
return { nodeCount: 0, refCount: 0, textLength: 0 };
}
const record = value as Record<string, unknown>;
return {
nodeCount: readNumber(record.nodeCount) ?? 0,
refCount: readNumber(record.refCount) ?? 0,
textLength: readNumber(record.textLength) ?? 0,
...(readNumber(record.iframeCount) !== null
? { iframeCount: readNumber(record.iframeCount) ?? undefined }
: {}),
...(readNumber(record.maxDepth) !== null
? { maxDepth: readNumber(record.maxDepth) ?? undefined }
: {}),
};
}
function renderSnapshot(root: SnapshotNode): string {
const lines = renderNode(root, 0);
return lines.length > 0 ? lines.join("\n") : "- document";
}
function renderNode(node: SnapshotNode, depth: number): string[] {
if (node.kind === "group") {
return (node.children ?? []).flatMap((child) => renderNode(child, depth));
}
const indent = " ".repeat(depth);
if (node.kind === "text") {
return [`${indent}- text: ${JSON.stringify(node.text ?? "")}`];
}
const attrs = [...(node.attributes ?? [])];
if (node.ref) {
attrs.push(`ref=${node.ref}`);
}
const suffix = attrs.length > 0 ? ` [${attrs.join(" ")}]` : "";
const ownLine = `${indent}- ${node.role ?? "generic"}${node.name ? ` ${JSON.stringify(node.name)}` : ""}${suffix}`;
const childLines = (node.children ?? []).flatMap((child) => renderNode(child, depth + 1));
return [ownLine, ...childLines];
}
function capRenderedSnapshot(
snapshot: string,
alreadyTruncated: boolean,
): { snapshot: string; truncated: boolean } {
if (snapshot.length <= MAX_RENDERED_TEXT_LENGTH && !alreadyTruncated) {
return { snapshot, truncated: false };
}
if (snapshot.length + 1 + TRUNCATION_MARKER.length <= MAX_RENDERED_TEXT_LENGTH) {
return { snapshot: `${snapshot}\n${TRUNCATION_MARKER}`, truncated: true };
}
const availableLength = MAX_RENDERED_TEXT_LENGTH - TRUNCATION_MARKER.length - 1;
const capped = snapshot.slice(0, Math.max(0, availableLength)).replace(/\n[^\n]*$/, "");
return { snapshot: `${capped}\n${TRUNCATION_MARKER}`, truncated: true };
}
function buildFillScript(metadata: BrowserRefMetadata, value: string): string {
return String.raw`(() => {
const element = document.querySelector(${JSON.stringify(selector)});
if (!element) return false;
const resolved = ${buildResolveExpression(metadata)};
if (!resolved.ok) return resolved;
const element = resolved.element;
element.scrollIntoView({ block: 'center', inline: 'center' });
element.focus();
const nextValue = ${JSON.stringify(value)};
@@ -263,102 +319,19 @@ function buildFillScript(selector: string, value: string): string {
element.value = nextValue;
element.dispatchEvent(new Event('input', { bubbles: true }));
element.dispatchEvent(new Event('change', { bubbles: true }));
return true;
return { ok: true };
}
element.textContent = nextValue;
element.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: nextValue }));
return true;
return { ok: true };
})()`;
}
function buildTypeScript(selector: string | undefined, text: string): string {
function buildSelectScript(metadata: BrowserRefMetadata, value: string): string {
return String.raw`(() => {
const element = ${selector ? `document.querySelector(${JSON.stringify(selector)})` : "document.activeElement"};
if (!element) return false;
element.scrollIntoView?.({ block: 'center', inline: 'center' });
element.focus?.();
const text = ${JSON.stringify(text)};
if ('value' in element) {
element.value = String(element.value || '') + text;
element.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: text }));
element.dispatchEvent(new Event('change', { bubbles: true }));
return true;
}
element.textContent = String(element.textContent || '') + text;
element.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: text }));
return true;
})()`;
}
function buildKeypressScript(selector: string | undefined, key: string): string {
return String.raw`(() => {
const element = ${selector ? `document.querySelector(${JSON.stringify(selector)})` : "document.activeElement"};
if (!element) return false;
element.focus?.();
const key = ${JSON.stringify(key)};
const eventInit = { bubbles: true, cancelable: true, key };
element.dispatchEvent(new KeyboardEvent('keydown', eventInit));
element.dispatchEvent(new KeyboardEvent('keypress', eventInit));
element.dispatchEvent(new KeyboardEvent('keyup', eventInit));
return true;
})()`;
}
function buildFocusScript(selector: string): string {
return String.raw`(() => {
const element = document.querySelector(${JSON.stringify(selector)});
if (!element) return false;
element.scrollIntoView?.({ block: 'center', inline: 'center' });
element.focus?.();
return document.activeElement === element;
})()`;
}
function buildClearScript(selector: string): string {
return String.raw`(() => {
const element = document.querySelector(${JSON.stringify(selector)});
if (!element) return false;
element.scrollIntoView?.({ block: 'center', inline: 'center' });
element.focus?.();
if ('value' in element) {
element.value = '';
element.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContent' }));
element.dispatchEvent(new Event('change', { bubbles: true }));
return true;
}
element.textContent = '';
element.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContent' }));
return true;
})()`;
}
function buildCheckScript(selector: string, checked: boolean): string {
return String.raw`(() => {
const element = document.querySelector(${JSON.stringify(selector)});
if (!element) return false;
element.scrollIntoView?.({ block: 'center', inline: 'center' });
element.focus?.();
const nextChecked = ${JSON.stringify(checked)};
if ('checked' in element) {
element.checked = nextChecked;
element.dispatchEvent(new Event('input', { bubbles: true }));
element.dispatchEvent(new Event('change', { bubbles: true }));
return true;
}
if (element.getAttribute('role') === 'checkbox' || element.getAttribute('role') === 'radio') {
element.setAttribute('aria-checked', String(nextChecked));
element.dispatchEvent(new Event('input', { bubbles: true }));
element.dispatchEvent(new Event('change', { bubbles: true }));
return true;
}
return false;
})()`;
}
function buildSelectScript(selector: string, value: string): string {
return String.raw`(() => {
const element = document.querySelector(${JSON.stringify(selector)});
if (!element) return false;
const resolved = ${buildResolveExpression(metadata)};
if (!resolved.ok) return resolved;
const element = resolved.element;
element.scrollIntoView?.({ block: 'center', inline: 'center' });
element.focus?.();
const nextValue = ${JSON.stringify(value)};
@@ -366,205 +339,34 @@ function buildSelectScript(selector: string, value: string): string {
element.value = nextValue;
element.dispatchEvent(new Event('input', { bubbles: true }));
element.dispatchEvent(new Event('change', { bubbles: true }));
return true;
return { ok: true };
}
return false;
})()`;
}
function buildHoverScript(selector: string): string {
function buildRuntimeElementExpression(metadata: BrowserRefMetadata): string {
return String.raw`(() => {
const element = document.querySelector(${JSON.stringify(selector)});
if (!element) return false;
element.scrollIntoView?.({ block: 'center', inline: 'center' });
const rect = element.getBoundingClientRect();
const eventInit = {
bubbles: true,
cancelable: true,
clientX: rect.left + rect.width / 2,
clientY: rect.top + rect.height / 2,
screenX: window.screenX + rect.left + rect.width / 2,
screenY: window.screenY + rect.top + rect.height / 2,
view: window,
};
element.dispatchEvent(new MouseEvent('mouseover', eventInit));
element.dispatchEvent(new MouseEvent('mouseenter', eventInit));
element.dispatchEvent(new MouseEvent('mousemove', eventInit));
return true;
const resolved = ${buildResolveExpression(metadata)};
if (!resolved.ok) return null;
return resolved.element;
})()`;
}
function buildDragScript(sourceSelector: string, targetSelector: string): string {
return String.raw`(() => {
const source = document.querySelector(${JSON.stringify(sourceSelector)});
const target = document.querySelector(${JSON.stringify(targetSelector)});
if (!source || !target) return false;
source.scrollIntoView?.({ block: 'center', inline: 'center' });
target.scrollIntoView?.({ block: 'center', inline: 'center' });
const data = new DataTransfer();
const sourceRect = source.getBoundingClientRect();
const targetRect = target.getBoundingClientRect();
function eventInit(rect) {
return {
bubbles: true,
cancelable: true,
clientX: rect.left + rect.width / 2,
clientY: rect.top + rect.height / 2,
screenX: window.screenX + rect.left + rect.width / 2,
screenY: window.screenY + rect.top + rect.height / 2,
dataTransfer: data,
view: window,
};
}
source.dispatchEvent(new MouseEvent('mousedown', eventInit(sourceRect)));
source.dispatchEvent(new DragEvent('dragstart', eventInit(sourceRect)));
target.dispatchEvent(new DragEvent('dragenter', eventInit(targetRect)));
target.dispatchEvent(new DragEvent('dragover', eventInit(targetRect)));
target.dispatchEvent(new DragEvent('drop', eventInit(targetRect)));
source.dispatchEvent(new DragEvent('dragend', eventInit(sourceRect)));
target.dispatchEvent(new MouseEvent('mouseup', eventInit(targetRect)));
return true;
})()`;
}
function parseRawSnapshotElements(value: unknown): RawSnapshotElement[] {
const parsed = typeof value === "string" ? JSON.parse(value) : value;
if (!Array.isArray(parsed)) {
return [];
}
return parsed.flatMap((item): RawSnapshotElement[] => {
if (!item || typeof item !== "object") {
return [];
}
const record = item as Record<string, unknown>;
const selector = readString(record.selector);
if (!selector) {
return [];
}
return [
{
role: readString(record.role) || "generic",
tagName: (readString(record.tagName) || "element").toLowerCase(),
text: readString(record.text) || "",
selector,
attributes: readAttributes(record.attributes),
},
];
});
function buildResolveExpression(metadata: BrowserRefMetadata): string {
return `window.__PASEO_BROWSER_AUTOMATION__?.resolve(${JSON.stringify(metadata.ref)}, ${JSON.stringify(metadata.fingerprint)}) ?? { ok: false, reason: 'stale_ref' }`;
}
function readString(value: unknown): string | null {
return typeof value === "string" ? value : null;
}
function readAttributes(value: unknown): Record<string, string> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return {};
}
const result: Record<string, string> = {};
for (const [key, attributeValue] of Object.entries(value)) {
if (typeof attributeValue === "string") {
result[key] = attributeValue;
}
}
return result;
function readStringArray(value: unknown): string[] {
return Array.isArray(value)
? value.filter((item): item is string => typeof item === "string")
: [];
}
const SNAPSHOT_SCRIPT = String.raw`(() => {
const MAX_ELEMENTS = 200;
const CANDIDATE_SELECTOR = [
'a[href]',
'button',
'input',
'textarea',
'select',
'summary',
'[role]',
'[tabindex]:not([tabindex="-1"])',
'[contenteditable=""]',
'[contenteditable="true"]'
].join(',');
function cssEscape(value) {
if (window.CSS && typeof window.CSS.escape === 'function') {
return window.CSS.escape(value);
}
return String(value).replace(/[^a-zA-Z0-9_-]/g, '\\$&');
}
function isVisible(element) {
const style = window.getComputedStyle(element);
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
return false;
}
const rect = element.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
}
function roleFor(element) {
const explicit = element.getAttribute('role');
if (explicit) return explicit;
const tag = element.tagName.toLowerCase();
if (tag === 'a') return 'link';
if (tag === 'button') return 'button';
if (tag === 'select') return 'combobox';
if (tag === 'textarea') return 'textbox';
if (tag === 'summary') return 'button';
if (tag === 'input') {
const type = (element.getAttribute('type') || 'text').toLowerCase();
if (type === 'checkbox') return 'checkbox';
if (type === 'radio') return 'radio';
if (type === 'button' || type === 'submit' || type === 'reset') return 'button';
return 'textbox';
}
return 'generic';
}
function textFor(element) {
const tag = element.tagName.toLowerCase();
const pieces = [
element.getAttribute('aria-label'),
element.getAttribute('alt'),
element.getAttribute('title'),
tag === 'input' ? element.getAttribute('placeholder') : null,
tag === 'input' || tag === 'textarea' ? element.value : null,
element.innerText,
element.textContent
];
const text = pieces.find((piece) => typeof piece === 'string' && piece.trim().length > 0);
return (text || '').replace(/\s+/g, ' ').trim().slice(0, 300);
}
function selectorFor(element) {
if (element.id) return '#' + cssEscape(element.id);
const parts = [];
let current = element;
while (current && current.nodeType === Node.ELEMENT_NODE && current !== document.body) {
const tag = current.tagName.toLowerCase();
const parent = current.parentElement;
if (!parent) break;
const siblings = Array.from(parent.children).filter((sibling) => sibling.tagName === current.tagName);
const index = siblings.indexOf(current) + 1;
parts.unshift(siblings.length > 1 ? tag + ':nth-of-type(' + index + ')' : tag);
current = parent;
}
return parts.length > 0 ? parts.join(' > ') : element.tagName.toLowerCase();
}
return JSON.stringify(Array.from(document.querySelectorAll(CANDIDATE_SELECTOR))
.filter(isVisible)
.slice(0, MAX_ELEMENTS)
.map((element) => ({
role: roleFor(element),
tagName: element.tagName.toLowerCase(),
text: textFor(element),
selector: selectorFor(element),
attributes: {
...(element.id ? { id: element.id } : {}),
...(element.getAttribute('name') ? { name: element.getAttribute('name') } : {}),
...(element.getAttribute('type') ? { type: element.getAttribute('type') } : {}),
...(element.getAttribute('href') ? { href: element.getAttribute('href') } : {}),
...(element.getAttribute('aria-label') ? { 'aria-label': element.getAttribute('aria-label') } : {})
}
})));
})()`;
function readNumber(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}

View File

@@ -0,0 +1,38 @@
import { describe, expect, test } from "vitest";
import { dispatchTrustedKey } from "./trusted-input.js";
describe("trusted browser input", () => {
test("Space dispatches a real space key event", async () => {
const commands: Array<{ command: string; params?: Record<string, unknown> }> = [];
await dispatchTrustedKey(async (command, params) => {
commands.push({ command, ...(params ? { params } : {}) });
return {};
}, "Space");
expect(commands).toEqual([
{
command: "Input.dispatchKeyEvent",
params: {
type: "keyDown",
key: " ",
code: "Space",
windowsVirtualKeyCode: 32,
nativeVirtualKeyCode: 32,
text: " ",
unmodifiedText: " ",
},
},
{
command: "Input.dispatchKeyEvent",
params: {
type: "keyUp",
key: " ",
code: "Space",
windowsVirtualKeyCode: 32,
nativeVirtualKeyCode: 32,
},
},
]);
});
});

View File

@@ -0,0 +1,217 @@
import type { ActionablePoint } from "./actionability.js";
import type { CdpCommandSender } from "./cdp-session-queue.js";
export type MouseButton = "left" | "right" | "middle";
export type InputModifier = "Alt" | "Control" | "Meta" | "Shift";
export interface ClickInputOptions {
button?: MouseButton;
doubleClick?: boolean;
modifiers?: InputModifier[];
}
const MODIFIER_MASKS: Record<InputModifier, number> = {
Alt: 1,
Control: 2,
Meta: 4,
Shift: 8,
};
const SPECIAL_KEY_DEFINITIONS: Record<
string,
{ key: string; code: string; windowsVirtualKeyCode: number; text?: string }
> = {
Enter: { key: "Enter", code: "Enter", windowsVirtualKeyCode: 13, text: "\r" },
Space: { key: " ", code: "Space", windowsVirtualKeyCode: 32, text: " " },
Tab: { key: "Tab", code: "Tab", windowsVirtualKeyCode: 9, text: "\t" },
Escape: { key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 },
Backspace: { key: "Backspace", code: "Backspace", windowsVirtualKeyCode: 8 },
Delete: { key: "Delete", code: "Delete", windowsVirtualKeyCode: 46 },
ArrowUp: { key: "ArrowUp", code: "ArrowUp", windowsVirtualKeyCode: 38 },
ArrowDown: { key: "ArrowDown", code: "ArrowDown", windowsVirtualKeyCode: 40 },
ArrowLeft: { key: "ArrowLeft", code: "ArrowLeft", windowsVirtualKeyCode: 37 },
ArrowRight: { key: "ArrowRight", code: "ArrowRight", windowsVirtualKeyCode: 39 },
Home: { key: "Home", code: "Home", windowsVirtualKeyCode: 36 },
End: { key: "End", code: "End", windowsVirtualKeyCode: 35 },
PageUp: { key: "PageUp", code: "PageUp", windowsVirtualKeyCode: 33 },
PageDown: { key: "PageDown", code: "PageDown", windowsVirtualKeyCode: 34 },
};
export async function dispatchTrustedClick(
send: CdpCommandSender,
point: ActionablePoint,
options: ClickInputOptions = {},
): Promise<void> {
const button = options.button ?? "left";
const modifiers = modifierMask(options.modifiers);
await send("Input.dispatchMouseEvent", {
type: "mouseMoved",
x: point.x,
y: point.y,
button: "none",
modifiers,
});
if (options.doubleClick) {
await dispatchTrustedMouseClick(send, point, button, modifiers, 1);
await dispatchTrustedMouseClick(send, point, button, modifiers, 2);
return;
}
await dispatchTrustedMouseClick(send, point, button, modifiers, 1);
}
async function dispatchTrustedMouseClick(
send: CdpCommandSender,
point: ActionablePoint,
button: MouseButton,
modifiers: number,
clickCount: number,
): Promise<void> {
await send("Input.dispatchMouseEvent", {
type: "mousePressed",
x: point.x,
y: point.y,
button,
buttons: mouseButtonMask(button),
clickCount,
modifiers,
});
await send("Input.dispatchMouseEvent", {
type: "mouseReleased",
x: point.x,
y: point.y,
button,
buttons: 0,
clickCount,
modifiers,
});
}
export async function dispatchTrustedHover(
send: CdpCommandSender,
point: ActionablePoint,
): Promise<void> {
await send("Input.dispatchMouseEvent", {
type: "mouseMoved",
x: point.x,
y: point.y,
button: "none",
});
}
export async function dispatchTrustedDrag(
send: CdpCommandSender,
source: ActionablePoint,
target: ActionablePoint,
): Promise<void> {
await send("Input.dispatchMouseEvent", {
type: "mouseMoved",
x: source.x,
y: source.y,
button: "none",
});
await send("Input.dispatchMouseEvent", {
type: "mousePressed",
x: source.x,
y: source.y,
button: "left",
buttons: 1,
clickCount: 1,
});
await send("Input.dispatchMouseEvent", {
type: "mouseMoved",
x: (source.x + target.x) / 2,
y: (source.y + target.y) / 2,
button: "left",
buttons: 1,
});
await send("Input.dispatchMouseEvent", {
type: "mouseMoved",
x: target.x,
y: target.y,
button: "left",
buttons: 1,
});
await send("Input.dispatchMouseEvent", {
type: "mouseReleased",
x: target.x,
y: target.y,
button: "left",
buttons: 0,
clickCount: 1,
});
}
export async function dispatchTrustedScroll(
send: CdpCommandSender,
point: ActionablePoint,
deltaX: number,
deltaY: number,
): Promise<void> {
await send("Input.dispatchMouseEvent", {
type: "mouseWheel",
x: point.x,
y: point.y,
deltaX,
deltaY,
});
}
export async function dispatchTrustedText(send: CdpCommandSender, text: string): Promise<void> {
if (text.length === 0) {
return;
}
await send("Input.insertText", { text });
}
export async function dispatchTrustedKey(send: CdpCommandSender, key: string): Promise<void> {
const definition = keyDefinition(key);
await send("Input.dispatchKeyEvent", {
type: "keyDown",
key: definition.key,
code: definition.code,
windowsVirtualKeyCode: definition.windowsVirtualKeyCode,
nativeVirtualKeyCode: definition.windowsVirtualKeyCode,
...(definition.text ? { text: definition.text, unmodifiedText: definition.text } : {}),
});
await send("Input.dispatchKeyEvent", {
type: "keyUp",
key: definition.key,
code: definition.code,
windowsVirtualKeyCode: definition.windowsVirtualKeyCode,
nativeVirtualKeyCode: definition.windowsVirtualKeyCode,
});
}
function keyDefinition(key: string): {
key: string;
code: string;
windowsVirtualKeyCode: number;
text?: string;
} {
const special = SPECIAL_KEY_DEFINITIONS[key];
if (special) {
return special;
}
const text = key.length === 1 ? key : "";
const upper = text.toUpperCase();
return {
key,
code: upper ? `Key${upper}` : key,
windowsVirtualKeyCode: upper ? upper.charCodeAt(0) : 0,
...(text ? { text, unmodifiedText: text } : {}),
};
}
function modifierMask(modifiers: InputModifier[] | undefined): number {
return (modifiers ?? []).reduce((mask, modifier) => mask | MODIFIER_MASKS[modifier], 0);
}
function mouseButtonMask(button: MouseButton): number {
if (button === "right") {
return 2;
}
if (button === "middle") {
return 4;
}
return 1;
}

View File

@@ -0,0 +1,44 @@
import { describe, expect, test } from "vitest";
import { getPaseoBrowserIdForWebContents, registerPaseoBrowserWebContents } from "./index.js";
class FakeRegisteredWebContents {
public readonly backgroundThrottlingCalls: boolean[] = [];
private destroyedListener: (() => void) | null = null;
private destroyed = false;
public constructor(public readonly id: number) {}
public isDestroyed(): boolean {
return this.destroyed;
}
public setBackgroundThrottling(allowed: boolean): void {
this.backgroundThrottlingCalls.push(allowed);
}
public once(event: "destroyed", listener: () => void): void {
expect(event).toBe("destroyed");
this.destroyedListener = listener;
}
public destroy(): void {
this.destroyed = true;
this.destroyedListener?.();
}
}
describe("registerPaseoBrowserWebContents", () => {
test("disables guest background throttling once when the webview is registered", () => {
const contents = new FakeRegisteredWebContents(9001);
registerPaseoBrowserWebContents(contents, "browser-throttle");
expect(contents.backgroundThrottlingCalls).toEqual([false]);
expect(getPaseoBrowserIdForWebContents(contents)).toBe("browser-throttle");
contents.destroy();
expect(getPaseoBrowserIdForWebContents(contents)).toBeNull();
expect(contents.backgroundThrottlingCalls).toEqual([false]);
});
});

View File

@@ -11,6 +11,16 @@ export type { BrowserWorkspaceRegistration };
const browserRegistry = new PaseoBrowserWebviewRegistry();
interface BrowserWebContentsIdentity {
readonly id: number;
isDestroyed(): boolean;
}
interface RegisteredBrowserWebContents extends BrowserWebContentsIdentity {
setBackgroundThrottling(allowed: boolean): void;
once(event: "destroyed", listener: () => void): void;
}
function getBrowserIdFromWebviewPartition(partition: string | undefined): string | null {
const prefix = "persist:paseo-browser-";
if (!partition?.startsWith(prefix)) {
@@ -36,14 +46,20 @@ export function listRegisteredPaseoBrowserIds(): string[] {
.filter((browserId) => getPaseoBrowserWebContents(browserId));
}
export function registerPaseoBrowserWebContents(contents: WebContents, browserId: string): void {
export function registerPaseoBrowserWebContents(
contents: RegisteredBrowserWebContents,
browserId: string,
): void {
contents.setBackgroundThrottling(false);
browserRegistry.registerWebContents({ webContentsId: contents.id, browserId });
contents.once("destroyed", () => {
browserRegistry.unregisterWebContents(contents.id);
});
}
export function getPaseoBrowserIdForWebContents(contents: WebContents | null): string | null {
export function getPaseoBrowserIdForWebContents(
contents: BrowserWebContentsIdentity | null,
): string | null {
if (!contents || contents.isDestroyed()) {
return null;
}
@@ -54,6 +70,10 @@ export function registerPaseoBrowserWorkspace(input: BrowserWorkspaceRegistratio
browserRegistry.registerWorkspace(input);
}
export function unregisterPaseoBrowser(browserId: string): void {
browserRegistry.unregisterBrowser(browserId);
}
export function getPaseoBrowserWorkspaceId(browserId: string): string | null {
return browserRegistry.getWorkspaceId(browserId);
}
@@ -75,17 +95,6 @@ export function getWorkspaceActivePaseoBrowserId(workspaceId: string): string |
return browserRegistry.getWorkspaceActiveBrowserId(workspaceId);
}
export function setAgentActivePaseoBrowserId(input: {
agentId: string;
browserId: string | null;
}): void {
browserRegistry.setAgentActiveBrowser(input);
}
export function getAgentActivePaseoBrowserId(agentId: string): string | null {
return browserRegistry.getAgentActiveBrowserId(agentId);
}
export function getPaseoBrowserWebContents(browserId: string): WebContents | null {
const contentsId = browserRegistry.getWebContentsIdForBrowser(browserId);
if (contentsId === null) {
@@ -99,16 +108,6 @@ export function getPaseoBrowserWebContents(browserId: string): WebContents | nul
return null;
}
export function getWorkspaceActivePaseoBrowserWebContents(workspaceId: string): WebContents | null {
const activeBrowserId = getWorkspaceActivePaseoBrowserId(workspaceId);
return activeBrowserId ? getPaseoBrowserWebContents(activeBrowserId) : null;
}
export function getAgentActivePaseoBrowserWebContents(agentId: string): WebContents | null {
const activeBrowserId = getAgentActivePaseoBrowserId(agentId);
return activeBrowserId ? getPaseoBrowserWebContents(activeBrowserId) : null;
}
export function getMostRecentWorkspaceActivePaseoBrowserWebContents(): WebContents | null {
const browserId = browserRegistry.getMostRecentWorkspaceActiveBrowserId();
return browserId ? getPaseoBrowserWebContents(browserId) : null;

View File

@@ -8,7 +8,6 @@ describe("PaseoBrowserWebviewRegistry", () => {
registry.registerWebContents({ webContentsId: 1, browserId: "browser-a" });
registry.registerWorkspace({ browserId: "browser-a", workspaceId: "workspace-a" });
registry.setWorkspaceActiveBrowser({ workspaceId: "workspace-a", browserId: "browser-a" });
registry.setAgentActiveBrowser({ agentId: "agent-a", browserId: "browser-a" });
registry.registerWebContents({ webContentsId: 2, browserId: "browser-a" });
expect(registry.getBrowserIdForWebContents(1)).toBeNull();
@@ -16,7 +15,6 @@ describe("PaseoBrowserWebviewRegistry", () => {
expect(registry.getWebContentsIdForBrowser("browser-a")).toBe(2);
expect(registry.getWorkspaceId("browser-a")).toBe("workspace-a");
expect(registry.getWorkspaceActiveBrowserId("workspace-a")).toBe("browser-a");
expect(registry.getAgentActiveBrowserId("agent-a")).toBe("browser-a");
});
it("ignores stale destroy events after a duplicate browserId moved", () => {

View File

@@ -8,7 +8,6 @@ export class PaseoBrowserWebviewRegistry {
private readonly webContentsIdsByBrowserId = new Map<string, number>();
private readonly workspaceIdsByBrowserId = new Map<string, string>();
private readonly activeBrowserIdsByWorkspaceId = new Map<string, string>();
private readonly activeBrowserIdsByAgentId = new Map<string, string>();
public registerWebContents(input: { webContentsId: number; browserId: string }): void {
const previousWebContentsId = this.webContentsIdsByBrowserId.get(input.browserId) ?? null;
@@ -52,6 +51,16 @@ export class PaseoBrowserWebviewRegistry {
this.workspaceIdsByBrowserId.set(input.browserId, input.workspaceId);
}
public unregisterBrowser(browserId: string): void {
const webContentsId = this.webContentsIdsByBrowserId.get(browserId) ?? null;
if (webContentsId !== null) {
this.browserIdsByWebContentsId.delete(webContentsId);
this.webContentsIdsByBrowserId.delete(browserId);
}
this.workspaceIdsByBrowserId.delete(browserId);
this.deleteActiveBrowserReferences(browserId);
}
public getWorkspaceId(browserId: string): string | null {
return this.workspaceIdsByBrowserId.get(browserId) ?? null;
}
@@ -80,29 +89,11 @@ export class PaseoBrowserWebviewRegistry {
return Array.from(this.activeBrowserIdsByWorkspaceId.values()).at(-1) ?? null;
}
public setAgentActiveBrowser(input: { agentId: string; browserId: string | null }): void {
if (input.browserId) {
this.activeBrowserIdsByAgentId.delete(input.agentId);
this.activeBrowserIdsByAgentId.set(input.agentId, input.browserId);
return;
}
this.activeBrowserIdsByAgentId.delete(input.agentId);
}
public getAgentActiveBrowserId(agentId: string): string | null {
return this.activeBrowserIdsByAgentId.get(agentId) ?? null;
}
private deleteActiveBrowserReferences(browserId: string): void {
for (const [workspaceId, activeBrowserId] of this.activeBrowserIdsByWorkspaceId) {
if (activeBrowserId === browserId) {
this.activeBrowserIdsByWorkspaceId.delete(workspaceId);
}
}
for (const [agentId, activeBrowserId] of this.activeBrowserIdsByAgentId) {
if (activeBrowserId === browserId) {
this.activeBrowserIdsByAgentId.delete(agentId);
}
}
}
}

View File

@@ -54,9 +54,9 @@ import {
listRegisteredPaseoBrowserIds,
readBrowserIdFromWebviewAttach,
registerBrowserWebviewNavigationGuards,
unregisterPaseoBrowser,
registerPaseoBrowserWorkspace,
registerPaseoBrowserWebContents,
setAgentActivePaseoBrowserId,
setWorkspaceActivePaseoBrowserId,
} from "./features/browser-webviews/index.js";
import { parseOpenProjectPathFromArgv } from "./open-project-routing.js";
@@ -144,20 +144,6 @@ function readActiveBrowserInput(
return { workspaceId: record.workspaceId.trim(), browserId: browserId || null };
}
function readAgentActiveBrowserInput(
input: unknown,
): { agentId: string; browserId: string | null } | null {
if (typeof input !== "object" || input === null || Array.isArray(input)) {
return null;
}
const record = input as Record<string, unknown>;
if (typeof record.agentId !== "string" || record.agentId.trim().length === 0) {
return null;
}
const browserId = typeof record.browserId === "string" ? record.browserId.trim() : null;
return { agentId: record.agentId.trim(), browserId: browserId || null };
}
const pendingBrowserWebviewIds: string[] = [];
function isBrowserRefreshInput(input: Electron.Input): boolean {
@@ -343,6 +329,12 @@ ipcMain.handle("paseo:browser:register-workspace-browser", (_event, rawInput: un
}
});
ipcMain.handle("paseo:browser:unregister-workspace-browser", (_event, browserId: unknown) => {
if (typeof browserId === "string" && browserId.trim().length > 0) {
unregisterPaseoBrowser(browserId.trim());
}
});
ipcMain.handle("paseo:browser:set-workspace-active-browser", (_event, rawInput: unknown) => {
const input = readActiveBrowserInput(rawInput);
if (input) {
@@ -350,13 +342,6 @@ ipcMain.handle("paseo:browser:set-workspace-active-browser", (_event, rawInput:
}
});
ipcMain.handle("paseo:browser:set-agent-active-browser", (_event, rawInput: unknown) => {
const input = readAgentActiveBrowserInput(rawInput);
if (input) {
setAgentActivePaseoBrowserId(input);
}
});
ipcMain.handle("paseo:browser:open-devtools", (_event, browserId: unknown) => {
if (typeof browserId !== "string" || browserId.trim().length === 0) {
const result = {

View File

@@ -76,10 +76,10 @@ contextBridge.exposeInMainWorld("paseoDesktop", {
browser: {
registerWorkspaceBrowser: (input: { browserId: string; workspaceId: string }) =>
ipcRenderer.invoke("paseo:browser:register-workspace-browser", input),
unregisterWorkspaceBrowser: (browserId: string) =>
ipcRenderer.invoke("paseo:browser:unregister-workspace-browser", browserId),
setWorkspaceActiveBrowser: (input: { workspaceId: string; browserId: string | null }) =>
ipcRenderer.invoke("paseo:browser:set-workspace-active-browser", input),
setAgentActiveBrowser: (input: { agentId: string; browserId: string | null }) =>
ipcRenderer.invoke("paseo:browser:set-agent-active-browser", input),
openDevTools: (browserId: string) =>
ipcRenderer.invoke("paseo:browser:open-devtools", browserId),
clearPartition: (browserId: string) =>

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.103",
"version": "0.1.104-beta.4",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.1.103",
"version": "0.1.104-beta.4",
"files": [
"dist",
"!dist/**/*.map"

View File

@@ -0,0 +1,7 @@
# Protocol Validator Codegen
This directory is build-time only. `ws-outbound.compile.ts` is the zod-aot discovery entry for the inbound WebSocket validator.
The generated runtime file is written to `../src/generated/validation/ws-outbound.aot.ts` and is not committed. The protocol package owns every generation trigger through its npm lifecycle scripts.
`zod-aot` is exact-pinned, and the protocol generator applies the small compiler patches it requires before generation. Treat changes to those patches like compiler changes: regenerate, inspect the output, and run the protocol validation regression tests before shipping.

View File

@@ -0,0 +1,4 @@
import { compile } from "zod-aot";
import { WSOutboundMessageSchema as SourceWSOutboundMessageSchema } from "../src/messages.js";
export const WSOutboundMessageSchema = compile(SourceWSOutboundMessageSchema);

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/protocol",
"version": "0.1.103",
"version": "0.1.104-beta.4",
"description": "Paseo shared protocol schemas and wire types",
"files": [
"dist",
@@ -23,7 +23,12 @@
"build:clean": "npm run clean && npm run build",
"prepack": "npm run build:clean",
"typecheck": "tsgo --noEmit",
"test": "vitest run"
"test": "vitest run",
"generate:validators": "node scripts/generate-validation-aot.mjs",
"watch": "concurrently --kill-others --names validation,tsc --prefix-colors green,yellow \"node scripts/watch-validation-aot.mjs\" \"tsc -p tsconfig.json --watch --preserveWatchOutput\"",
"prebuild": "npm run generate:validators",
"pretypecheck": "npm run generate:validators",
"pretest": "npm run generate:validators"
},
"dependencies": {
"zod": "^4.4.3"
@@ -31,6 +36,7 @@
"devDependencies": {
"@types/node": "^20.9.0",
"typescript": "^5.2.2",
"vitest": "^4.1.6"
"vitest": "^4.1.6",
"zod-aot": "0.20.4"
}
}

View File

@@ -0,0 +1,106 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { createRequire } from "node:module";
import { dirname, relative, resolve, sep } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const source = resolve(packageRoot, "codegen/ws-outbound.compile.ts");
const runtimeSchemaMetadata = resolve(packageRoot, "src/validation/ws-outbound-schema-metadata.ts");
const output = resolve(packageRoot, "src/generated/validation/ws-outbound.aot.ts");
const require = createRequire(import.meta.url);
const zodAotEntry = require.resolve("zod-aot");
const zodAotRoot = resolve(dirname(zodAotEntry), "..");
const emitterPath = resolve(zodAotRoot, "dist/cli/emitter.js");
const discriminatedUnionPath = resolve(
zodAotRoot,
"dist/core/codegen/schemas/discriminated-union.js",
);
async function ensureZodAotRuntimeImportExtensionPatch() {
const emitter = await readFile(emitterPath, "utf8");
if (emitter.includes('sourceRelPath.endsWith(".js")')) {
return;
}
const before = 'let importPath = sourceRelPath.replace(/\\.[cm]?[jt]sx?$/, "");';
const after =
'let importPath = sourceRelPath.endsWith(".js")\n ? sourceRelPath\n : sourceRelPath.replace(/\\.[cm]?[jt]sx?$/, "");';
if (!emitter.includes(before)) {
throw new Error("zod-aot emitter shape changed; update the runtime import extension patch");
}
await writeFile(emitterPath, emitter.replace(before, after));
}
async function ensureZodAotDiscriminatedUnionOutputPatch() {
let discriminatedUnionEmitter = await readFile(discriminatedUnionPath, "utf8");
if (
discriminatedUnionEmitter.includes(
"const needsOutputPropagation = ir.options.some(hasMutation);",
)
) {
return;
}
const importBefore = 'import { escapeString } from "../context.js";';
const importAfter = 'import { escapeString, hasMutation } from "../context.js";';
const outputFlagBefore = "const discKey = escapeString(ir.discriminator);\n let code = emit `";
const outputFlagAfter =
"const discKey = escapeString(ir.discriminator);\n const needsOutputPropagation = ir.options.some(hasMutation);\n let code = emit `";
const propagationBefore =
" ${g.visit(option, { input: objVar, output: objVar })}\n break;`;";
const propagationAfter =
' ${g.visit(option, { input: objVar, output: objVar })}\n ${needsOutputPropagation ? `${g.output}=${objVar};` : ""}\n break;`;';
if (
!discriminatedUnionEmitter.includes(importBefore) ||
!discriminatedUnionEmitter.includes(outputFlagBefore) ||
!discriminatedUnionEmitter.includes(propagationBefore)
) {
throw new Error("zod-aot discriminated-union emitter shape changed; update the output patch");
}
discriminatedUnionEmitter = discriminatedUnionEmitter
.replace(importBefore, importAfter)
.replace(outputFlagBefore, outputFlagAfter)
.replace(propagationBefore, propagationAfter);
await writeFile(discriminatedUnionPath, discriminatedUnionEmitter);
}
await Promise.all([
ensureZodAotRuntimeImportExtensionPatch(),
ensureZodAotDiscriminatedUnionOutputPatch(),
]);
const [{ discoverSchemas }, { compileSchemas }, { generateCompiledFileContent }] =
await Promise.all([
import(pathToFileURL(resolve(zodAotRoot, "dist/discovery.js")).href),
import(pathToFileURL(resolve(zodAotRoot, "dist/core/pipeline.js")).href),
import(pathToFileURL(resolve(zodAotRoot, "dist/cli/emitter.js")).href),
]);
const schemas = await discoverSchemas(source, { cacheBust: true });
if (schemas.length === 0) {
throw new Error(`No zod-aot compile() exports found in ${relative(packageRoot, source)}`);
}
const compiled = compileSchemas(schemas, { mode: "inline" });
const runtimeImportPath = relative(dirname(output), runtimeSchemaMetadata)
.replace(/\.[cm]?[jt]sx?$/, ".js")
.split(sep)
.join("/");
const content = generateCompiledFileContent(compiled, runtimeImportPath, {
zodCompat: false,
}).replace(
"// AUTO-GENERATED by zod-aot — DO NOT EDIT",
"// @ts-nocheck\n// AUTO-GENERATED by zod-aot — DO NOT EDIT",
);
await mkdir(dirname(output), { recursive: true });
await writeFile(output, content);
console.info(
`generated ${relative(packageRoot, output)} from ${relative(packageRoot, source)} (${schemas
.map((schema) => schema.exportName)
.join(", ")})`,
);

View File

@@ -0,0 +1,96 @@
import { readdir, stat } from "node:fs/promises";
import { dirname, join, relative, sep } from "node:path";
import { fileURLToPath } from "node:url";
import { spawn } from "node:child_process";
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
const protocolSrcDir = join(packageRoot, "src");
const pollIntervalMs = 1000;
let currentFingerprint = "";
let generateInFlight = false;
let generateAgain = false;
async function collectSourceFiles(dir) {
const entries = await readdir(dir, { withFileTypes: true });
const files = [];
for (const entry of entries) {
const path = join(dir, entry.name);
const relativePath = relative(protocolSrcDir, path);
const parts = relativePath.split(sep);
if (parts[0] === "generated") {
continue;
}
if (entry.isDirectory()) {
files.push(...(await collectSourceFiles(path)));
continue;
}
if (entry.isFile() && entry.name.endsWith(".ts")) {
files.push(path);
}
}
return files;
}
async function fingerprintSources() {
const files = await collectSourceFiles(protocolSrcDir);
const stats = await Promise.all(
files.sort().map(async (file) => {
const fileStat = await stat(file);
return `${relative(packageRoot, file)}:${fileStat.mtimeMs}:${fileStat.size}`;
}),
);
return stats.join("\n");
}
function runGenerate() {
if (generateInFlight) {
generateAgain = true;
return;
}
generateInFlight = true;
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
const child = spawn(npmCommand, ["run", "generate:validators"], {
cwd: packageRoot,
stdio: "inherit",
});
child.on("exit", (code, signal) => {
generateInFlight = false;
if (code !== 0) {
console.error(
signal
? `validation AOT generation stopped by ${signal}`
: `validation AOT generation failed with exit code ${code}`,
);
}
if (generateAgain) {
generateAgain = false;
runGenerate();
}
});
}
async function poll() {
try {
const nextFingerprint = await fingerprintSources();
if (currentFingerprint && nextFingerprint !== currentFingerprint) {
runGenerate();
}
currentFingerprint = nextFingerprint;
} catch (error) {
console.error("validation AOT watcher failed to scan protocol sources", error);
}
}
currentFingerprint = await fingerprintSources();
console.log("Watching protocol schema sources for validation AOT changes...");
setInterval(poll, pollIntervalMs);

View File

@@ -0,0 +1,41 @@
import { z } from "zod";
import {
BROWSER_AUTOMATION_COMMAND_NAMES,
type BrowserAutomationCommandName,
} from "./rpc-schemas.js";
const KNOWN_BROWSER_AUTOMATION_COMMAND_NAMES = new Set<string>(BROWSER_AUTOMATION_COMMAND_NAMES);
export const BrowserAutomationHostCapabilitySchema = z
.object({
supportedCommands: z.array(z.string().min(1)).transform((commands, context) => {
const supportedCommands: BrowserAutomationCommandName[] = [];
const seen = new Set<BrowserAutomationCommandName>();
for (const command of commands) {
if (!isKnownBrowserAutomationCommandName(command) || seen.has(command)) {
continue;
}
seen.add(command);
supportedCommands.push(command);
}
if (supportedCommands.length === 0) {
context.addIssue({
code: "custom",
message: "supportedCommands must include at least one known browser automation command",
});
return z.NEVER;
}
return supportedCommands;
}),
hostKind: z.string().min(1).default("browser host"),
})
.passthrough();
export type BrowserAutomationHostCapability = z.infer<typeof BrowserAutomationHostCapabilitySchema>;
function isKnownBrowserAutomationCommandName(value: string): value is BrowserAutomationCommandName {
return KNOWN_BROWSER_AUTOMATION_COMMAND_NAMES.has(value);
}

File diff suppressed because it is too large Load Diff

View File

@@ -2,23 +2,65 @@ import { z } from "zod";
export const BrowserAutomationErrorCodeSchema = z.enum([
"browser_disabled",
"browser_no_desktop",
"browser_no_tab",
"browser_no_host",
"browser_tab_not_found",
"browser_tab_closed",
"browser_timeout",
"screenshot_no_frame",
"browser_denied",
"browser_unsupported",
"browser_stale_ref",
"browser_unknown_error",
]);
const BrowserAutomationTabTargetSchema = z.object({
workspaceId: z.string().min(1).optional(),
browserId: z.string().min(1).optional(),
});
const BROWSER_AUTOMATION_BROWSER_ID_PATTERN =
/^(?:[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}|\d{13,}-[0-9a-f]+)$/i;
const BROWSER_AUTOMATION_BROWSER_ID_MESSAGE =
"browserId must be a real id returned by browser_new_tab or browser_list_tabs";
const BROWSER_AUTOMATION_WAIT_CONDITION_MESSAGE =
"browser_wait requires exactly one of text or url";
export const BROWSER_AUTOMATION_COMMAND_NAMES = [
"list_tabs",
"new_tab",
"snapshot",
"click",
"fill",
"wait",
"type",
"keypress",
"navigate",
"back",
"forward",
"reload",
"screenshot",
"upload",
"select",
"hover",
"drag",
"logs",
"evaluate",
"scroll",
"resize",
"close_tab",
] as const;
export const BrowserAutomationCommandNameSchema = z.enum(BROWSER_AUTOMATION_COMMAND_NAMES);
export const BrowserAutomationBrowserIdSchema = z
.string({ error: () => BROWSER_AUTOMATION_BROWSER_ID_MESSAGE })
.min(1, BROWSER_AUTOMATION_BROWSER_ID_MESSAGE)
.regex(BROWSER_AUTOMATION_BROWSER_ID_PATTERN, BROWSER_AUTOMATION_BROWSER_ID_MESSAGE);
const BrowserAutomationTabTargetSchema = z
.object({
browserId: BrowserAutomationBrowserIdSchema,
})
.strict();
const BrowserAutomationRefSchema = z.string().regex(/^@e\d+$/);
const BrowserAutomationMouseButtonSchema = z.enum(["left", "right", "middle"]);
const BrowserAutomationInputModifierSchema = z.enum(["Alt", "Control", "Meta", "Shift"]);
const BrowserAutomationHttpUrlSchema = z
.string()
.url()
@@ -29,37 +71,31 @@ const BrowserAutomationHttpUrlSchema = z
export const BrowserAutomationListTabsCommandSchema = z.object({
command: z.literal("list_tabs"),
args: z
.object({
workspaceId: z.string().min(1).optional(),
})
.default({}),
args: z.object({}).strict().default({}),
});
export const BrowserAutomationNewTabCommandSchema = z.object({
command: z.literal("new_tab"),
args: z
.object({
workspaceId: z.string().min(1).optional(),
url: BrowserAutomationHttpUrlSchema.optional(),
})
.strict()
.default({}),
});
export const BrowserAutomationPageInfoCommandSchema = z.object({
command: z.literal("page_info"),
args: BrowserAutomationTabTargetSchema.default({}),
});
export const BrowserAutomationSnapshotCommandSchema = z.object({
command: z.literal("snapshot"),
args: BrowserAutomationTabTargetSchema.default({}),
args: BrowserAutomationTabTargetSchema,
});
export const BrowserAutomationClickCommandSchema = z.object({
command: z.literal("click"),
args: BrowserAutomationTabTargetSchema.extend({
ref: BrowserAutomationRefSchema,
button: BrowserAutomationMouseButtonSchema.default("left"),
doubleClick: z.boolean().default(false),
modifiers: z.array(BrowserAutomationInputModifierSchema).default([]),
}),
});
@@ -77,6 +113,8 @@ export const BrowserAutomationWaitCommandSchema = z.object({
text: z.string().min(1).optional(),
url: z.string().min(1).optional(),
timeoutMs: z.number().int().positive().max(30_000).optional(),
}).refine((args) => Number(Boolean(args.text)) + Number(Boolean(args.url)) === 1, {
message: BROWSER_AUTOMATION_WAIT_CONDITION_MESSAGE,
}),
});
@@ -105,42 +143,23 @@ export const BrowserAutomationNavigateCommandSchema = z.object({
export const BrowserAutomationBackCommandSchema = z.object({
command: z.literal("back"),
args: BrowserAutomationTabTargetSchema.default({}),
args: BrowserAutomationTabTargetSchema,
});
export const BrowserAutomationForwardCommandSchema = z.object({
command: z.literal("forward"),
args: BrowserAutomationTabTargetSchema.default({}),
args: BrowserAutomationTabTargetSchema,
});
export const BrowserAutomationReloadCommandSchema = z.object({
command: z.literal("reload"),
args: BrowserAutomationTabTargetSchema.default({}),
args: BrowserAutomationTabTargetSchema,
});
export const BrowserAutomationScreenshotCommandSchema = z.object({
command: z.literal("screenshot"),
args: BrowserAutomationTabTargetSchema.default({}),
});
export const BrowserAutomationFullPageScreenshotCommandSchema = z.object({
command: z.literal("full_page_screenshot"),
args: BrowserAutomationTabTargetSchema.default({}),
});
export const BrowserAutomationPdfCommandSchema = z.object({
command: z.literal("pdf"),
args: BrowserAutomationTabTargetSchema.extend({
landscape: z.boolean().optional(),
printBackground: z.boolean().default(true),
}).default({ printBackground: true }),
});
export const BrowserAutomationDownloadCommandSchema = z.object({
command: z.literal("download"),
args: BrowserAutomationTabTargetSchema.extend({
url: BrowserAutomationHttpUrlSchema,
fileName: z.string().min(1).optional(),
fullPage: z.boolean().default(false),
}),
});
@@ -152,28 +171,6 @@ export const BrowserAutomationUploadCommandSchema = z.object({
}),
});
export const BrowserAutomationFocusCommandSchema = z.object({
command: z.literal("focus"),
args: BrowserAutomationTabTargetSchema.extend({
ref: BrowserAutomationRefSchema,
}),
});
export const BrowserAutomationClearCommandSchema = z.object({
command: z.literal("clear"),
args: BrowserAutomationTabTargetSchema.extend({
ref: BrowserAutomationRefSchema,
}),
});
export const BrowserAutomationCheckCommandSchema = z.object({
command: z.literal("check"),
args: BrowserAutomationTabTargetSchema.extend({
ref: BrowserAutomationRefSchema,
checked: z.boolean().default(true),
}),
});
export const BrowserAutomationSelectCommandSchema = z.object({
command: z.literal("select"),
args: BrowserAutomationTabTargetSchema.extend({
@@ -201,45 +198,42 @@ export const BrowserAutomationLogsCommandSchema = z.object({
command: z.literal("logs"),
args: BrowserAutomationTabTargetSchema.extend({
maxEntries: z.number().int().positive().max(200).default(50),
}).default({ maxEntries: 50 }),
});
export const BrowserAutomationStorageCommandSchema = z.object({
command: z.literal("storage"),
args: BrowserAutomationTabTargetSchema.default({}),
});
const BrowserAutomationViewportInputSchema = z.object({
width: z.number().int().positive(),
height: z.number().int().positive(),
deviceScaleFactor: z.number().positive().optional(),
});
const BrowserAutomationGeolocationInputSchema = z.object({
latitude: z.number().min(-90).max(90),
longitude: z.number().min(-180).max(180),
accuracy: z.number().positive().optional(),
});
export const BrowserAutomationEnvironmentCommandSchema = z.object({
command: z.literal("environment"),
args: BrowserAutomationTabTargetSchema.extend({
viewport: BrowserAutomationViewportInputSchema.optional(),
geolocation: BrowserAutomationGeolocationInputSchema.optional(),
}).default({}),
});
export const BrowserAutomationSetBackgroundCommandSchema = z.object({
command: z.literal("set_background"),
args: BrowserAutomationTabTargetSchema.extend({
color: z.string().min(1),
}),
});
export const BrowserAutomationEvaluateCommandSchema = z.object({
command: z.literal("evaluate"),
args: BrowserAutomationTabTargetSchema.extend({
function: z.string().min(1),
ref: BrowserAutomationRefSchema.optional(),
}),
});
export const BrowserAutomationScrollCommandSchema = z.object({
command: z.literal("scroll"),
args: BrowserAutomationTabTargetSchema.extend({
ref: BrowserAutomationRefSchema.optional(),
deltaX: z.number(),
deltaY: z.number(),
}),
});
export const BrowserAutomationResizeCommandSchema = z.object({
command: z.literal("resize"),
args: BrowserAutomationTabTargetSchema.extend({
width: z.number().int().positive(),
height: z.number().int().positive(),
}),
});
export const BrowserAutomationCloseTabCommandSchema = z.object({
command: z.literal("close_tab"),
args: BrowserAutomationTabTargetSchema,
});
export const BrowserAutomationCommandSchema = z.discriminatedUnion("command", [
BrowserAutomationListTabsCommandSchema,
BrowserAutomationNewTabCommandSchema,
BrowserAutomationPageInfoCommandSchema,
BrowserAutomationSnapshotCommandSchema,
BrowserAutomationClickCommandSchema,
BrowserAutomationFillCommandSchema,
@@ -251,24 +245,19 @@ export const BrowserAutomationCommandSchema = z.discriminatedUnion("command", [
BrowserAutomationForwardCommandSchema,
BrowserAutomationReloadCommandSchema,
BrowserAutomationScreenshotCommandSchema,
BrowserAutomationFullPageScreenshotCommandSchema,
BrowserAutomationPdfCommandSchema,
BrowserAutomationDownloadCommandSchema,
BrowserAutomationUploadCommandSchema,
BrowserAutomationFocusCommandSchema,
BrowserAutomationClearCommandSchema,
BrowserAutomationCheckCommandSchema,
BrowserAutomationSelectCommandSchema,
BrowserAutomationHoverCommandSchema,
BrowserAutomationDragCommandSchema,
BrowserAutomationLogsCommandSchema,
BrowserAutomationStorageCommandSchema,
BrowserAutomationEnvironmentCommandSchema,
BrowserAutomationSetBackgroundCommandSchema,
BrowserAutomationEvaluateCommandSchema,
BrowserAutomationScrollCommandSchema,
BrowserAutomationResizeCommandSchema,
BrowserAutomationCloseTabCommandSchema,
]);
export const BrowserAutomationTabInfoSchema = z.object({
browserId: z.string().min(1),
browserId: BrowserAutomationBrowserIdSchema,
workspaceId: z.string().min(1).optional(),
url: z.string(),
title: z.string(),
@@ -285,164 +274,131 @@ export const BrowserAutomationListTabsResultSchema = z.object({
export const BrowserAutomationNewTabResultSchema = z.object({
command: z.literal("new_tab"),
browserId: z.string().min(1),
browserId: BrowserAutomationBrowserIdSchema,
workspaceId: z.string().min(1),
url: z.string().min(1),
});
export const BrowserAutomationPageInfoResultSchema = z.object({
command: z.literal("page_info"),
tab: BrowserAutomationTabInfoSchema,
});
export const BrowserAutomationSnapshotElementSchema = z.object({
ref: z.string().regex(/^@e\d+$/),
role: z.string(),
tagName: z.string(),
text: z.string(),
selector: z.string(),
attributes: z.record(z.string(), z.string()).default({}),
});
export const BrowserAutomationSnapshotStatsSchema = z
.object({
nodeCount: z.number().int().nonnegative(),
refCount: z.number().int().nonnegative(),
textLength: z.number().int().nonnegative(),
iframeCount: z.number().int().nonnegative().optional(),
maxDepth: z.number().int().nonnegative().optional(),
})
.strict();
export const BrowserAutomationSnapshotResultSchema = z.object({
command: z.literal("snapshot"),
browserId: z.string().min(1),
browserId: BrowserAutomationBrowserIdSchema,
workspaceId: z.string().min(1).optional(),
url: z.string(),
title: z.string(),
elements: z.array(BrowserAutomationSnapshotElementSchema),
format: z.literal("aria-yaml"),
snapshot: z.string(),
truncated: z.boolean(),
stats: BrowserAutomationSnapshotStatsSchema,
});
export const BrowserAutomationClickResultSchema = z.object({
command: z.literal("click"),
browserId: z.string().min(1),
browserId: BrowserAutomationBrowserIdSchema,
ref: BrowserAutomationRefSchema,
x: z.number().optional(),
y: z.number().optional(),
});
export const BrowserAutomationFillResultSchema = z.object({
command: z.literal("fill"),
browserId: z.string().min(1),
browserId: BrowserAutomationBrowserIdSchema,
ref: BrowserAutomationRefSchema,
});
export const BrowserAutomationWaitResultSchema = z.object({
command: z.literal("wait"),
browserId: z.string().min(1),
browserId: BrowserAutomationBrowserIdSchema,
matched: z.enum(["text", "url"]),
});
export const BrowserAutomationTypeResultSchema = z.object({
command: z.literal("type"),
browserId: z.string().min(1),
browserId: BrowserAutomationBrowserIdSchema,
ref: BrowserAutomationRefSchema.optional(),
x: z.number().optional(),
y: z.number().optional(),
});
export const BrowserAutomationKeypressResultSchema = z.object({
command: z.literal("keypress"),
browserId: z.string().min(1),
browserId: BrowserAutomationBrowserIdSchema,
key: z.string().min(1),
ref: BrowserAutomationRefSchema.optional(),
x: z.number().optional(),
y: z.number().optional(),
});
export const BrowserAutomationNavigateResultSchema = z.object({
command: z.literal("navigate"),
browserId: z.string().min(1),
browserId: BrowserAutomationBrowserIdSchema,
url: z.string().min(1),
});
export const BrowserAutomationBackResultSchema = z.object({
command: z.literal("back"),
browserId: z.string().min(1),
browserId: BrowserAutomationBrowserIdSchema,
});
export const BrowserAutomationForwardResultSchema = z.object({
command: z.literal("forward"),
browserId: z.string().min(1),
browserId: BrowserAutomationBrowserIdSchema,
});
export const BrowserAutomationReloadResultSchema = z.object({
command: z.literal("reload"),
browserId: z.string().min(1),
browserId: BrowserAutomationBrowserIdSchema,
});
export const BrowserAutomationScreenshotResultSchema = z.object({
command: z.literal("screenshot"),
browserId: z.string().min(1),
browserId: BrowserAutomationBrowserIdSchema,
mimeType: z.literal("image/png"),
dataBase64: z.string().min(1),
width: z.number().int().nonnegative(),
height: z.number().int().nonnegative(),
});
export const BrowserAutomationFullPageScreenshotResultSchema = z.object({
command: z.literal("full_page_screenshot"),
browserId: z.string().min(1),
mimeType: z.literal("image/png"),
dataBase64: z.string().min(1),
width: z.number().int().nonnegative(),
height: z.number().int().nonnegative(),
});
export const BrowserAutomationPdfResultSchema = z.object({
command: z.literal("pdf"),
browserId: z.string().min(1),
mimeType: z.literal("application/pdf"),
dataBase64: z.string().min(1),
});
export const BrowserAutomationDownloadResultSchema = z.object({
command: z.literal("download"),
browserId: z.string().min(1),
url: z.string().min(1),
filePath: z.string().min(1),
totalBytes: z.number().int().nonnegative().optional(),
state: z.string().min(1),
});
export const BrowserAutomationUploadResultSchema = z.object({
command: z.literal("upload"),
browserId: z.string().min(1),
browserId: BrowserAutomationBrowserIdSchema,
ref: BrowserAutomationRefSchema,
filePaths: z.array(z.string().min(1)).min(1),
});
export const BrowserAutomationFocusResultSchema = z.object({
command: z.literal("focus"),
browserId: z.string().min(1),
ref: BrowserAutomationRefSchema,
});
export const BrowserAutomationClearResultSchema = z.object({
command: z.literal("clear"),
browserId: z.string().min(1),
ref: BrowserAutomationRefSchema,
});
export const BrowserAutomationCheckResultSchema = z.object({
command: z.literal("check"),
browserId: z.string().min(1),
ref: BrowserAutomationRefSchema,
checked: z.boolean(),
});
export const BrowserAutomationSelectResultSchema = z.object({
command: z.literal("select"),
browserId: z.string().min(1),
browserId: BrowserAutomationBrowserIdSchema,
ref: BrowserAutomationRefSchema,
value: z.string(),
});
export const BrowserAutomationHoverResultSchema = z.object({
command: z.literal("hover"),
browserId: z.string().min(1),
browserId: BrowserAutomationBrowserIdSchema,
ref: BrowserAutomationRefSchema,
x: z.number().optional(),
y: z.number().optional(),
});
export const BrowserAutomationDragResultSchema = z.object({
command: z.literal("drag"),
browserId: z.string().min(1),
browserId: BrowserAutomationBrowserIdSchema,
sourceRef: BrowserAutomationRefSchema,
targetRef: BrowserAutomationRefSchema,
sourceX: z.number().optional(),
sourceY: z.number().optional(),
targetX: z.number().optional(),
targetY: z.number().optional(),
});
export const BrowserAutomationConsoleLogEntrySchema = z.object({
@@ -465,64 +421,43 @@ export const BrowserAutomationNetworkLogEntrySchema = z.object({
export const BrowserAutomationLogsResultSchema = z.object({
command: z.literal("logs"),
browserId: z.string().min(1),
browserId: BrowserAutomationBrowserIdSchema,
console: z.array(BrowserAutomationConsoleLogEntrySchema),
network: z.array(BrowserAutomationNetworkLogEntrySchema),
});
export const BrowserAutomationCookieEntrySchema = z.object({
name: z.string(),
value: z.string(),
domain: z.string().optional(),
path: z.string().optional(),
secure: z.boolean().optional(),
httpOnly: z.boolean().optional(),
expirationDate: z.number().optional(),
export const BrowserAutomationEvaluateResultSchema = z.object({
command: z.literal("evaluate"),
browserId: BrowserAutomationBrowserIdSchema,
resultJson: z.string(),
truncated: z.boolean(),
});
export const BrowserAutomationStorageEntrySchema = z.object({
key: z.string(),
value: z.string(),
export const BrowserAutomationScrollResultSchema = z.object({
command: z.literal("scroll"),
browserId: BrowserAutomationBrowserIdSchema,
ref: BrowserAutomationRefSchema.optional(),
deltaX: z.number(),
deltaY: z.number(),
x: z.number().optional(),
y: z.number().optional(),
});
export const BrowserAutomationStorageResultSchema = z.object({
command: z.literal("storage"),
browserId: z.string().min(1),
url: z.string(),
cookies: z.array(BrowserAutomationCookieEntrySchema),
localStorage: z.array(BrowserAutomationStorageEntrySchema),
sessionStorage: z.array(BrowserAutomationStorageEntrySchema),
export const BrowserAutomationResizeResultSchema = z.object({
command: z.literal("resize"),
browserId: BrowserAutomationBrowserIdSchema,
width: z.number().int().positive(),
height: z.number().int().positive(),
});
export const BrowserAutomationViewportResultSchema = z.object({
width: z.number().int().nonnegative(),
height: z.number().int().nonnegative(),
deviceScaleFactor: z.number().positive(),
});
export const BrowserAutomationGeolocationResultSchema = z.object({
latitude: z.number(),
longitude: z.number(),
accuracy: z.number(),
});
export const BrowserAutomationEnvironmentResultSchema = z.object({
command: z.literal("environment"),
browserId: z.string().min(1),
viewport: BrowserAutomationViewportResultSchema,
geolocation: BrowserAutomationGeolocationResultSchema.optional(),
});
export const BrowserAutomationSetBackgroundResultSchema = z.object({
command: z.literal("set_background"),
browserId: z.string().min(1),
color: z.string().min(1),
export const BrowserAutomationCloseTabResultSchema = z.object({
command: z.literal("close_tab"),
browserId: BrowserAutomationBrowserIdSchema,
});
export const BrowserAutomationResultSchema = z.discriminatedUnion("command", [
BrowserAutomationListTabsResultSchema,
BrowserAutomationNewTabResultSchema,
BrowserAutomationPageInfoResultSchema,
BrowserAutomationSnapshotResultSchema,
BrowserAutomationClickResultSchema,
BrowserAutomationFillResultSchema,
@@ -534,20 +469,15 @@ export const BrowserAutomationResultSchema = z.discriminatedUnion("command", [
BrowserAutomationForwardResultSchema,
BrowserAutomationReloadResultSchema,
BrowserAutomationScreenshotResultSchema,
BrowserAutomationFullPageScreenshotResultSchema,
BrowserAutomationPdfResultSchema,
BrowserAutomationDownloadResultSchema,
BrowserAutomationUploadResultSchema,
BrowserAutomationFocusResultSchema,
BrowserAutomationClearResultSchema,
BrowserAutomationCheckResultSchema,
BrowserAutomationSelectResultSchema,
BrowserAutomationHoverResultSchema,
BrowserAutomationDragResultSchema,
BrowserAutomationLogsResultSchema,
BrowserAutomationStorageResultSchema,
BrowserAutomationEnvironmentResultSchema,
BrowserAutomationSetBackgroundResultSchema,
BrowserAutomationEvaluateResultSchema,
BrowserAutomationScrollResultSchema,
BrowserAutomationResizeResultSchema,
BrowserAutomationCloseTabResultSchema,
]);
export const BrowserAutomationErrorSchema = z.object({
@@ -556,16 +486,26 @@ export const BrowserAutomationErrorSchema = z.object({
retryable: z.boolean().default(false),
});
export const BrowserAutomationExecuteRequestSchema = z.object({
type: z.literal("browser.automation.execute.request"),
requestId: z.string().min(1),
agentId: z.string().min(1).optional(),
cwd: z.string().min(1).optional(),
workspaceId: z.string().min(1).optional(),
browserId: z.string().min(1).optional(),
command: BrowserAutomationCommandSchema,
export const BrowserAutomationDialogEventSchema = z.object({
type: z.enum(["alert", "confirm", "prompt", "beforeunload"]),
message: z.string(),
defaultValue: z.string().optional(),
action: z.enum(["accepted", "dismissed"]),
promptText: z.string().optional(),
timestamp: z.number(),
});
export const BrowserAutomationExecuteRequestSchema = z
.object({
type: z.literal("browser.automation.execute.request"),
requestId: z.string().min(1),
agentId: z.string().min(1).optional(),
cwd: z.string().min(1).optional(),
workspaceId: z.string().min(1).optional(),
command: BrowserAutomationCommandSchema,
})
.strict();
export const BrowserAutomationExecuteResponseSchema = z.object({
type: z.literal("browser.automation.execute.response"),
payload: z.discriminatedUnion("ok", [
@@ -573,16 +513,19 @@ export const BrowserAutomationExecuteResponseSchema = z.object({
requestId: z.string().min(1),
ok: z.literal(true),
result: BrowserAutomationResultSchema,
dialogs: z.array(BrowserAutomationDialogEventSchema).optional(),
}),
z.object({
requestId: z.string().min(1),
ok: z.literal(false),
error: BrowserAutomationErrorSchema,
dialogs: z.array(BrowserAutomationDialogEventSchema).optional(),
}),
]),
});
export type BrowserAutomationErrorCode = z.infer<typeof BrowserAutomationErrorCodeSchema>;
export type BrowserAutomationCommandName = z.infer<typeof BrowserAutomationCommandNameSchema>;
export type BrowserAutomationCommand = z.infer<typeof BrowserAutomationCommandSchema>;
export type BrowserAutomationResult = z.infer<typeof BrowserAutomationResultSchema>;
export type BrowserAutomationConsoleLogEntry = z.infer<
@@ -591,8 +534,7 @@ export type BrowserAutomationConsoleLogEntry = z.infer<
export type BrowserAutomationNetworkLogEntry = z.infer<
typeof BrowserAutomationNetworkLogEntrySchema
>;
export type BrowserAutomationCookieEntry = z.infer<typeof BrowserAutomationCookieEntrySchema>;
export type BrowserAutomationStorageEntry = z.infer<typeof BrowserAutomationStorageEntrySchema>;
export type BrowserAutomationDialogEvent = z.infer<typeof BrowserAutomationDialogEventSchema>;
export type BrowserAutomationExecuteRequest = z.infer<typeof BrowserAutomationExecuteRequestSchema>;
export type BrowserAutomationExecuteResponse = z.infer<
typeof BrowserAutomationExecuteResponseSchema

View File

@@ -11,7 +11,7 @@ export const CLIENT_CAPS = {
// Old clients use a strict TerminalState schema and would reject the extra fields.
// Drop the gate (always send the flags) when floor >= v0.1.88.
terminalReflowableSnapshot: "terminal_reflowable_snapshot",
desktopBrowserAutomation: "desktop_browser_automation",
browserHost: "browser_host",
} as const;
export type ClientCapability = (typeof CLIENT_CAPS)[keyof typeof CLIENT_CAPS];

View File

@@ -0,0 +1,11 @@
# Generated protocol validators
`ws-outbound.aot.ts` is generated by zod-aot from `packages/protocol/codegen/ws-outbound.compile.ts`.
Run:
```bash
npm run generate:validators --workspace=@getpaseo/protocol
```
The generated TypeScript is intentionally gitignored. zod-aot is exact-pinned because this is a young solo-maintainer project; keep the generated validator behind protocol regression tests when upgrading it.

View File

@@ -1,5 +1,5 @@
import { z } from "zod";
import { AgentProviderSchema } from "@getpaseo/protocol/provider-manifest";
import { AgentProviderSchema } from "../provider-manifest.js";
export const LoopLogEntrySchema = z.object({
seq: z.number().int().positive(),

View File

@@ -1,5 +1,6 @@
import { describe, expect, test } from "vitest";
import { BROWSER_AUTOMATION_COMMAND_NAMES } from "./browser-automation/rpc-schemas.js";
import { CLIENT_CAPS } from "./client-capabilities.js";
import {
MutableDaemonConfigPatchSchema,
@@ -10,7 +11,9 @@ import {
} from "./messages.js";
describe("browser automation protocol integration", () => {
test("desktop automation capability parses in hello without narrowing old clients", () => {
const browserId = "11111111-1111-4111-8111-111111111111";
test("browser host capability parses supported commands in hello", () => {
expect(
WSHelloMessageSchema.parse({
type: "hello",
@@ -18,13 +21,108 @@ describe("browser automation protocol integration", () => {
clientType: "mobile",
protocolVersion: 1,
capabilities: {
[CLIENT_CAPS.desktopBrowserAutomation]: true,
[CLIENT_CAPS.browserHost]: {
supportedCommands: [...BROWSER_AUTOMATION_COMMAND_NAMES],
hostKind: "desktop app",
},
},
}).capabilities,
).toMatchObject({
[CLIENT_CAPS.desktopBrowserAutomation]: true,
[CLIENT_CAPS.browserHost]: {
supportedCommands: [...BROWSER_AUTOMATION_COMMAND_NAMES],
hostKind: "desktop app",
},
});
});
test("browser host capability requires at least one supported command", () => {
expect(() =>
WSHelloMessageSchema.parse({
type: "hello",
clientId: "client-1",
clientType: "mobile",
protocolVersion: 1,
capabilities: {
[CLIENT_CAPS.browserHost]: {
supportedCommands: [],
hostKind: "desktop app",
},
},
}),
).toThrow();
expect(() =>
WSHelloMessageSchema.parse({
type: "hello",
clientId: "client-2",
clientType: "mobile",
protocolVersion: 1,
capabilities: {
[CLIENT_CAPS.browserHost]: {},
},
}),
).toThrow();
expect(() =>
WSHelloMessageSchema.parse({
type: "hello",
clientId: "client-3",
clientType: "mobile",
protocolVersion: 1,
capabilities: {
[CLIENT_CAPS.browserHost]: {
supportedCommands: ["future_command"],
},
},
}),
).toThrow();
});
test("browser host capability ignores unknown future commands when known commands remain", () => {
expect(
WSHelloMessageSchema.parse({
type: "hello",
clientId: "client-1",
clientType: "mobile",
protocolVersion: 1,
capabilities: {
[CLIENT_CAPS.browserHost]: {
supportedCommands: ["list_tabs", "future_command", "list_tabs"],
hostKind: "desktop app",
},
},
}).capabilities,
).toMatchObject({
[CLIENT_CAPS.browserHost]: {
supportedCommands: ["list_tabs"],
hostKind: "desktop app",
},
});
});
test("browser host capability accepts new tool commands as supported commands", () => {
expect(
WSHelloMessageSchema.parse({
type: "hello",
clientId: "client-1",
clientType: "mobile",
protocolVersion: 1,
capabilities: {
[CLIENT_CAPS.browserHost]: {
supportedCommands: ["evaluate", "scroll", "resize", "close_tab"],
hostKind: "desktop app",
},
},
}).capabilities,
).toMatchObject({
[CLIENT_CAPS.browserHost]: {
supportedCommands: ["evaluate", "scroll", "resize", "close_tab"],
hostKind: "desktop app",
},
});
});
test("hello remains valid when no browser host capability is advertised", () => {
expect(
WSHelloMessageSchema.parse({
type: "hello",
@@ -35,17 +133,17 @@ describe("browser automation protocol integration", () => {
).toBeUndefined();
});
test("daemon to desktop execute request is an outbound session message", () => {
test("daemon to browser host execute request is an outbound session message", () => {
const parsed = SessionOutboundMessageSchema.parse({
type: "browser.automation.execute.request",
requestId: "req-1",
command: { command: "page_info", args: { browserId: "browser-1" } },
command: { command: "snapshot", args: { browserId } },
});
expect(parsed.type).toBe("browser.automation.execute.request");
});
test("desktop to daemon execute response is an inbound session message", () => {
test("browser host to daemon execute response is an inbound session message", () => {
const parsed = SessionInboundMessageSchema.parse({
type: "browser.automation.execute.response",
payload: {

View File

@@ -38,41 +38,6 @@ describe("provider snapshot message schemas", () => {
expect(parsed.enabled).toBe(true);
});
test("normalizes thinking option defaults on provider snapshot models", () => {
const parsed = ProviderSnapshotEntrySchema.parse({
provider: "claude",
status: "ready",
models: [
{
provider: "claude",
id: "MiniMax-M2.7",
label: "MiniMax-M2.7",
isDefault: true,
contextWindowMaxTokens: 1_000_000,
thinkingOptions: [
{ id: "off", label: "Off" },
{ id: "max", label: "Max", isDefault: true },
],
},
],
});
expect(parsed.models).toEqual([
{
provider: "claude",
id: "MiniMax-M2.7",
label: "MiniMax-M2.7",
isDefault: true,
contextWindowMaxTokens: 1_000_000,
thinkingOptions: [
{ id: "off", label: "Off" },
{ id: "max", label: "Max", isDefault: true },
],
defaultThinkingOptionId: "max",
},
]);
});
test("defaults missing enabled state in providers snapshot response entries", () => {
const parsed = GetProvidersSnapshotResponseMessageSchema.parse({
type: "get_providers_snapshot_response",

View File

@@ -2,9 +2,9 @@ import { z } from "zod";
import { TerminalActivitySchema } from "./terminal-activity.js";
import { CLIENT_CAPS } from "./client-capabilities.js";
import { AGENT_LIFECYCLE_STATUSES } from "./agent-lifecycle.js";
import { MAX_EXPLICIT_AGENT_TITLE_CHARS } from "@getpaseo/protocol/agent-title-limits";
import { AgentProviderSchema } from "@getpaseo/protocol/provider-manifest";
import { normalizeAgentModelDefinition, TOOL_CALL_ICON_NAMES } from "./agent-types.js";
import { MAX_EXPLICIT_AGENT_TITLE_CHARS } from "./agent-title-limits.js";
import { AgentProviderSchema } from "./provider-manifest.js";
import { TOOL_CALL_ICON_NAMES } from "./agent-types.js";
import {
ChatCreateRequestSchema,
ChatListRequestSchema,
@@ -40,7 +40,7 @@ import {
ScheduleDeleteResponseSchema,
ScheduleRunOnceResponseSchema,
ScheduleUpdateResponseSchema,
} from "@getpaseo/protocol/schedule/rpc-schemas";
} from "./schedule/rpc-schemas.js";
import {
LoopRunRequestSchema,
LoopListRequestSchema,
@@ -52,11 +52,12 @@ import {
LoopInspectResponseSchema,
LoopLogsResponseSchema,
LoopStopResponseSchema,
} from "@getpaseo/protocol/loop/rpc-schemas";
} from "./loop/rpc-schemas.js";
import {
BrowserAutomationExecuteRequestSchema,
BrowserAutomationExecuteResponseSchema,
} from "./browser-automation/rpc-schemas.js";
import { BrowserAutomationHostCapabilitySchema } from "./browser-automation/capabilities.js";
import {
PaseoConfigRawSchema,
PaseoLifecycleCommandRawSchema,
@@ -72,7 +73,7 @@ import {
type PaseoMetadataGenerationEntry,
type PaseoScriptEntryRaw,
type ProjectConfigRpcError,
} from "@getpaseo/protocol/paseo-config-schema";
} from "./paseo-config-schema.js";
export {
PaseoConfigRawSchema,
PaseoLifecycleCommandRawSchema,
@@ -246,19 +247,17 @@ export const AgentFeatureSchema = z.discriminatedUnion("type", [
AgentFeatureSelectSchema,
]);
const AgentModelDefinitionSchema: z.ZodType<AgentModelDefinition> = z
.object({
provider: AgentProviderSchema,
id: z.string(),
label: z.string(),
description: z.string().optional(),
isDefault: z.boolean().optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
contextWindowMaxTokens: z.number().optional(),
thinkingOptions: z.array(AgentSelectOptionSchema).optional(),
defaultThinkingOptionId: z.string().optional(),
})
.transform(normalizeAgentModelDefinition);
const AgentModelDefinitionSchema: z.ZodType<AgentModelDefinition> = z.object({
provider: AgentProviderSchema,
id: z.string(),
label: z.string(),
description: z.string().optional(),
isDefault: z.boolean().optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
contextWindowMaxTokens: z.number().optional(),
thinkingOptions: z.array(AgentSelectOptionSchema).optional(),
defaultThinkingOptionId: z.string().optional(),
});
export const ProviderSnapshotEntrySchema = z.object({
provider: AgentProviderSchema,
@@ -360,20 +359,21 @@ const AgentPermissionActionSchema = z.object({
intent: z.enum(["implement", "implement_resume", "dismiss"]).optional(),
});
export const AgentPermissionResponseSchema: z.ZodType<AgentPermissionResponse> = z.union([
z.object({
behavior: z.literal("allow"),
selectedActionId: z.string().optional(),
updatedInput: z.record(z.string(), z.unknown()).optional(),
updatedPermissions: z.array(AgentPermissionUpdateSchema).optional(),
}),
z.object({
behavior: z.literal("deny"),
selectedActionId: z.string().optional(),
message: z.string().optional(),
interrupt: z.boolean().optional(),
}),
]);
export const AgentPermissionResponseSchema: z.ZodType<AgentPermissionResponse> =
z.discriminatedUnion("behavior", [
z.object({
behavior: z.literal("allow"),
selectedActionId: z.string().optional(),
updatedInput: z.record(z.string(), z.unknown()).optional(),
updatedPermissions: z.array(AgentPermissionUpdateSchema).optional(),
}),
z.object({
behavior: z.literal("deny"),
selectedActionId: z.string().optional(),
message: z.string().optional(),
interrupt: z.boolean().optional(),
}),
]);
export const AgentPermissionRequestPayloadSchema: z.ZodType<AgentPermissionRequest, unknown> =
z.object({
@@ -553,13 +553,16 @@ const ToolCallCanceledPayloadSchema = ToolCallBasePayloadSchema.extend({
error: z.null(),
});
const ToolCallTimelineItemPayloadSchema: z.ZodType<ToolCallTimelineItem, unknown> = z.union([
ToolCallRunningPayloadSchema,
ToolCallCompletedPayloadSchema,
ToolCallFailedPayloadSchema,
ToolCallCanceledPayloadSchema,
]);
const ToolCallTimelineItemPayloadSchema: z.ZodType<ToolCallTimelineItem, unknown> =
z.discriminatedUnion("status", [
ToolCallRunningPayloadSchema,
ToolCallCompletedPayloadSchema,
ToolCallFailedPayloadSchema,
ToolCallCanceledPayloadSchema,
]);
// zod-aot 0.20.4 miscompiles this as a nested discriminated union by omitting
// the inner tool_call branch from the generated outer dispatch.
export const AgentTimelineItemPayloadSchema: z.ZodType<AgentTimelineItem, unknown> = z.union([
z.object({
type: z.literal("user_message"),
@@ -3112,7 +3115,9 @@ export const SetDaemonConfigResponseMessageSchema = z.object({
export const ReadProjectConfigResponseMessageSchema = z.object({
type: z.literal("read_project_config_response"),
payload: z.discriminatedUnion("ok", [
// zod-aot 0.2.0 miscompiles boolean discriminators as string options
// (`"true"`/`"false"`), so keep this sequential until upstream fixes it.
payload: z.union([
z.object({
requestId: z.string(),
repoRoot: z.string(),
@@ -3131,7 +3136,9 @@ export const ReadProjectConfigResponseMessageSchema = z.object({
export const WriteProjectConfigResponseMessageSchema = z.object({
type: z.literal("write_project_config_response"),
payload: z.discriminatedUnion("ok", [
// zod-aot 0.2.0 miscompiles boolean discriminators as string options
// (`"true"`/`"false"`), so keep this sequential until upstream fixes it.
payload: z.union([
z.object({
requestId: z.string(),
repoRoot: z.string(),
@@ -4672,7 +4679,7 @@ export const WSHelloMessageSchema = z.object({
[CLIENT_CAPS.reasoningMergeEnum]: z.boolean().optional(),
[CLIENT_CAPS.customModeIcons]: z.boolean().optional(),
[CLIENT_CAPS.terminalReflowableSnapshot]: z.boolean().optional(),
[CLIENT_CAPS.desktopBrowserAutomation]: z.boolean().optional(),
[CLIENT_CAPS.browserHost]: BrowserAutomationHostCapabilitySchema.optional(),
})
.passthrough()
.optional(),

View File

@@ -1,5 +1,5 @@
import { z } from "zod";
import { AgentProviderSchema } from "@getpaseo/protocol/provider-manifest";
import { AgentProviderSchema } from "../provider-manifest.js";
export const ScheduleStatusSchema = z.enum(["active", "paused", "completed"]);
export type ScheduleStatus = z.infer<typeof ScheduleStatusSchema>;

View File

@@ -1,5 +1,5 @@
import type { ToolCallTimelineItem } from "./agent-types.js";
import { getPaseoToolLeafName, isPaseoToolName } from "@getpaseo/protocol/tool-name-normalization";
import { getPaseoToolLeafName, isPaseoToolName } from "./tool-name-normalization.js";
import { stripCwdPrefix } from "./path-utils.js";
export type ToolCallDisplayInput = Pick<

View File

@@ -0,0 +1,3 @@
import { WSOutboundMessageSchema as SourceWSOutboundMessageSchema } from "../messages.js";
export const WSOutboundMessageSchema = { schema: SourceWSOutboundMessageSchema };

View File

@@ -0,0 +1,19 @@
import type { z } from "zod";
import { WSOutboundMessageSchema } from "../generated/validation/ws-outbound.aot.js";
import type { WSOutboundMessage } from "../messages.js";
type WSOutboundValidationResult =
| { success: true; data: WSOutboundMessage }
| { success: false; error: z.ZodError };
interface WSOutboundGeneratedValidator {
safeParse(input: unknown): WSOutboundValidationResult;
}
// zod-aot emits runtime JavaScript from WSOutboundMessageSchema but not its TypeScript surface.
// Protocol regression tests cover the patched compiler cases behind this boundary.
const wsOutboundValidator = WSOutboundMessageSchema as WSOutboundGeneratedValidator;
export function validateWSOutboundMessage(input: unknown): WSOutboundValidationResult {
return wsOutboundValidator.safeParse(input);
}

View File

@@ -0,0 +1,145 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { createRequire } from "node:module";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { createJiti } from "jiti";
import { describe, expect, it } from "vitest";
import { WSOutboundMessageSchema as GeneratedWSOutboundMessageSchema } from "../../src/generated/validation/ws-outbound.aot.js";
interface GeneratedSchema {
safeParse(input: unknown): { success: boolean; data?: unknown };
}
const protocolRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const generatedWSOutboundPath = resolve(
protocolRoot,
"src/generated/validation/ws-outbound.aot.ts",
);
const require = createRequire(import.meta.url);
async function compileInlineSchema(sourceSchema: string): Promise<GeneratedSchema> {
const scratchRoot = resolve(protocolRoot, "../../.tmp");
await mkdir(scratchRoot, { recursive: true });
const tempDir = await mkdtemp(join(scratchRoot, "paseo-zod-aot-"));
try {
const sourcePath = join(tempDir, "schema.source.js");
const outputPath = join(tempDir, "schema.generated.ts");
await writeFile(join(tempDir, "package.json"), '{"type":"module"}\n');
await writeFile(
sourcePath,
[
'import { z } from "zod";',
'import { compile } from "zod-aot";',
sourceSchema,
"export const Schema = compile(SourceSchema);",
"",
].join("\n"),
);
const zodAotEntry = require.resolve("zod-aot");
const zodAotRoot = resolve(dirname(zodAotEntry), "..");
const [{ discoverSchemas }, { compileSchemas }, { generateCompiledFileContent }] =
await Promise.all([
import(pathToFileURL(resolve(zodAotRoot, "dist/discovery.js")).href),
import(pathToFileURL(resolve(zodAotRoot, "dist/core/pipeline.js")).href),
import(pathToFileURL(resolve(zodAotRoot, "dist/cli/emitter.js")).href),
]);
const schemas = await discoverSchemas(sourcePath, { cacheBust: true });
const compiled = compileSchemas(schemas, { mode: "inline" });
const content = generateCompiledFileContent(compiled, "./schema.source.js", {
zodCompat: false,
});
await writeFile(outputPath, content);
const jiti = createJiti(import.meta.url, { moduleCache: false });
const generated = await jiti.import(outputPath);
return generated.Schema as GeneratedSchema;
} finally {
await rm(tempDir, { recursive: true, force: true });
}
}
describe("WS outbound zod-aot validation", () => {
it("applies defaults inside discriminated-union branches", async () => {
const schema = await compileInlineSchema(`
const SourceSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("with_default"),
enabled: z.boolean().default(true),
}),
z.object({
type: z.literal("without_default"),
label: z.string(),
}),
]);
`);
expect(schema.safeParse({ type: "with_default" })).toMatchObject({
success: true,
data: { type: "with_default", enabled: true },
});
});
it("routes tool-call-like status unions through the current sequential item union", async () => {
const schema = await compileInlineSchema(`
const ToolCallItemSchema = z.discriminatedUnion("status", [
z.object({ type: z.literal("tool_call"), status: z.literal("running"), callId: z.string() }),
z.object({ type: z.literal("tool_call"), status: z.literal("completed"), callId: z.string(), output: z.string() }),
z.object({ type: z.literal("tool_call"), status: z.literal("failed"), callId: z.string(), error: z.string() }),
z.object({ type: z.literal("tool_call"), status: z.literal("canceled"), callId: z.string() }),
]);
const TimelineItemSchema = z.union([
z.object({ type: z.literal("assistant_message"), text: z.string() }),
ToolCallItemSchema,
]);
const SourceSchema = z.object({
item: TimelineItemSchema,
});
`);
expect(
schema.safeParse({ item: { type: "tool_call", status: "running", callId: "run" } }),
).toMatchObject({
success: true,
data: { item: { type: "tool_call", status: "running", callId: "run" } },
});
expect(
schema.safeParse({
item: { type: "tool_call", status: "completed", callId: "done", output: "ok" },
}),
).toMatchObject({
success: true,
data: { item: { type: "tool_call", status: "completed", callId: "done", output: "ok" } },
});
expect(
schema.safeParse({
item: { type: "tool_call", status: "failed", callId: "fail", error: "boom" },
}),
).toMatchObject({
success: true,
data: { item: { type: "tool_call", status: "failed", callId: "fail", error: "boom" } },
});
expect(
schema.safeParse({ item: { type: "tool_call", status: "canceled", callId: "stop" } }),
).toMatchObject({
success: true,
data: { item: { type: "tool_call", status: "canceled", callId: "stop" } },
});
});
it("accepts a minimal valid envelope and rejects a corrupted envelope", () => {
expect(GeneratedWSOutboundMessageSchema.safeParse({ type: "pong" }).success).toBe(true);
expect(GeneratedWSOutboundMessageSchema.safeParse({ type: "not_a_message" }).success).toBe(
false,
);
});
it("emits runtime imports with .js extensions", async () => {
const generated = await readFile(generatedWSOutboundPath, "utf8");
expect(generated).toContain('from "../../validation/ws-outbound-schema-metadata.js"');
});
});

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.1.103",
"version": "0.1.104-beta.4",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.1.103",
"version": "0.1.104-beta.4",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -65,10 +65,10 @@
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.195",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.1.103",
"@getpaseo/highlight": "0.1.103",
"@getpaseo/protocol": "0.1.103",
"@getpaseo/relay": "0.1.103",
"@getpaseo/client": "0.1.104-beta.4",
"@getpaseo/highlight": "0.1.104-beta.4",
"@getpaseo/protocol": "0.1.104-beta.4",
"@getpaseo/relay": "0.1.104-beta.4",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",

View File

@@ -47,7 +47,10 @@ interface CreateAgentCommandDependencies {
providerSnapshotManager: ProviderSnapshotManager;
createPaseoWorktree?: CreatePaseoWorktreeWorkflowFn;
// Mints a fresh directory workspace for a cwd and returns its id.
ensureWorkspaceForCreate?: (cwd: string) => Promise<string>;
ensureWorkspaceForCreate?: (
cwd: string,
firstAgentContext?: FirstAgentContext,
) => Promise<string>;
}
export interface CreateAgentFromSessionInput {
@@ -253,7 +256,7 @@ async function resolveMcpCreateAgent(
? createdWorkspaceId
: (input.workspaceId ??
parentAgent?.workspaceId ??
(await ensureWorkspaceForMcpCreate(dependencies, resolvedCwd)));
(await ensureWorkspaceForMcpCreate(dependencies, resolvedCwd, input.initialPrompt)));
const { modeId: resolvedMode, featureValues: resolvedFeatures } =
await dependencies.providerSnapshotManager.resolveCreateConfig({
@@ -300,11 +303,12 @@ async function resolveMcpCreateAgent(
async function ensureWorkspaceForMcpCreate(
dependencies: CreateAgentCommandDependencies,
cwd: string,
initialPrompt: string,
): Promise<string | undefined> {
if (!dependencies.ensureWorkspaceForCreate) {
return undefined;
}
return dependencies.ensureWorkspaceForCreate(cwd);
return dependencies.ensureWorkspaceForCreate(cwd, { prompt: initialPrompt });
}
async function sendInitialPrompt(
@@ -424,7 +428,7 @@ async function resolveMcpCwd(params: {
refName: worktree.refName,
action: worktree.action,
githubPrNumber: worktree.githubPrNumber,
...(params.initialPrompt ? { firstAgentContext: { prompt: params.initialPrompt } } : {}),
firstAgentContext: { prompt: params.initialPrompt },
runSetup: false,
paseoHome: dependencies.paseoHome,
worktreesRoot: dependencies.worktreesRoot,

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,6 @@ import type {
ServerNotification,
ServerRequest,
} from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
import { createPaseoToolCatalog, type PaseoToolHostDependencies } from "./tools/paseo-tools.js";
import type { PaseoToolResult } from "./tools/types.js";
@@ -14,39 +13,6 @@ export type AgentMcpServerOptions = PaseoToolHostDependencies;
type McpToolContext = RequestHandlerExtra<ServerRequest, ServerNotification>;
function isZodSchema(value: unknown): value is z.ZodType {
return (
typeof value === "object" &&
value !== null &&
typeof (value as { safeParseAsync?: unknown }).safeParseAsync === "function"
);
}
function relaxMcpOutputSchema(outputSchema: unknown): unknown {
if (!outputSchema) {
return outputSchema;
}
if (isZodSchema(outputSchema)) {
return outputSchema instanceof z.ZodObject ? outputSchema.passthrough() : outputSchema;
}
return z.object(outputSchema as z.ZodRawShape).passthrough();
}
function relaxMcpToolOutputSchema<TConfig extends { outputSchema?: unknown }>(
config: TConfig,
): TConfig {
if (config.outputSchema === undefined) {
return config;
}
return {
...config,
outputSchema: relaxMcpOutputSchema(config.outputSchema),
} as TConfig;
}
function formatStructuredContentForModel(structuredContent: unknown): string {
if (
!structuredContent ||
@@ -115,12 +81,12 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
for (const tool of catalog.tools.values()) {
server.registerTool(
tool.name,
relaxMcpToolOutputSchema({
{
title: tool.title,
description: tool.description,
inputSchema: tool.inputSchema,
outputSchema: tool.outputSchema,
}),
},
async (args: unknown, context?: McpToolContext) =>
toMcpToolResult(await catalog.executeTool(tool.name, args, { signal: context?.signal })),
);

View File

@@ -438,7 +438,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
}
});
test("exposes Ultracode only on Claude models that support it", async () => {
test("exposes Ultra Code on xhigh-capable Claude models", async () => {
const emptyConfigDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-models-empty-"));
try {
const client = new ClaudeAgentClient({
@@ -459,8 +459,9 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
expect(getThinkingIds("claude-opus-4-8[1m]")).toContain("ultracode");
expect(getThinkingIds("claude-opus-4-8")).toContain("ultracode");
expect(getThinkingIds("claude-sonnet-5")).toContain("xhigh");
expect(getThinkingIds("claude-sonnet-5")).not.toContain("ultracode");
expect(getThinkingIds("claude-opus-4-7")).not.toContain("ultracode");
expect(getThinkingIds("claude-sonnet-5")).toContain("ultracode");
expect(getThinkingIds("claude-opus-4-7[1m]")).toContain("ultracode");
expect(getThinkingIds("claude-opus-4-7")).toContain("ultracode");
expect(getThinkingIds("claude-sonnet-4-6")).not.toContain("ultracode");
} finally {
await fs.rm(emptyConfigDir, { recursive: true, force: true });
@@ -597,6 +598,30 @@ describe("ClaudeAgentSession features", () => {
}),
).resolves.toEqual([expect.objectContaining({ id: "fast_mode", value: false })]);
await expect(
client.listFeatures({
provider: "claude",
cwd: process.cwd(),
model: "claude-opus-4-8[1m]",
}),
).resolves.toEqual([expect.objectContaining({ id: "fast_mode", value: false })]);
await expect(
client.listFeatures({
provider: "claude",
cwd: process.cwd(),
model: "claude-opus-4-8-20260101",
}),
).resolves.toEqual([expect.objectContaining({ id: "fast_mode", value: false })]);
await expect(
client.listFeatures({
provider: "claude",
cwd: process.cwd(),
model: "openrouter/anthropic/claude-opus-4-8",
}),
).resolves.toEqual([]);
await expect(
client.listFeatures({
provider: "claude",

View File

@@ -34,6 +34,7 @@ import {
getClaudeModelsWithSettings,
normalizeClaudeRuntimeModelId,
} from "./models.js";
import { CLAUDE_ULTRACODE_THINKING_OPTION_ID } from "./model-manifest.js";
import { parsePartialJsonObject } from "./partial-json.js";
import { ClaudeSidechainTracker } from "./sidechain-tracker.js";
import { buildClaudeFeatures, claudeModelSupportsFastMode } from "./feature-definitions.js";
@@ -373,7 +374,7 @@ interface ClaudeAgentSessionOptions {
}
type ClaudeThinkingEffort = "low" | "medium" | "high" | "xhigh" | "max";
type ClaudeThinkingOption = ClaudeThinkingEffort | "ultracode";
type ClaudeThinkingOption = ClaudeThinkingEffort | typeof CLAUDE_ULTRACODE_THINKING_OPTION_ID;
function resolvePathEnvKey(): "Path" | "PATH" | null {
if (process.env["Path"] !== undefined) return "Path";
@@ -421,7 +422,7 @@ function isClaudeThinkingEffort(value: string | null | undefined): value is Clau
}
function isClaudeThinkingOption(value: string | null | undefined): value is ClaudeThinkingOption {
return value === "ultracode" || isClaudeThinkingEffort(value);
return value === CLAUDE_ULTRACODE_THINKING_OPTION_ID || isClaudeThinkingEffort(value);
}
interface ClaudeOptionsLogSummary {
@@ -2864,7 +2865,7 @@ class ClaudeAgentSession implements AgentSession {
this.config.thinkingOptionId && this.config.thinkingOptionId !== "default"
? this.config.thinkingOptionId
: undefined;
if (thinkingOptionId === "ultracode") {
if (thinkingOptionId === CLAUDE_ULTRACODE_THINKING_OPTION_ID) {
return { thinking: { type: "adaptive" }, effort: "xhigh", ultracode: true };
}
if (thinkingOptionId && isClaudeThinkingEffort(thinkingOptionId)) {

Some files were not shown because too many files have changed in this diff Show More