Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 7cc47701ad fix(paseo-agent): polish provider settings UI
Keep provider instance keys stable for credentials and model addressing while allowing display-name edits.
2026-07-02 15:15:37 +02:00
Mohamed Boudra 6a2b5ea5ea feat(paseo-agent): add browser OAuth start mode
Default daemon-run OAuth to browser mode while keeping device-code explicit for remote-safe setup.
2026-07-02 14:51:26 +02:00
Mohamed Boudra 269aa53c27 fix(paseo-agent): retain prompts and required tools
Emit submitted prompts into the daemon timeline so refresh and reload can replay the full Paseo Agent conversation. Keep nonpersistent providers from getting synthetic resume handles, and gate Paseo tool injection through provider capabilities.
2026-07-02 14:48:32 +02:00
Mohamed Boudra 03b45f8e11 fix(paseo-agent): make catalog auth deterministic
Catalog auth derivation used an environment probe, so missing local keys could make provider listing throw. Catalog hints are now static and CLI tests scrub provider-key env vars.
2026-07-02 13:57:12 +02:00
Mohamed Boudra 02ec891fe4 fix(cli): wait for daemon feature flags
Provider and pairing gates now wait for the daemon's server_info message instead of reading the cache immediately after connect. This removes a startup race where slower CLI cold starts could treat a supported daemon as missing the feature.
2026-07-02 13:31:48 +02:00
Mohamed Boudra 5d67032ef6 fix(paseo-agent): support empty model-provider setup
Allow catalog provider setup to persist credentials before explicit model overrides exist, while keeping unrelated provider snapshots warmable after Paseo Agent config changes.
2026-07-02 13:12:23 +02:00
Mohamed Boudra d6e4e5f3ce refactor(paseo-agent): derive catalog from Pi
Keep Paseo catalog entries as curated provider refs while deriving model metadata, endpoints, and auth defaults from Pi at runtime.
2026-07-02 12:40:21 +02:00
Mohamed Boudra 2ba7562dff fix(paseo-agent): use working OAuth catalog model
Live proof showed the previous OAuth catalog default was rejected at session start. Switch the catalog, docs, fixtures, and regenerated schema to the working default while keeping provider config generic.
2026-07-02 12:06:02 +02:00
Mohamed Boudra 363e76858f feat(app): add Paseo Agent provider catalog UI 2026-07-02 11:38:11 +02:00
Mohamed Boudra 27e713671d feat(cli): add catalog-driven provider setup 2026-07-02 11:26:56 +02:00
Mohamed Boudra 4e5737169f feat: wire paseo agent provider catalog 2026-07-02 11:07:49 +02:00
Mohamed Boudra fce3d14a83 feat(paseo-agent): add model provider catalog
Keep persisted provider config structurally open and resolve catalog semantics at runtime.
2026-07-02 10:38:17 +02:00
Mohamed Boudra 343dd2d10d Regenerate lockfile after rebase onto 0.1.103 2026-07-02 08:40:26 +02:00
Mohamed Boudra a410617d4a Open the model provider type wire schema
A closed z.enum inside the strict get_providers payload meant any provider
type added daemon-side failed the entire WS envelope parse on older clients,
silently dropping the response and timing out the settings sheet. Make
providerType an open string on the wire; the daemon's config schema keeps the
closed set and setProvider rejects unknown types with a clear error. Also
correct the paseoAgentConfig COMPAT version, the provider CLI descriptions,
and the stale no-app-UI doc claim.
2026-07-02 08:39:06 +02:00
Mohamed Boudra 4a8a841f72 Report context window usage for Paseo Agent
toAgentUsage dropped SessionStats.contextUsage, leaving the context-window
indicator blank on every Paseo Agent session. Mirror the Pi provider's
mapping into contextWindowMaxTokens/contextWindowUsedTokens.
2026-07-02 08:39:06 +02:00
Mohamed Boudra 7a6d2d86d7 Name the layers: agent providers and model providers
Record the naming decision in the glossary: bare "provider" splits into
agent provider (the harness: Claude Code, Codex, Paseo Agent) and model
provider (a model API attached to Paseo Agent). Bare "Agent" is the
runnable identity — an agent provider today, a named agent definition
tomorrow — resolved through one CLI namespace (--agent). Agent session
stays the running instance.

Retire "inference provider" across docs, UI strings, and identifiers
(PaseoAgentModelProvider, paseoAgentModelProviders). Labels and internal
names only — wire and config keys are untouched.
2026-07-02 08:39:06 +02:00
Mohamed Boudra 341af162b0 Adapt Paseo Agent to rebased main
Main replaced AgentClient.listModels/listModes with a single required
fetchCatalog(); migrate PaseoAgentClient to it. Regenerate the lockfile so
the @earendil-works/pi-* deps are present (rebase conflicts took main's
lockfile at each step).
2026-07-02 08:39:06 +02:00
Mohamed Boudra 96067da1d3 Open Paseo Agent E2E provider settings 2026-07-02 08:38:58 +02:00
Mohamed Boudra 888f28990b Move OAuth file mode checks to POSIX tests 2026-07-02 08:38:58 +02:00
Mohamed Boudra b1abec4512 Resolve Paseo Agent credentials asynchronously 2026-07-02 08:38:58 +02:00
Mohamed Boudra 1a084eb8b0 Align desktop Nix npm deps fetcher 2026-07-02 08:38:58 +02:00
Mohamed Boudra 42ab7927f2 Fix PR CI follow-up failures 2026-07-02 08:38:58 +02:00
Mohamed Boudra 56d908c6dc Use Nix npm fetcher v2 2026-07-02 08:38:46 +02:00
Mohamed Boudra 2cfb29f1f0 Expose Pi TUI dependency to Nix 2026-07-02 08:38:46 +02:00
Mohamed Boudra c3827466eb Share Paseo Agent env reference helpers 2026-07-02 08:38:27 +02:00
Mohamed Boudra c52482368b Precompile Paseo Agent permission matchers 2026-07-02 08:38:27 +02:00
Mohamed Boudra 8089c802f0 Fix Paseo Agent PR review issues 2026-07-02 08:38:27 +02:00
Mohamed Boudra 3e535aa2d7 Fix Paseo Agent Zod 4 record schemas 2026-07-02 08:38:27 +02:00
Mohamed Boudra 4063671e3f Add first-class Paseo Agent definitions 2026-07-02 08:38:27 +02:00
Mohamed Boudra 9665ba07a3 Fix Paseo Agent ChatGPT auth home lookup 2026-07-02 08:38:27 +02:00
Mohamed Boudra 4086f65096 Add Paseo Agent prompt profiles 2026-07-02 08:38:27 +02:00
Mohamed Boudra 44afecc366 Add remote Paseo Agent configuration 2026-07-02 08:38:27 +02:00
Mohamed Boudra c9cd414571 Add Paseo Agent provider 2026-07-02 08:38:27 +02:00
253 changed files with 24793 additions and 19305 deletions
-20
View File
@@ -1,31 +1,11 @@
.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
+6
View File
@@ -76,6 +76,7 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Build server stack
run: npm run build:server
@@ -112,6 +113,7 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Install agent CLIs for provider tests
run: npm install -g @anthropic-ai/claude-code opencode-ai
@@ -168,6 +170,7 @@ jobs:
}
Start-Sleep -Seconds (20 * $attempt)
}
- name: Build server stack
run: npm run build:server
@@ -199,6 +202,7 @@ jobs:
fi
sleep $((attempt * 20))
done
- name: Install Playwright browsers
timeout-minutes: 10
run: npx playwright install chromium
@@ -223,6 +227,7 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Build client dependencies
run: npm run build:client
@@ -265,6 +270,7 @@ jobs:
fi
sleep $((attempt * 20))
done
- name: Install Playwright browsers
timeout-minutes: 10
run: npx playwright install chromium
+1
View File
@@ -27,6 +27,7 @@ jobs:
run: npm ci
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build app dependencies
run: npm run build:app-deps
+39 -23
View File
@@ -5,22 +5,7 @@ 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:
@@ -28,7 +13,7 @@ on:
workflow_dispatch:
inputs:
paseo_version:
description: "Expected source version to build. Required when publish is true."
description: "Paseo version to build. Required when publish is true."
required: false
default: ""
publish:
@@ -39,6 +24,15 @@ 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
@@ -63,6 +57,7 @@ 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:
@@ -73,6 +68,7 @@ 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: |
@@ -80,9 +76,9 @@ jobs:
owner="$(printf '%s' "${REPO_OWNER}" | tr '[:upper:]' '[:lower:]')"
image="ghcr.io/${owner}/paseo"
package_version="$(node -p "require('./package.json').version")"
install_version="${INPUT_PASEO_VERSION:-${package_version}}"
install_version="${INPUT_PASEO_VERSION:-latest}"
publish=false
source_build=false
publish_latest=false
prerelease=false
@@ -91,6 +87,7 @@ jobs:
publish=true
if [[ "${REF_NAME}" == *-* ]]; then
prerelease=true
source_build=true
else
publish_latest=true
fi
@@ -107,6 +104,24 @@ 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
@@ -122,13 +137,14 @@ 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}"
echo "Resolved image=${image} install_version=${install_version} publish=${publish} source_build=${source_build}"
build:
needs: setup
@@ -144,8 +160,8 @@ jobs:
- uses: docker/build-push-action@v7
with:
context: .
file: docker/base/Dockerfile
context: ${{ needs.setup.outputs.source_build == 'true' && '.' || 'docker/base' }}
file: ${{ needs.setup.outputs.source_build == 'true' && 'docker/base/Dockerfile.source' || 'docker/base/Dockerfile' }}
platforms: ${{ env.PLATFORMS }}
build-args: |
PASEO_VERSION=${{ needs.setup.outputs.install_version }}
@@ -177,8 +193,8 @@ jobs:
- uses: docker/build-push-action@v7
with:
context: .
file: docker/base/Dockerfile
context: ${{ needs.setup.outputs.source_build == 'true' && '.' || 'docker/base' }}
file: ${{ needs.setup.outputs.source_build == 'true' && 'docker/base/Dockerfile.source' || 'docker/base/Dockerfile' }}
platforms: ${{ env.PLATFORMS }}
build-args: |
PASEO_VERSION=${{ needs.setup.outputs.install_version }}
-1
View File
@@ -84,7 +84,6 @@ 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/
-27
View File
@@ -1,32 +1,5 @@
# 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
+29 -34
View File
@@ -21,36 +21,34 @@ 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/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 |
| 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/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config |
| [docs/paseo-agent.md](docs/paseo-agent.md) | Paseo Agent provider (id `paseo`): model-provider catalog, generic config, OAuth store, CLI/app setup |
| [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 |
## Quick start
@@ -93,13 +91,10 @@ 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.
- New fields: `.optional()` with a sensible default or `.transform()` fallback.
- 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.
+6 -30
View File
@@ -1,33 +1,10 @@
# 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; \
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}
ARG PASEO_VERSION=latest
ENV HOME=/home/paseo \
PASEO_HOME=/home/paseo/.paseo \
PASEO_LISTEN=0.0.0.0:6767 \
@@ -52,14 +29,13 @@ 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 /tmp/paseo-packs/*.tgz; \
rm -rf /tmp/paseo-packs; \
npm install -g --omit=optional \
"@getpaseo/server@${PASEO_VERSION}" \
"@getpaseo/cli@${PASEO_VERSION}"; \
npm cache clean --force; \
server_entry="$(npm root -g)/@getpaseo/server/dist/scripts/supervisor-entrypoint.js"; \
test -f "$server_entry"; \
@@ -90,7 +66,7 @@ RUN set -eux; \
"$XDG_CACHE_HOME"; \
chown -R paseo:paseo /home/paseo /workspace
COPY docker/base/rootfs/ /
COPY rootfs/ /
RUN chmod +x /usr/local/bin/paseo-docker-entrypoint
WORKDIR /workspace
+101
View File
@@ -0,0 +1,101 @@
# 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"]
+1 -1
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 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.
> **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.
### `packages/website` — Marketing site
-69
View File
@@ -1,69 +0,0 @@
# 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.
-24
View File
@@ -37,30 +37,6 @@ The `agents/{sanitized-cwd}/` directory name is derived from the agent's `cwd` b
---
## Runtime PID Lock
**Path:** `$PASEO_HOME/paseo.pid`
The daemon writes this file when it starts and removes it when it exits cleanly.
It is a runtime lock, not a durable settings file.
| Field | Type | Description |
| ---------------- | --------------------- | ------------------------------------------------------------------------------------------------------------ |
| `pid` | `number` | Owner process PID for stop/status operations |
| `startedAt` | `string` (ISO 8601) | Lock creation timestamp |
| `hostname` | `string` | Host that created the lock |
| `uid` | `number` | User id that created the lock, or `0` where unavailable |
| `listen` | `string \| null` | Daemon listen target at lock creation; updated when the server binds |
| `executablePath` | `string?` | `process.execPath` for the daemon runtime. Desktop uses this to derive whether the daemon is desktop-managed |
| `desktopManaged` | `boolean?` _(legacy)_ | Legacy spawn-origin flag accepted for old locks only; new locks derive desktop management from executable |
Desktop-managed is a read-time classification by the desktop app: the daemon
executable must be the desktop app executable or live inside the same desktop
install. The CLI reports the raw `executablePath` but does not try to classify
it because an npm-installed CLI has no authoritative desktop install root.
---
## 1. Agent Record
**Path:** `$PASEO_HOME/agents/{project-dir}/{agentId}.json`
-9
View File
@@ -178,15 +178,6 @@ defaults. The default rotation is `10m` x `3` files everywhere.
`worktree.setup` and `worktree.teardown` accept either a multiline shell script or an array
of commands. Both run sequentially.
Lifecycle commands run in the worktree through a stable script shell: `bash`
resolved from `PATH` on macOS/Linux, and PowerShell with `-NoProfile` on
Windows. They inherit the daemon environment plus Paseo's lifecycle variables;
login and interactive shell startup files are not loaded, and Bash's `BASH_ENV`
hook is unset. Daemon-run loop verify checks and ACP single-string terminal
commands use the same non-login Bash behavior on macOS/Linux, but preserve their
existing `cmd.exe /c` string semantics on Windows. Service scripts are separate:
they launch in a terminal and receive the service environment described below.
```json
{
"worktree": {
+10 -9
View File
@@ -10,7 +10,8 @@ The image source lives in [`docker/`](../docker/).
The official image:
- builds `@getpaseo/server` and `@getpaseo/cli` from source-built workspace tarballs
- installs `@getpaseo/server` and `@getpaseo/cli` from npm for stable images,
or from source-built workspace tarballs for beta images
- 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`
@@ -189,17 +190,16 @@ See [SECURITY.md](../SECURITY.md) for the daemon trust model.
## Building Locally
```bash
docker build -f docker/base/Dockerfile -t paseo:local .
docker build -t paseo:local docker/base
```
To assert the source tree version while building:
To bake a specific published npm version:
```bash
docker build \
--build-arg PASEO_VERSION=0.1.102 \
-t paseo:0.1.102 \
-f docker/base/Dockerfile \
.
docker/base
```
The Docker workflow builds the image on pull requests and on `main` as a
@@ -216,12 +216,13 @@ pushing a `v*` release tag:
gh workflow run docker.yml \
--ref main \
-f paseo_version=0.1.102-beta.1 \
-f publish=true
-f publish=true \
-f source_build=auto
```
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.
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.
The published image is multi-arch for `linux/amd64` and `linux/arm64`.
+9 -4
View File
@@ -6,7 +6,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. Its `id` is opaque workspace identity; its `cwd` is the filesystem directory. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/protocol/src/messages.ts:2178`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. The git-derived, persisted property of a workspace, used across its lifetime (archive safety, sidebar, grouping). Derived from the cwd's git reality (`deriveWorkspaceKind`, `packages/server/src/server/workspace-registry-model.ts:158`), not stored from a user choice. Code: `PersistedWorkspaceKind` (`packages/server/src/server/workspace-registry-model.ts:8`). Don't confuse with **Isolation** (the create-time intent).
- **Isolation** — Create-time choice for a new workspace: reuse the existing checkout (**Local**) or cut a dedicated git worktree (**New worktree**). A transient setup input, also remembered as a create-form preference; it is not a workspace property. UI: "Isolation" control on the New Workspace screen. Code: `isolation` (`"local" | "worktree"`), `useWorkspaceIsolation` (`packages/app/src/screens/new-workspace-screen.tsx`); persisted as `FormPreferences.isolation` (`packages/app/src/create-agent-preferences/preferences.ts`). Distinct from **Workspace kind**, which is the git-derived property the intent produces (Local → `local_checkout` or `directory` by git-ness; New worktree → `worktree`). On the wire it is the create request's `source.kind` (`directory | worktree`, `packages/protocol/src/messages.ts:1693`).
- **Agent** — See **Agent session**. UI still says "Agent" / "New Agent" in places, but moving toward **Agent session** as the canonical term. Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`). Forbidden: "Task", "Job", "Run".
- **Agent** — A runnable identity the user picks to start work: today that is an **Agent provider** (Claude Code, Codex, Paseo Agent); direction: also a named **Agent definition** (`reviewer`) that pins provider, model, prompt, and tools. One CLI namespace: `paseo run --agent <id>` resolves agent provider ids first, then agent definition ids. The running instance is an **Agent session** — "New Agent" starts a new agent session. Forbidden: "Task", "Job", "Run".
- **Daemon** — Local Paseo server process; identified by `serverId`. UI: "Daemon" (system contexts only). Code: `serverId` in `ServerInfoStatusPayloadSchema` (`packages/protocol/src/messages.ts:1936`), `DaemonClient` (`packages/client/src/daemon-client.ts`).
- **Host** — Client-side connection profile pointing at a daemon; bundles one or more `HostConnection`s. UI: "Host" / "Add host" / "Switch host". Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Forbidden: "Connection" (means `HostConnection`, not host).
- **Project host entry** — One row in a project for a single (project, daemon) pair, aggregating that daemon's workspaces in the project. Internal. Code: `ProjectHostEntry` (`packages/app/src/utils/projects.ts:11`). Don't introduce "Checkout" as a synonym.
@@ -17,11 +17,13 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, GitHub PR info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
- **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`.
- **Agent session** — One running instance of an agent inside a workspace (one provider, one model, one cwd, one timeline). The conceptual unit; in the UI this opens as a tab. Moving toward this as the canonical term over "Agent". Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`).
- **Agent session** — One running instance of an agent inside a workspace (one agent provider, one model, one cwd, one timeline). The conceptual unit; in the UI this opens as a tab. Canonical term for the running instance (bare "Agent" is the identity being run, see **Agent**). Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`).
- **Agent definition** — A Paseo-owned markdown persona in `$PASEO_HOME/agents/*.md`: frontmatter `name`, `prompt`, `model`, `tools`, `permissions`; body is the prompt. Selected via `agents.paseo.defaultAgent` (`defaultProfile` is a legacy alias). Direction: definitions join the `--agent` namespace (`paseo run --agent reviewer`), so a definition id must not shadow an agent provider id. Code: `loadAgentDefinition` (`packages/server/src/server/agent/providers/paseo-agent/prompt-profiles.ts:62`), config keys (`packages/server/src/server/agent/providers/paseo-agent/config.ts:174`). Forbidden: "Profile", "Prompt profile" as synonyms.
- **Session** — Two senses: (a) per-client connection to a daemon, internal; (b) user-facing agent session, see **Agent session**. Code: `Session` (`packages/server/src/server/session.ts`) for (a). Don't confuse with: provider-side agent session log.
- **Profile** — Internal name for the persisted shape of a host. Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Never user-facing.
- **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi, OMP). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`).
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`).
- **Agent provider** — The harness/backend that runs agents (Claude Code, Codex, Copilot, OpenCode, Pi, OMP, Paseo Agent with id `paseo`). UI: "Agent provider" (target label; surfaces still saying bare "Provider" migrate as touched). Wire and config keep `provider` — labels move, the protocol does not. Code: `AgentProvider` (`packages/protocol/src/agent-types.ts:3`), `ProviderSnapshotEntrySchema` (`packages/protocol/src/messages.ts:262`). Don't confuse with: **Model provider**. Forbidden: bare "Provider" as a new UI label — always qualify which layer.
- **Model provider** — A model API attached to the Paseo Agent provider: a type (`openrouter`, `openai`, `anthropic`, `openai-codex`, …) plus key/OAuth and models, keyed by instance name (`openrouter-main`, `chatgpt`). UI: "Model providers" (Paseo Agent settings). CLI: `paseo provider add` — the CLI `provider` noun means model provider. Config: `agents.paseo.providers` (`packages/server/src/server/agent/providers/paseo-agent/config.ts:169`); wire: `PaseoAgentProviderTypeSchema` (`packages/protocol/src/messages.ts:1924`). Don't confuse with: **Agent provider**. Forbidden: "Inference provider" (old docs term), bare "Provider" as a new UI label.
- **Model** — A specific LLM offered by a provider; for the Paseo Agent provider, addressed as `<modelProviderName>/<modelId>`. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`).
- **Tab** — UI surface representing one session inside a workspace. Not a conceptual unit; use **Agent session** when talking about the model. Code: `WorkspaceTabDescriptor` (`packages/app/src/screens/workspace/workspace-tabs-types.ts`).
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/protocol/src/terminal-stream-protocol.ts`).
- **Schedule** — Cron-style trigger that creates new agents. UI: CLI/MCP (`paseo schedule`, `create_schedule`). Don't confuse with: Heartbeat (cron prompt back into the same agent) or Loop (iterative re-execution of one agent).
@@ -39,5 +41,8 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
## Inconsistencies (documented, not papered over)
- Bare "Provider" survives as a UI label in the provider picker and settings; target is "Agent provider" / "Model providers" per the entries above. Migrate labels as surfaces are touched — no big-bang rename.
- CLI `paseo provider` describes itself as "Manage agent providers" (`packages/cli/src/commands/provider/index.ts:11`) but manages **model providers**. Fix the description; keep the noun.
- CLI `paseo run --provider <id>` selects an agent provider. Direction: add `--agent <id>` as the canonical flag resolving one namespace (agent provider ids, then agent definition ids), keep `--provider` as a COMPAT alias.
- CLI `--host <host>` description `"Daemon host target"` (`packages/cli/src/utils/command-options.ts:5`) blurs daemon/host; the app keeps them distinct.
- `WorkspaceDescriptorPayloadSchema.workspaceKind` accepts legacy `"checkout"` on the wire (`packages/protocol/src/messages.ts:2187`) while `PersistedWorkspaceKind` does not (`packages/server/src/server/workspace-registry-model.ts:8`).
+351
View File
@@ -0,0 +1,351 @@
# Paseo Agent provider
Paseo Agent is the built-in agent provider that runs Pi's coding-agent harness **in
process** (no `pi` CLI, no `~/.pi` discovery). Its provider id is **`paseo`** and its
model backends are configured under `agents.paseo` in `$PASEO_HOME/config.json`.
Use it like any other agent provider:
```bash
paseo run --provider paseo --model <providerInstance>/<modelId> "Reply with pong"
```
Example: `--model openrouter/openai/gpt-4o-mini` selects the `openrouter` provider
instance and the `openai/gpt-4o-mini` model exposed by that instance.
> Smoke note: the daemon supervisor runs from `packages/server/dist`. After changing
> provider/config code, run `npm run build:server` (or run a source/dev daemon) before
> smoking, otherwise stale `dist` may reject or omit `agents.paseo` behavior. Always pass
> `--host <addr>` to CLI smoke commands so they hit your isolated daemon, not the real
> daemon on `:6767`.
## Timeline and persistence
Paseo Agent does not write or resume Pi CLI session files such as
`~/.pi/agent/sessions/*`; it runs Pi's harness in process and currently reports no
provider-owned session persistence. App refreshes and daemon-side agent reloads therefore
depend on Paseo's own timeline rows, not on a Pi history replay. Submitted prompts must be
emitted as `user_message` timeline rows when the turn starts; assistant text, reasoning,
and tool calls arrive later from Pi events and are mapped into the same Paseo timeline.
## Provider catalog
Paseo Agent model providers are catalog-driven, but Paseo does not copy Pi's provider
registry. `catalog.ts` is only the curated Paseo surface: catalog id, Pi provider id,
label, icon, default-model policy, and auth hints Pi cannot infer. Pi supplies the wire
API, base URL, headers, model ids, context windows, token limits, costs, reasoning flags,
thinking maps, and OAuth registry data at runtime.
The current catalog contains four entries:
| id | Pi provider | default models | auth source |
| ------------- | -------------- | -------------- | --------------------------------------------- |
| `openrouter` | `openrouter` | none | Paseo hint `OPENROUTER_API_KEY` |
| `chatgpt` | `openai-codex` | Pi full list | Pi OAuth registry (`openai-codex`) |
| `kimi` | `kimi-coding` | Pi full list | Paseo hint `KIMI_API_KEY` (Pi has no env key) |
| `opencode-go` | `opencode-go` | Pi full list | Paseo hint `OPENCODE_API_KEY` |
OpenRouter intentionally has no default model because Pi's OpenRouter registry is large.
Users can store the OpenRouter credential first, but they must choose explicit model ids
before running Paseo Agent through that provider. The other catalog entries expose Pi's
bundled model list unless an instance sets `options.models`.
## Config shape
`agents.paseo.providers` is structurally generic. Provider instance names are free-form
keys; provider types are catalog ids. Use the catalog id as the default instance name,
or create several instances of the same type with different models, keys, or endpoint
overrides.
```jsonc
{
"agents": {
"paseo": {
"defaultAgent": "orchestrator",
"defaultModel": "openrouter-main/openai/gpt-4o-mini",
"providers": {
"openrouter-main": {
"type": "openrouter",
"options": {
"apiKey": "$OPENROUTER_API_KEY",
"models": [
{ "id": "openai/gpt-4o-mini", "label": "GPT-4o mini" },
{ "id": "anthropic/claude-3.7-sonnet", "reasoning": true },
],
},
},
"chatgpt": {
"type": "chatgpt",
},
"kimi": {
"type": "kimi",
"options": {
"apiKey": "$KIMI_API_KEY",
"models": [{ "id": "k2p7" }],
},
},
},
},
},
}
```
Most options are overrides over Pi-derived provider data:
- `apiKey` may be omitted for API-key providers. Omitted means "use the derived or hinted
env var", such as `OPENROUTER_API_KEY` or `KIMI_API_KEY`.
- `apiKey` may also be a literal key, `$ENV`, `${ENV}`, or `!command` expression. Paseo
mirrors Pi's config-value semantics: literals and commands count as configured; env
references count only when every referenced env var is set in the daemon environment.
- `baseUrl`, `api`, `headers`, and `authHeader` override or extend the Pi-derived request
config.
- `models[]` is an instance override. Omit it to use that entry's default policy, which
can be an empty list for catalog entries such as OpenRouter. A model may override `api`
when a single backend serves mixed protocols or when Pi has no data for a custom id.
- `refreshToken` is an advanced OAuth seed path. Prefer the OAuth store described below.
Env references make config portable: `config.json` can be copied between machines while
the secret stays in that machine's daemon environment or keychain command.
`defaultModel` is optional and uses the same `<providerInstance>/<modelId>` form. An
explicit session model wins, then the selected agent definition's model, then
`agents.paseo.defaultModel`, then Pi's first available model.
`defaultProfile` is still accepted as a legacy alias for `defaultAgent`.
## Authentication
API-key providers use the catalog auth metadata plus the configured `apiKey` expression.
Redacted provider responses include an optional `auth` state:
- `Connected` means the key or credential expression resolves locally. It does not make a
network call, so a fake literal key still reports connected until a real session uses it.
- `Needs attention` means the instance exists but the auth expression does not currently
resolve, the OAuth store binding does not match, or another auth precondition is missing.
- `not configured` is used by older/no-auth responses.
The redacted provider `available` flag mirrors local credential availability. The Paseo
Agent runtime still needs at least one exposed model before it can start a session.
Secrets are not returned in catalog responses, redacted provider responses, or CLI table
output.
### OAuth store
OAuth credentials live in Paseo's store, not in another tool's auth file:
```text
$PASEO_HOME/paseo-agent/auth.json
```
The store is created through Pi's `AuthStorage`. The parent directory is private and the
file is written mode `0600`; Pi also re-chmods on write. During a Paseo Agent session,
Pi reads the credential, refreshes expired access tokens, and persists refresh-token
rotation back into the same Paseo-owned file.
Stored OAuth credentials are bound to the provider instance's `{ flow, baseUrl }`. If the
catalog flow or configured base URL changes, the old credential is left on disk but the
provider reports `Needs attention` until it is authorized again. This prevents a token
for one OAuth target from silently being used against another.
The OAuth implementation uses Pi's OAuth registry. Catalog OAuth entries are limited to
flows that registry knows about: `openai-codex`, `anthropic`, and `github-copilot`.
The current catalog only uses `openai-codex` for `chatgpt`.
## CLI setup
The provider CLI talks to the selected daemon. Always pass `--host` when smoking an
isolated daemon.
Commands:
- `paseo provider add [id]` configures a catalog provider. Omit `id` to choose from the
daemon catalog. Use `--name`, repeated/comma-separated `--model`, `--api-key-stdin`,
`--device-code`, `--json`, and `--host` as needed.
- `paseo provider ls` lists configured instances and redacted auth state.
- `paseo provider rm <name>` removes one provider instance and clears `defaultModel` if
it pointed at that instance.
Examples:
```bash
printf '%s\n' "$OPENROUTER_API_KEY" |
paseo provider add \
openrouter \
--api-key-stdin \
--model openai/gpt-4o-mini \
--host 127.0.0.1:7911
```
```bash
paseo provider add kimi \
--model k2p7 \
--host 127.0.0.1:7911
# Press Enter at the API-key prompt to store "$KIMI_API_KEY".
```
```bash
paseo provider add chatgpt --device-code --host 127.0.0.1:7911
```
Without an `[id]`, `provider add` prints the catalog and prompts for a selection. For
API-key providers it writes the configured key expression into `config.json`. For OAuth
providers it first writes the provider config, then stores the OAuth credential in the
daemon's Paseo-owned auth store.
`provider add` is idempotent for the same instance name: running it again updates the
existing entry instead of creating a duplicate. `provider rm <name>` removes only that
provider instance and clears `defaultModel` if it pointed at the removed instance.
## App setup
The app uses the same catalog and config RPCs as the CLI. In Settings, open the host
settings, then the Paseo Agent provider section. The app fetches the catalog from the
connected daemon, shows catalog entries in the picker, and renders either an API-key
form or an OAuth sign-in flow from the entry's `auth.kind`. OAuth providers offer two
daemon-run starts: browser redirect for the local fast path and device code for
remote-safe authorization. Relay connections present device code first; direct TCP does
not prove same-machine by itself, so both options remain available.
Configured rows show the redacted provider state from the daemon: label, provider type,
models, availability, and auth state. The app gates this UI on
`server_info.features.paseoAgentCatalog`; older daemons show an update-host affordance
instead of trying to synthesize the feature through older RPCs.
## Wire surface
The catalog surface is gated by:
```text
server_info.features.paseoAgentCatalog
```
RPC names use dotted namespaces:
| request | response |
| --------------------------------------------------- | ---------------------------------------------------- |
| `config.paseo_agent.get_catalog.request` | `config.paseo_agent.get_catalog.response` |
| `config.paseo_agent.get_providers.request` | `config.paseo_agent.get_providers.response` |
| `config.paseo_agent.set_provider.request` | `config.paseo_agent.set_provider.response` |
| `config.paseo_agent.remove_provider.request` | `config.paseo_agent.remove_provider.response` |
| `config.paseo_agent.oauth.start.request` | `config.paseo_agent.oauth.start.response` |
| `config.paseo_agent.oauth.complete.request` | `config.paseo_agent.oauth.complete.response` |
| `config.paseo_agent.oauth.store_credential.request` | `config.paseo_agent.oauth.store_credential.response` |
`oauth.start` accepts `mode?: string`; the daemon currently supports `"browser"` and
`"device_code"`, defaulting to `"browser"` because the common app/daemon desktop path can
complete the localhost redirect on the daemon host. Browser mode returns
`authorization: { kind: "auth_url", url, instructions? }` as soon as the daemon callback
listener is ready. `oauth.complete` then waits for the pending redirect flow to resolve,
stores the credential, and returns the redacted `auth` state. Device-code mode returns
`authorization: { kind: "device_code", userCode?, verificationUri?, intervalSeconds?,
expiresInSeconds?, instructions? }` and uses the same `oauth.complete` response after the
provider authorizes.
Protocol strings are intentionally open. Provider ids, auth kinds, OAuth flow names,
OAuth start modes, and wire API names are strings, not closed enums. Old clients can parse
new catalog entries, and new clients can show "update host" only when the daemon lacks the
catalog feature.
## Adding a catalog entry
Adding a new model-provider type should be a data change:
1. Add one entry to `PASEO_AGENT_PROVIDER_CATALOG` in
`packages/server/src/server/agent/providers/paseo-agent/catalog.ts`.
2. Set `id`, `piProvider`, `label`, and `defaultModels: false` only when the Pi provider
should not expose its full model list by default.
3. Add `auth` only when Pi cannot infer the auth source or when an explicit flow hint keeps
resolution simple.
4. Add or update focused tests around catalog assembly, provider resolution, auth state,
and CLI/app rendering if the new entry exercises a new shape.
Do not add provider-specific branches in the runtime, CLI, or app. The catalog entry is
what unlocks CLI setup, app setup, redacted provider state, config persistence, and model
addressing.
## MCP tools
Paseo Agent bridges `AgentSessionConfig.mcpServers` into Pi custom tools, so the
daemon-injected `paseo` MCP server (and any other configured MCP server) is available to
the model. On session start the provider connects to each server, lists its tools, and
registers them as Pi tools named `<serverName>__<toolName>`; tool input schemas (JSON
Schema) are converted to TypeBox, calls are proxied to the MCP server, and results map
back to the model. Connections are torn down on session close. Servers that fail to
connect or list are logged and skipped rather than failing the session.
Transports: HTTP (streamable) is the primary path (the injected `paseo` server is HTTP);
SSE and stdio transports are also wired via the MCP SDK. No extra config is needed: MCP
servers come from Paseo's normal injection/config, not from `agents.paseo`.
The internal `paseo` MCP server is required for Paseo Agent sessions and is injected even
when the global `mcp.injectIntoAgents` setting is disabled for other providers.
## Agent definitions
Paseo Agent can load a Paseo-owned agent definition from `$PASEO_HOME/agents/*.md`.
Configure the default agent in `agents.paseo.defaultAgent`; `orchestrator` resolves
to `$PASEO_HOME/agents/orchestrator.md`. Only top-level markdown files are selectable
agents. Reusable partials can live anywhere under `$PASEO_HOME/agents`.
```jsonc
{
"agents": {
"paseo": {
"defaultAgent": "orchestrator",
"defaultModel": "openrouter-main/openai/gpt-4o-mini",
"providers": {},
},
},
}
```
Example `$PASEO_HOME/agents/orchestrator.md`:
```markdown
---
name: Orchestrator
description: Coordinates work through Paseo-managed agents
prompt: extend
mcp: [paseo]
model: openrouter-main/openai/gpt-4o-mini
tools: [read, grep, paseo__list_agents, paseo__create_agent]
permissions:
- tool: paseo__archive_*
action: deny
---
!{{./partials/collaboration.md}}
Use the Paseo MCP tools to inspect active agents, create focused helper agents, and
summarize handoffs clearly.
!{{./partials/review-rules.md}}
```
`prompt: extend` keeps Pi's default base prompt and prepends the composed agent body to
the append list. `prompt: override` uses the agent body as the custom base prompt, so
Pi's default base prompt is skipped. In both prompt modes, per-session `systemPrompt` is
appended after the agent, and the daemon-level append prompt is appended last.
Frontmatter supports `name`, `description`, `prompt`, `mcp`, `model`, `tools`,
`permissions`, and `projectContext`. `projectContext` is parsed for a future explicit
project-context model, but it does not activate implicit `AGENTS.md`/`CLAUDE.md`
discovery; Paseo Agent still keeps Pi context discovery off. `model` is an agent default:
an explicit session model wins, then the selected agent model, then
`agents.paseo.defaultModel`, then Pi's first available model.
Partials use bang braces and expand exactly where they appear: `!{{./partials/base.md}}`.
Paths are relative to the file containing the directive and are confined to
`$PASEO_HOME/agents`: absolute paths, directory escapes, cycles, overly deep partial
chains, oversized definitions, and frontmatter inside partials are rejected.
`tools` is the Pi tool allowlist for the agent: it controls what the model sees and can
call. Omit it to use Pi's default built-in tools plus bridged MCP tools. `permissions` is
an ordered first-match policy for active tool calls. The first matching `tool` pattern
wins; unmatched tools are allowed. Denied calls are blocked before execution through Pi's
tool preflight hook, so the policy applies to built-in, custom, and bridged MCP tools.
`mcp: [paseo]` is an expectation check, not a new injection mechanism. The normal daemon
MCP injection still supplies the actual server; if an agent declares an MCP server that
is not present in the session's `mcpServers`, Paseo Agent logs a warning and continues.
+1 -1
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, workspace renaming)
- MCP server exposes the daemon to other agents (create_agent, send_agent_prompt, schedules, terminals, worktrees)
- Scheduled agents (cron-style triggers) via app, CLI, and MCP
- Frequent releases (multiple per week)
- Community contributions across packaging, providers, and bug fixes
-42
View File
@@ -1,42 +0,0 @@
# 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.
-2
View File
@@ -18,8 +18,6 @@ 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.
+3 -2
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 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`.
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.
**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,7 +281,8 @@ 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 publish=true \
-f source_build=auto
```
This replaces `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` in place without touching
+1
View File
@@ -45,6 +45,7 @@ buildNpmPackage rec {
nodejs = nodejs_22;
inherit (paseo) npmDeps;
npmDepsFetcherVersion = 2;
# Prevent onnxruntime-node's install script from running during automatic
# npm rebuild. We manually rebuild only node-pty in buildPhase.
+1 -1
View File
@@ -1 +1 @@
sha256-s4AXc/WKI2p5CdIJJMBmXiLoO4cNcT71GKfn8z9iknI=
sha256-RBD8SQx4szE41qGDIYcaKWVhzfC1kVUBC7M5OOQBQQY=
+1
View File
@@ -52,6 +52,7 @@ buildNpmPackage rec {
# Default hash lives in nix/npm-deps.hash (see arg default above).
# CI auto-updates that file when package-lock.json changes (see .github/workflows/).
inherit npmDepsHash;
npmDepsFetcherVersion = 2;
# Prevent onnxruntime-node's install script from running during automatic
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
+2905 -80
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.104-beta.4",
"version": "0.1.103",
"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": "npm run watch --workspace=@getpaseo/protocol",
"watch:protocol": "tsc -p packages/protocol/tsconfig.json --watch --preserveWatchOutput",
"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",
+130
View File
@@ -0,0 +1,130 @@
import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { Page } from "@playwright/test";
import { expect } from "@playwright/test";
import { gotoAppShell, openSettings } from "./app";
import { connectDaemonClient } from "./daemon-client-loader";
import { getServerId } from "./server-id";
import { openSettingsHostSection } from "./settings";
type PaseoAgentDaemonClient = Pick<
InternalDaemonClient,
| "close"
| "connect"
| "removePaseoAgentProvider"
| "setPaseoAgentProvider"
| "storePaseoAgentOAuthCredential"
>;
interface ApiKeyProviderInput {
catalogId: string;
apiKey: string;
}
interface OAuthProviderInput {
catalogId: string;
}
interface ExpectedProvider {
name: string;
providerLabel: string;
auth: "Connected" | "Needs attention";
modelCount: number;
}
async function connectPaseoAgentClient(): Promise<PaseoAgentDaemonClient> {
return connectDaemonClient<PaseoAgentDaemonClient>({ clientIdPrefix: "paseo-agent-e2e" });
}
export async function openPaseoAgentSettings(page: Page): Promise<void> {
await gotoAppShell(page);
await openSettings(page);
await openSettingsHostSection(page, getServerId(), "providers");
await page.getByRole("button", { name: "Paseo Agent provider details", exact: true }).click();
const sheet = page.getByTestId("paseo-agent-settings-sheet");
await expect(sheet).toBeVisible();
await expect(sheet.getByText("Paseo Agent", { exact: true })).toBeVisible();
}
export async function addApiKeyProvider(page: Page, provider: ApiKeyProviderInput): Promise<void> {
await page.getByRole("button", { name: "Add model provider", exact: true }).click();
await expect(page.getByTestId("paseo-agent-provider-picker")).toBeVisible();
await page.getByTestId(`paseo-agent-catalog-select-${provider.catalogId}`).click();
await expect(page.getByTestId("paseo-agent-provider-form")).toBeVisible();
await expect(page.getByLabel("Provider name")).toHaveCount(0);
await expect(page.getByLabel("Models")).toHaveCount(0);
await page.getByLabel("API key").fill(provider.apiKey);
await page.getByRole("button", { name: "Save provider", exact: true }).click();
await expect(page.getByTestId("paseo-agent-provider-form")).toHaveCount(0);
await expect(page.getByText(provider.apiKey, { exact: true })).toHaveCount(0);
}
export async function startOAuthProviderSignIn(
page: Page,
provider: OAuthProviderInput,
): Promise<void> {
await page.getByRole("button", { name: "Add model provider", exact: true }).click();
await expect(page.getByTestId("paseo-agent-provider-picker")).toBeVisible();
await page.getByTestId(`paseo-agent-catalog-select-${provider.catalogId}`).click();
await expect(page.getByTestId("paseo-agent-provider-form")).toHaveCount(0);
await expect(page.getByTestId("paseo-agent-oauth-sign-in")).toBeVisible();
await expect(page.getByLabel("Provider name")).toHaveCount(0);
await expect(
page.getByRole("button", { name: "Sign in with browser", exact: true }),
).toBeVisible();
await page.getByRole("button", { name: "Use a code instead", exact: true }).click();
await expect(page.getByTestId("paseo-agent-oauth-user-code")).toBeVisible();
await expect(page.getByTestId("paseo-agent-oauth-verification-link")).toBeVisible();
}
export async function expectModelProviderListed(
page: Page,
expected: ExpectedProvider,
): Promise<void> {
const modelLabel = expected.modelCount === 1 ? "1 model" : `${expected.modelCount} models`;
await expect(
page.getByRole("listitem", {
name: new RegExp(
`${expected.name}.*${expected.providerLabel}.*${modelLabel}.*${expected.auth}`,
),
}),
).toBeVisible();
}
export async function seedChatGptProvider(providerName: string): Promise<void> {
const client = await connectPaseoAgentClient();
try {
await client.setPaseoAgentProvider({
name: providerName,
providerType: "chatgpt",
options: {
models: [{ id: "gpt-5.4-mini", reasoning: true }],
},
});
await client.storePaseoAgentOAuthCredential({
name: providerName,
credential: {
type: "oauth",
access: "fake-access-token",
refresh: "fake-refresh-token",
expires: 4_102_444_800,
futureField: { passthrough: true },
},
});
} finally {
await client.close().catch(() => undefined);
}
}
export async function cleanupPaseoAgentProviders(providerNames: Iterable<string>): Promise<void> {
const client = await connectPaseoAgentClient();
try {
for (const name of providerNames) {
await client.removePaseoAgentProvider(name);
}
} finally {
await client.close().catch(() => undefined);
}
}
+7 -7
View File
@@ -13,12 +13,12 @@ import { getServerId } from "./helpers/server-id";
import { clickArchiveWorkspaceMenuItem, expectWorkspaceAbsentFromSidebar } from "./helpers/sidebar";
import { waitForSidebarHydration } from "./helpers/workspace-ui";
// Model B entry points into the New Workspace screen. The surviving entries are
// the global button (universal) and each project's per-row New workspace icon
// (preselects that project) — shown for git projects and for non-git projects on
// a multiplicity-capable host. These specs prove the global entry opens the
// screen, the project icon preselects the right project across the reused 'new'
// screen, and non-git projects never offer the worktree Isolation control.
// Model B entry points into the New Workspace screen. The per-project
// "+ New workspace" sidebar row is gone; the surviving entries are the global
// button (universal) and each git project's own new-worktree icon (preselects
// that project). These specs prove the global entry opens the screen, the
// project icon preselects the right project across the reused 'new' screen, and
// non-git projects never offer the worktree Isolation control.
function projectRow(page: import("@playwright/test").Page, projectKey: string) {
return page.getByTestId(`sidebar-project-row-${projectKey}`);
@@ -215,7 +215,7 @@ test.describe("New workspace entry points", () => {
await expect(projectRow(page, nonGitProject.projectId)).toBeVisible({ timeout: 30_000 });
// Open New Workspace for the non-git project via the global button, then
// select it in the picker (the per-row icon would preselect it too).
// select it in the picker (its row has no new-worktree icon).
await openGlobalNewWorkspaceComposer(page);
const trigger = page.getByTestId("new-workspace-project-picker-trigger");
await expect(trigger).toBeVisible({ timeout: 30_000 });
@@ -0,0 +1,153 @@
import { expect } from "@playwright/test";
import { test } from "./fixtures";
import {
addApiKeyProvider,
cleanupPaseoAgentProviders,
expectModelProviderListed,
openPaseoAgentSettings,
seedChatGptProvider,
startOAuthProviderSignIn,
} from "./helpers/paseo-agent";
const CHATGPT_PROVIDER = "phase-e-chatgpt-ui";
const CLOSE_PROVIDER = "phase-e-close-ui";
const DEFAULT_OPENROUTER_PROVIDER = "OpenRouter";
const DEFAULT_CHATGPT_PROVIDER = "ChatGPT";
const RENAME_PROVIDER = "phase-e-rename-ui";
test.describe("Paseo Agent provider configuration", () => {
const providerNamesToCleanup = new Set<string>();
test.afterEach(async () => {
await cleanupPaseoAgentProviders(providerNamesToCleanup);
providerNamesToCleanup.clear();
});
test("adds an OpenRouter model provider from Settings", async ({ page }) => {
providerNamesToCleanup.add(DEFAULT_OPENROUTER_PROVIDER);
await openPaseoAgentSettings(page);
await addApiKeyProvider(page, {
catalogId: "openrouter",
apiKey: "sk-or-phase-e-write-only",
});
await expectModelProviderListed(page, {
name: DEFAULT_OPENROUTER_PROVIDER,
providerLabel: "OpenRouter",
modelCount: 0,
auth: "Connected",
});
});
test("adds an API-key provider without prompting for a provider name", async ({ page }) => {
providerNamesToCleanup.add(DEFAULT_OPENROUTER_PROVIDER);
await openPaseoAgentSettings(page);
await page.getByRole("button", { name: "Add model provider", exact: true }).click();
await page.getByTestId("paseo-agent-catalog-select-openrouter").click();
await expect(page.getByTestId("paseo-agent-provider-form")).toBeVisible();
await expect(page.getByLabel("Provider name")).toHaveCount(0);
await expect(page.getByLabel("Models")).toHaveCount(0);
await page.getByLabel("API key").fill("sk-or-default-name");
await page.getByRole("button", { name: "Save provider", exact: true }).click();
await expectModelProviderListed(page, {
name: DEFAULT_OPENROUTER_PROVIDER,
providerLabel: "OpenRouter",
modelCount: 0,
auth: "Connected",
});
});
test("starts a ChatGPT sign-in from Settings", async ({ page }) => {
providerNamesToCleanup.add(DEFAULT_CHATGPT_PROVIDER);
await openPaseoAgentSettings(page);
await startOAuthProviderSignIn(page, {
catalogId: "chatgpt",
});
});
test("starts OAuth sign-in without prompting for a provider name", async ({ page }) => {
providerNamesToCleanup.add(DEFAULT_CHATGPT_PROVIDER);
await openPaseoAgentSettings(page);
await page.getByRole("button", { name: "Add model provider", exact: true }).click();
await page.getByTestId("paseo-agent-catalog-select-chatgpt").click();
await expect(page.getByTestId("paseo-agent-provider-form")).toHaveCount(0);
await expect(page.getByLabel("Provider name")).toHaveCount(0);
});
test("shows user-facing provider picker rows", async ({ page }) => {
await openPaseoAgentSettings(page);
await page.getByRole("button", { name: "Add model provider", exact: true }).click();
const picker = page.getByTestId("paseo-agent-provider-picker");
await expect(
page
.getByTestId("paseo-agent-catalog-entry-openrouter")
.getByText("API key", { exact: true }),
).toBeVisible();
await expect(page.getByTestId("paseo-agent-catalog-icon-openrouter")).toBeVisible();
await expect(
page.getByTestId("paseo-agent-catalog-entry-chatgpt").getByText("Sign in", { exact: true }),
).toBeVisible();
await expect(page.getByTestId("paseo-agent-catalog-icon-chatgpt")).toBeVisible();
await expect(picker.getByText("openai-codex-responses")).toHaveCount(0);
await expect(picker.getByText(/\b\d+ models?\b/)).toHaveCount(0);
});
test("shows a stored ChatGPT login as a read-only model provider row", async ({ page }) => {
providerNamesToCleanup.add(CHATGPT_PROVIDER);
await seedChatGptProvider(CHATGPT_PROVIDER);
await openPaseoAgentSettings(page);
await expectModelProviderListed(page, {
name: CHATGPT_PROVIDER,
providerLabel: "ChatGPT",
modelCount: 1,
auth: "Connected",
});
});
test("renames a configured provider row without changing its stored credential key", async ({
page,
}) => {
providerNamesToCleanup.add(RENAME_PROVIDER);
await seedChatGptProvider(RENAME_PROVIDER);
await openPaseoAgentSettings(page);
await page.getByTestId(`paseo-agent-provider-rename-${RENAME_PROVIDER}`).click();
await expect(page.getByTestId("paseo-agent-provider-rename-form")).toBeVisible();
await page.getByLabel("Provider name").fill("Work account");
await page.getByRole("button", { name: "Save", exact: true }).click();
await expectModelProviderListed(page, {
name: "Work account",
providerLabel: "ChatGPT",
modelCount: 1,
auth: "Connected",
});
});
test("closes the Paseo Agent settings sheet after providers load", async ({ page }) => {
providerNamesToCleanup.add(CLOSE_PROVIDER);
await seedChatGptProvider(CLOSE_PROVIDER);
await openPaseoAgentSettings(page);
await expectModelProviderListed(page, {
name: CLOSE_PROVIDER,
providerLabel: "ChatGPT",
modelCount: 1,
auth: "Connected",
});
await page.getByLabel("Close", { exact: true }).click();
await expect(page.getByTestId("paseo-agent-settings-sheet")).toHaveCount(0);
});
});
@@ -76,7 +76,7 @@ test.describe("Schedules", () => {
await page.goto(buildSchedulesRoute());
const row = page.getByTestId(`schedule-row-${scheduleId}`);
await expect(row).toBeVisible({ timeout: 30_000 });
await expect(row).toContainText(workspace.projectDisplayName, { timeout: 30_000 });
await expect(row).toContainText("ten-second-stream");
await row.click();
await expect(page.getByTestId("schedule-form-sheet")).toBeVisible({ timeout: 10_000 });
@@ -5,6 +5,7 @@ import {
buildFakeScheduleHostWorkspace,
installFakeScheduleHost,
} from "./helpers/schedule-fake-host";
import { getServerId } from "./helpers/server-id";
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
import { waitForSidebarHydration } from "./helpers/workspace-ui";
import { buildSchedulesRoute } from "../src/utils/host-routes";
@@ -81,9 +82,9 @@ test.describe("Schedules project target", () => {
await page.getByRole("button", { name: "Schedules" }).click();
await expect(page).toHaveURL(/\/schedules$/);
await expect(page).not.toHaveURL(/\/h\//);
await expect(page.getByTestId("schedules-empty")).toBeVisible();
await expect(page.getByTestId(`schedules-section-${getServerId()}`)).toBeVisible();
await page.getByTestId("schedules-empty-new").click();
await page.getByRole("button", { name: "New schedule" }).click();
await expect(page.getByTestId("schedule-form-sheet")).toBeVisible({ timeout: 10_000 });
await expect(page.getByTestId("schedule-cwd-trigger")).toHaveCount(0);
@@ -126,8 +127,10 @@ test.describe("Schedules project target", () => {
label: "Fake host",
port: fakePort,
});
await expect(page.getByTestId("schedules-empty")).toBeVisible({ timeout: 30_000 });
await page.getByTestId("schedules-empty-new").click();
await expect(page.getByTestId(`schedules-section-${fakeHost.serverId}`)).toBeVisible({
timeout: 30_000,
});
await page.getByRole("button", { name: "New schedule" }).click();
await expect(page.getByTestId("schedule-form-sheet")).toBeVisible({ timeout: 10_000 });
await page.getByRole("button", { name: /select project/i }).click();
+5 -8
View File
@@ -38,7 +38,7 @@ async function seedSecondWorkspace(seeded: SeededWorkspace, title: string): Prom
test.describe("Model B sidebar shape", () => {
test.describe.configure({ timeout: 180_000 });
test("git and non-git projects both render as expandable parents, both show a per-row New workspace icon, and the global button covers both", async ({
test("git and non-git projects both render as expandable parents; git keeps a per-row new-worktree icon, the global button covers both", async ({
page,
}) => {
const gitProject = await seedWorkspace({ repoPrefix: "model-b-git-" });
@@ -62,17 +62,14 @@ test.describe("Model B sidebar shape", () => {
await expect(workspaceRow(page, nonGitProject.workspaceId)).toBeVisible({ timeout: 30_000 });
await expect(workspaceRow(page, nonGitSecondId)).toBeVisible({ timeout: 30_000 });
// Both projects show a per-row New workspace icon (revealed on hover): the
// git project can branch off a worktree, and the non-git project can add
// another workspace because the host supports workspaceMultiplicity.
// The per-project "+ New workspace" row is gone. The git project keeps a
// per-row new-worktree icon (revealed on hover); the non-git project has
// none, since worktree creation needs a git checkout.
await projectRow(page, gitProject.projectId).hover();
await expect(projectNewWorktreeIcon(page, gitProject.projectId)).toBeVisible({
timeout: 30_000,
});
await projectRow(page, nonGitProject.projectId).hover();
await expect(projectNewWorktreeIcon(page, nonGitProject.projectId)).toBeVisible({
timeout: 30_000,
});
await expect(projectNewWorktreeIcon(page, nonGitProject.projectId)).toHaveCount(0);
// The global new-workspace button is the universal entry — present for both
// kinds regardless of their per-row affordance.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.1.104-beta.4",
"version": "0.1.103",
"private": true,
"main": "index.ts",
"scripts": {
@@ -9,7 +9,7 @@ import {
} from "lucide-react-native";
import { withUnistyles } from "react-native-unistyles";
import type { AgentAttachment } from "@getpaseo/protocol/messages";
import type { WorkspaceComposerAttachment } from "@/attachments/types";
import type { BrowserAnnotationIntent, 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,6 +36,13 @@ 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,
@@ -89,10 +96,11 @@ export function getWorkspaceAttachmentPillContent(
t: TFunction,
): AttachmentPillContent {
if (attachment.kind === "browser_element") {
const intent = attachment.attachment.intent;
return {
icon: attachmentBrowserIcon,
title: attachment.attachment.tag,
subtitle: t("composer.attachments.element"),
subtitle: intent ? t(BROWSER_INTENT_LABEL_KEYS[intent]) : t("composer.attachments.element"),
};
}
if (isPullRequestContextAttachment(attachment)) {
+8
View File
@@ -21,6 +21,12 @@ 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;
@@ -44,6 +50,8 @@ 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;
@@ -1,6 +1,5 @@
import { beforeEach, describe, expect, test } from "vitest";
import { beforeEach, describe, expect, test, vi } 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";
@@ -9,6 +8,20 @@ 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" }
@@ -17,28 +30,9 @@ 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 readonly sentResponses: BrowserAutomationExecuteResponse[] = [];
public sentResponses: BrowserAutomationExecuteResponse[] = [];
private handler: ((request: BrowserAutomationExecuteRequest) => void) | null = null;
public on(
@@ -61,107 +55,6 @@ 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 {
@@ -177,69 +70,45 @@ function browserNewTabRequest(): BrowserAutomationExecuteRequest {
type: "browser.automation.execute.request",
requestId: "req-new",
agentId: "agent-1",
workspaceId: "wks_workspace_a",
command: {
command: "new_tab",
args: { url: "https://example.com" },
},
workspaceId: "/repo",
command: { command: "new_tab", args: { workspaceId: "/repo", url: "https://example.com" } },
};
}
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 {
function emptyListTabsPayload(requestId = "req-new:list_tabs") {
return {
requestId,
ok: true,
ok: true as const,
result: {
command: "list_tabs",
command: "list_tabs" as const,
tabs: [],
},
};
}
function currentListTabsPayload(requestId = "req-new:list_tabs"): BrowserAutomationResponsePayload {
function currentListTabsPayload(requestId = "req-new:list_tabs") {
return {
requestId,
ok: true,
ok: true as const,
result: {
command: "list_tabs",
command: "list_tabs" as const,
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: "wks_workspace_a",
workspaceId: "/repo",
url: browser.url,
title: browser.title,
isActive: true,
@@ -247,268 +116,151 @@ 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("browser_new_tab creates a workspace browser tab without stealing focus", async () => {
const browser = new BrowserAutomationHandlerHarness();
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());
const workspaceKey = buildWorkspaceTabPersistenceKey({
serverId: "server-1",
workspaceId: "wks_workspace_a",
workspaceId: "/repo",
});
if (!workspaceKey) {
throw new Error("Expected workspace key");
}
const previousFocusedTabId = useWorkspaceLayoutStore
if (!workspaceKey) throw new Error("expected workspace key");
const focusedTabId = useWorkspaceLayoutStore
.getState()
.openTabFocused(workspaceKey, { kind: "draft", draftId: "human-draft" });
browser.mount({ serverId: "server-1" });
if (!focusedTabId) throw new Error("expected focused tab");
mountBrowserAutomationHandler({
client,
serverId: "server-1",
getHost: () =>
({
browser: {
executeAutomationCommand,
registerWorkspaceBrowser,
setWorkspaceActiveBrowser,
setAgentActiveBrowser,
},
}) satisfies DesktopHostBridge,
ensureResidentBrowserWebview,
});
browser.receive(browserNewTabRequest());
await flushAsyncWork();
client.receive(browserNewTabRequest());
await flushPromises();
const result = newTabResultFrom(browser.client.payloadAt(0));
const openedTabs = workspaceBrowserTabs(workspaceKey, result.browserId);
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(openedTabs).toEqual([
expect.objectContaining({
target: { kind: "browser", browserId: result.browserId },
}),
]);
expect(layout?.root).toEqual(
expect.objectContaining({
kind: "pane",
pane: expect.objectContaining({ focusedTabId: previousFocusedTabId }),
}),
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 } }),
);
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: {} },
},
]);
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);
});
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({
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,
serverId: "server-1",
getHost: () => ({ browser: { executeAutomationCommand } }) satisfies DesktopHostBridge,
ensureResidentBrowserWebview,
registrationWaitTimeoutMs: 1,
registrationPollIntervalMs: 1,
});
browser.receive(browserNewTabRequest());
await waitForRegistrationTimeout();
client.receive(browserNewTabRequest());
await waitForAsyncWork();
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(client.sentResponses[0]?.payload).toMatchObject({
requestId: "req-new",
ok: false,
error: {
code: "browser_timeout",
retryable: true,
},
]);
expect(browser.resident.ensuredWebviews).toEqual([
});
expect(ensureResidentBrowserWebview).toHaveBeenCalledWith(
expect.objectContaining({ url: "https://example.com" }),
]);
});
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" });
browser.receive(browserNewTabRequest());
await flushAsyncWork();
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",
);
expect(client.sentResponses[0]?.payload).not.toMatchObject({
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_tab_not_found",
message: `No browser tab found for ID: ${result.browserId}`,
retryable: false,
},
result: { command: "new_tab" },
});
});
test("browser_close_tab removes the workspace tab, browser record, resident webview, registry entry, and partition", async () => {
const browser = new BrowserAutomationHandlerHarness();
const workspaceKey = buildWorkspaceTabPersistenceKey({
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",
workspaceId: "wks_workspace_a",
getHost: () => ({ browser: { executeAutomationCommand } }) satisfies DesktopHostBridge,
});
if (!workspaceKey) {
throw new Error("Expected workspace key");
}
browser.mount({ serverId: "server-1" });
browser.receive(browserNewTabRequest());
await flushAsyncWork();
const result = newTabResultFrom(browser.client.payloadAt(0));
client.receive(browserNewTabRequest());
await flushPromises();
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",
expect(client.sentResponses[0]?.payload).toEqual({
requestId: "req-new",
ok: false,
error: {
code: "browser_tab_not_found",
message: `No browser tab found for ID: ${result.browserId}`,
code: "browser_unknown_error",
message: "IPC registration check failed",
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();
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: [] },
}));
browser.receive(request);
await flushAsyncWork();
mountBrowserAutomationHandler({
client,
getHost: () => ({ browser: { executeAutomationCommand } }) satisfies DesktopHostBridge,
});
expect(browser.browser.executedRequests).toEqual([request]);
expect(browser.client.sentResponses).toEqual([
client.receive(browserAutomationRequest());
await flushPromises();
expect(executeAutomationCommand).toHaveBeenCalledWith(browserAutomationRequest());
expect(client.sentResponses).toEqual([
{
type: "browser.automation.execute.response",
payload: {
@@ -520,14 +272,14 @@ describe("mountBrowserAutomationHandler", () => {
]);
});
test("missing desktop bridge sends browser_unsupported", async () => {
const browser = new BrowserAutomationHandlerHarness();
browser.mount({ host: null });
test("missing bridge sends browser_unsupported", async () => {
const client = new FakeDaemonClient();
mountBrowserAutomationHandler({ client, getHost: () => null });
browser.receive(browserAutomationRequest());
await flushAsyncWork();
client.receive(browserAutomationRequest());
await flushPromises();
expect(browser.client.sentResponses).toEqual([
expect(client.sentResponses).toEqual([
{
type: "browser.automation.execute.response",
payload: {
@@ -535,7 +287,7 @@ describe("mountBrowserAutomationHandler", () => {
ok: false,
error: {
code: "browser_unsupported",
message: "Browser automation is not available in this app runtime.",
message: "Desktop browser automation is not available in this app runtime.",
retryable: false,
},
},
@@ -544,68 +296,80 @@ describe("mountBrowserAutomationHandler", () => {
});
test("typed bridge errors become failure responses", async () => {
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,
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,
};
},
},
}),
});
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 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,
const client = new FakeDaemonClient();
mountBrowserAutomationHandler({
client,
getHost: () => ({
browser: {
executeAutomationCommand: async () => {
throw new Error('No handler registered for "paseo:browser:execute-automation-command"');
},
},
}),
});
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 browser automation requests", async () => {
const browser = new BrowserAutomationHandlerHarness();
browser.mount();
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 } }),
});
browser.unmount();
browser.receive(browserAutomationRequest());
await flushAsyncWork();
unsubscribe();
client.receive(browserAutomationRequest());
await flushPromises();
expect(browser.browser.executedRequests).toEqual([]);
expect(browser.client.sentResponses).toEqual([]);
expect(executeAutomationCommand).not.toHaveBeenCalled();
expect(client.sentResponses).toEqual([]);
});
});
+37 -166
View File
@@ -1,14 +1,9 @@
import type { SessionInboundMessage, SessionOutboundMessage } from "@getpaseo/protocol/messages";
import { getDesktopHost, type DesktopHostBridge } from "@/desktop/host";
import {
ensureResidentBrowserWebview as ensureResidentBrowserWebviewDefault,
removeResidentBrowserWebview,
resizeResidentBrowserWebview,
} from "@/components/browser-webview-resident";
import { createWorkspaceBrowser, getBrowserRecord, useBrowserStore } from "@/stores/browser-store";
import { ensureResidentBrowserWebview as ensureResidentBrowserWebviewDefault } from "@/components/browser-webview-resident";
import { createWorkspaceBrowser } from "@/stores/browser-store";
import {
buildWorkspaceTabPersistenceKey,
collectAllTabs,
useWorkspaceLayoutStore,
} from "@/stores/workspace-layout-store";
@@ -45,7 +40,7 @@ export function mountBrowserAutomationHandler(
options: BrowserAutomationHandlerOptions,
): () => void {
const getHost = options.getHost ?? getDesktopHost;
const unsubscribe = options.client.on("browser.automation.execute.request", (request) => {
return options.client.on("browser.automation.execute.request", (request) => {
void handleBrowserAutomationRequest({
client: options.client,
getHost,
@@ -61,9 +56,6 @@ export function mountBrowserAutomationHandler(
: {}),
});
});
return () => {
unsubscribe();
};
}
export function mountBrowserAutomationDaemonClientHandler(
@@ -119,32 +111,7 @@ async function handleBrowserAutomationRequest(params: {
return;
}
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;
}
await rememberAgentBrowserTarget({ request, browserHost });
if (!executeAutomationCommand) {
client.sendBrowserAutomationExecuteResponse({
@@ -152,7 +119,7 @@ async function handleBrowserAutomationRequest(params: {
payload: browserAutomationFailure({
requestId: request.requestId,
code: "browser_unsupported",
message: "Browser automation is not available in this app runtime.",
message: "Desktop browser automation is not available in this app runtime.",
}),
});
return;
@@ -172,127 +139,6 @@ 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;
@@ -313,11 +159,11 @@ async function openBrowserTabForRequest(params: {
BrowserAutomationExecuteRequest["command"],
{ command: "new_tab" }
>;
const workspaceId = request.workspaceId;
const workspaceId = request.workspaceId ?? command.args.workspaceId;
if (!serverId || !workspaceId) {
return browserAutomationFailure({
requestId: request.requestId,
code: "browser_unsupported",
code: "browser_no_tab",
message: "Cannot create a browser tab without a workspace context.",
});
}
@@ -328,7 +174,7 @@ async function openBrowserTabForRequest(params: {
if (!workspaceKey) {
return browserAutomationFailure({
requestId: request.requestId,
code: "browser_unsupported",
code: "browser_no_tab",
message: "Cannot create a browser tab without a workspace context.",
});
}
@@ -338,6 +184,9 @@ 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 });
@@ -355,7 +204,7 @@ async function openBrowserTabForRequest(params: {
return browserAutomationFailure({
requestId: request.requestId,
code: "browser_timeout",
message: `Timed out waiting for browser tab ${browserId} to register with the browser automation host. Try browser_new_tab again.`,
message: `Timed out waiting for browser tab ${browserId} to register with desktop automation. Try browser_new_tab again.`,
retryable: true,
});
}
@@ -386,7 +235,7 @@ async function waitForBrowserRegistration(params: {
agentId: params.request.agentId,
cwd: params.request.cwd,
workspaceId: params.workspaceId,
command: { command: "list_tabs", args: {} },
command: { command: "list_tabs", args: { workspaceId: params.workspaceId } },
});
if (payload.ok && payload.result.command === "list_tabs") {
if (payload.result.tabs.some((tab) => tab.browserId === params.browserId)) {
@@ -402,6 +251,28 @@ 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,
@@ -423,14 +294,14 @@ function normalizeThrownBridgeError(
return browserAutomationFailure({
requestId,
code: "browser_unsupported",
message: "Browser automation is not implemented by this app build yet.",
message: "Desktop browser automation is not implemented by this desktop build yet.",
});
}
return browserAutomationFailure({
requestId,
code: "browser_unknown_error",
message: message || "Browser automation failed.",
message: message || "Desktop browser automation failed.",
});
}
@@ -12,22 +12,21 @@ 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 {
@@ -41,7 +40,11 @@ import {
useWorkspaceAttachments,
useWorkspaceAttachmentsStore,
} from "@/attachments/workspace-attachments-store";
import type { AttachmentMetadata, BrowserElementAttachment } from "@/attachments/types";
import type {
AttachmentMetadata,
BrowserAnnotationIntent,
BrowserElementAttachment,
} from "@/attachments/types";
import { persistAttachmentFromDataUrl } from "@/attachments/service";
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import {
@@ -49,6 +52,7 @@ import {
isElectronRuntime,
type DesktopBrowserShortcutEvent,
} from "@/desktop/host";
import { isDev } from "@/constants/platform";
import { useBrowserStore, normalizeWorkspaceBrowserUrl } from "@/stores/browser-store";
import {
prepareBrowserWebview,
@@ -75,14 +79,25 @@ type WebTextInput = TextInput & {
getNativeRef?: () => unknown;
};
type BrowserElementSelection = Omit<BrowserElementAttachment, "formatted" | "comment"> & {
type BrowserElementSelection = Omit<
BrowserElementAttachment,
"formatted" | "comment" | "intent"
> & {
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"
@@ -208,6 +223,10 @@ 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,
@@ -270,6 +289,7 @@ function buildBrowserElementAttachment(
parentChain: selection.parentChain,
children: selection.children,
...(comment ? { comment } : {}),
...(annotation ? { intent: annotation.intent } : {}),
...(screenshot ? { screenshot } : {}),
formatted: formatElementAttachment(selection, annotation),
};
@@ -621,11 +641,10 @@ export function BrowserPane({
const pendingNavigationUrlRef = useRef<string | null>(null);
const domReadyRef = useRef(false);
const annotationMarkersRef = useRef<BrowserAnnotationMarker[]>([]);
const [selectorMode, setSelectorMode] = useState<"annotate" | "screenshot" | null>(null);
const selectorActive = selectorMode !== null;
const [selectorActive, setSelectorActive] = useState(false);
// Which action the active selector performs on click: open the annotation card
// ("annotate") or copy a screenshot of the element to the clipboard ("screenshot").
const selectorModeRef = useRef<"annotate" | "screenshot">("annotate");
// ("annotate") or copy the element to the clipboard ("grab").
const selectorModeRef = useRef<"annotate" | "grab">("annotate");
const toast = useToast();
const toastRef = useRef(toast);
toastRef.current = toast;
@@ -1055,7 +1074,7 @@ export function BrowserPane({
[],
);
const screenshotElementToClipboard = useCallback(
const grabElementToClipboard = useCallback(
async (selection: BrowserElementSelection) => {
const text = formatElementAttachment(selection);
const copyElement = getDesktopHost()?.browser?.copyElement;
@@ -1068,23 +1087,19 @@ 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 screenshot failed", error);
console.warn("[browser-pane] capture element for grab 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?.show(copiedMessage, { variant: "success" });
toastRef.current?.copied(t("workspace.browser.controls.grabElementLabel"));
} else {
toastRef.current?.error(t("workspace.browser.controls.screenshotFailed"));
toastRef.current?.error(t("workspace.browser.controls.grabFailed"));
}
return;
} catch (error) {
@@ -1095,12 +1110,10 @@ export function BrowserPane({
// Fallback to expo-clipboard (text only) when the bridge is unavailable.
try {
await Clipboard.setStringAsync(text);
toastRef.current?.show(t("workspace.browser.controls.elementCopied"), {
variant: "success",
});
toastRef.current?.copied(t("workspace.browser.controls.grabElementLabel"));
} catch (error) {
console.warn("[browser-pane] clipboard fallback failed", error);
toastRef.current?.error(t("workspace.browser.controls.screenshotFailed"));
toastRef.current?.error(t("workspace.browser.controls.grabFailed"));
}
},
[t],
@@ -1108,8 +1121,8 @@ export function BrowserPane({
const handleSelectorResult = useCallback(
(selection: BrowserElementSelection) => {
if (selectorModeRef.current === "screenshot") {
void screenshotElementToClipboard(selection);
if (selectorModeRef.current === "grab") {
void grabElementToClipboard(selection);
return;
}
pendingScreenshotRef.current = undefined;
@@ -1119,7 +1132,7 @@ export function BrowserPane({
return undefined;
});
},
[captureElementScreenshot, screenshotElementToClipboard],
[captureElementScreenshot, grabElementToClipboard],
);
const submitAnnotation = useCallback(
@@ -1142,15 +1155,15 @@ export function BrowserPane({
}, []);
const startElementSelector = useCallback(
(mode: "annotate" | "screenshot") => {
(mode: "annotate" | "grab") => {
const webview = webviewRef.current;
if (!webview || !domReadyRef.current) return;
// Annotate needs a workspace scope to attach to; screenshot only copies.
// Annotate needs a workspace scope to attach to; grab only copies.
if (mode === "annotate" && !workspaceAttachmentScopeKey) return;
selectorModeRef.current = mode;
pendingScreenshotRef.current = undefined;
setPendingSelection(null);
setSelectorMode(mode);
setSelectorActive(true);
const js = `
(function() {
@@ -1376,11 +1389,11 @@ export function BrowserPane({
const poll = startSelectorResultPolling({
webview,
onSelection: handleSelectorResult,
onDone: () => setSelectorMode(null),
onDone: () => setSelectorActive(false),
});
window.setTimeout(() => {
window.clearInterval(poll);
setSelectorMode(null);
setSelectorActive(false);
if (webviewRef.current !== webview || !domReadyRef.current) {
return;
}
@@ -1389,10 +1402,10 @@ export function BrowserPane({
return undefined;
})
.catch(() => {
setSelectorMode(null);
setSelectorActive(false);
});
} catch {
setSelectorMode(null);
setSelectorActive(false);
}
},
[handleSelectorResult, workspaceAttachmentScopeKey],
@@ -1400,7 +1413,7 @@ export function BrowserPane({
const cancelElementSelector = useCallback(() => {
const webview = webviewRef.current;
setSelectorMode(null);
setSelectorActive(false);
if (webview && domReadyRef.current) {
try {
clearWebviewSelector(webview);
@@ -1457,12 +1470,12 @@ export function BrowserPane({
startElementSelector("annotate");
}, [cancelElementSelector, selectorActive, startElementSelector]);
const handleToggleScreenshot = useCallback(() => {
const handleToggleGrab = useCallback(() => {
if (selectorActive) {
cancelElementSelector();
return;
}
startElementSelector("screenshot");
startElementSelector("grab");
}, [cancelElementSelector, selectorActive, startElementSelector]);
const handleOpenDevTools = useCallback(() => {
@@ -1508,21 +1521,13 @@ export function BrowserPane({
],
[browser?.canGoForward],
);
const annotateIconButtonStyle = useCallback(
const selectorIconButtonStyle = useCallback(
({ hovered, pressed }: { hovered?: boolean; pressed?: boolean }) => [
styles.iconButton,
selectorMode === "annotate" && styles.selectorActiveButton,
selectorActive && styles.selectorActiveButton,
(hovered || pressed) && styles.iconButtonHovered,
],
[selectorMode],
);
const screenshotIconButtonStyle = useCallback(
({ hovered, pressed }: { hovered?: boolean; pressed?: boolean }) => [
styles.iconButton,
selectorMode === "screenshot" && styles.selectorActiveButton,
(hovered || pressed) && styles.iconButtonHovered,
],
[selectorMode],
[selectorActive],
);
const devicePreset = useMemo(
@@ -1628,46 +1633,36 @@ export function BrowserPane({
onSelect={setDeviceSizeId}
triggerStyle={baseIconButtonStyle}
/>
<ToolbarButton
label={t("workspace.browser.controls.openDevTools")}
onPress={handleOpenDevTools}
style={baseIconButtonStyle}
>
<Wrench size={16} color={theme.colors.foregroundMuted} />
</ToolbarButton>
{isDev ? (
<ToolbarButton
label={t("workspace.browser.controls.openDevTools")}
onPress={handleOpenDevTools}
style={baseIconButtonStyle}
>
<PencilRuler size={16} color={theme.colors.foregroundMuted} />
</ToolbarButton>
) : null}
<ToolbarButton
label={
selectorMode === "annotate"
selectorActive
? t("workspace.browser.controls.cancelSelector")
: t("workspace.browser.controls.annotateElement")
: t("workspace.browser.controls.selectElement")
}
active={selectorMode === "annotate"}
active={selectorActive}
onPress={handleToggleElementSelector}
style={annotateIconButtonStyle}
style={selectorIconButtonStyle}
>
<MousePointer2
size={16}
color={
selectorMode === "annotate" ? theme.colors.accent : theme.colors.foregroundMuted
}
color={selectorActive ? theme.colors.accent : theme.colors.foregroundMuted}
/>
</ToolbarButton>
<ToolbarButton
label={
selectorMode === "screenshot"
? t("workspace.browser.controls.cancelSelector")
: t("workspace.browser.controls.screenshotElement")
}
active={selectorMode === "screenshot"}
onPress={handleToggleScreenshot}
style={screenshotIconButtonStyle}
label={t("workspace.browser.controls.grabElement")}
onPress={handleToggleGrab}
style={baseIconButtonStyle}
>
<Camera
size={16}
color={
selectorMode === "screenshot" ? theme.colors.accent : theme.colors.foregroundMuted
}
/>
<Copy size={16} color={theme.colors.foregroundMuted} />
</ToolbarButton>
</View>
</View>
@@ -1695,6 +1690,45 @@ 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,
@@ -1706,11 +1740,14 @@ 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 });
onSubmit({ comment: commentRef.current, intent: intentRef.current });
}, [onSubmit]);
useEffect(() => {
@@ -1755,6 +1792,17 @@ 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
@@ -1766,12 +1814,24 @@ function BrowserElementAnnotationCard({
value={comment}
/>
<View style={styles.annotationActions}>
<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>
<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>
</View>
</View>
</View>
@@ -1926,7 +1986,31 @@ const styles = StyleSheet.create((theme) => ({
annotationElement: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
marginBottom: theme.spacing[2],
},
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,
},
annotationInput: {
minHeight: 64,
@@ -1945,6 +2029,27 @@ 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",
@@ -2,66 +2,33 @@ 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("parks a browser webview in the permanent paintable 1x1 host", () => {
const visibleHost = document.createElement("div");
it("keeps a browser webview mounted offscreen and reuses the same node", () => {
const host = document.createElement("div");
const webview = document.createElement("webview");
visibleHost.appendChild(webview);
document.body.appendChild(visibleHost);
host.appendChild(webview);
document.body.appendChild(host);
releaseResidentBrowserWebview("browser-a", webview);
const host = residentHost();
expect(visibleHost.children).toHaveLength(0);
expect(Array.from(host.children)).toEqual([webview]);
expect(host.children).toHaveLength(0);
expect(webview.isConnected).toBe(true);
expectPermanentHostParking(host);
expectResidentWebviewParking(webview);
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();
});
it("creates a resident webview for an agent-created unfocused tab", () => {
@@ -77,113 +44,6 @@ 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", () => {
@@ -4,7 +4,6 @@ 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;
@@ -22,84 +21,42 @@ 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;
applyResidentHostParkingStyle(host);
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";
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;
return element as HTMLElement;
}
}
return null;
}
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`;
function applyResidentWebviewStyle(webview: HTMLElement): void {
webview.style.display = "block";
webview.style.width = `${RESIDENT_VIEWPORT_WIDTH}px`;
webview.style.height = `${RESIDENT_VIEWPORT_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(
@@ -131,15 +88,11 @@ 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;
}
@@ -161,7 +114,6 @@ export function takeResidentBrowserWebview(browserId: string): HTMLElement | nul
}
residentWebviewsByBrowserId.delete(normalizedBrowserId);
clearResidentWebviewParkingStyle(webview);
return webview;
}
@@ -177,33 +129,10 @@ export function releaseResidentBrowserWebview(browserId: string, webview: HTMLEl
}
residentWebviewsByBrowserId.set(normalizedBrowserId, webview);
applyResidentWebviewStyle(webview, normalizedBrowserId);
applyResidentWebviewStyle(webview);
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) {
@@ -212,7 +141,6 @@ export function removeResidentBrowserWebview(browserId: string): void {
const resident = residentWebviewsByBrowserId.get(normalizedBrowserId) ?? null;
residentWebviewsByBrowserId.delete(normalizedBrowserId);
residentWebviewSizesByBrowserId.delete(normalizedBrowserId);
resident?.remove();
}
@@ -221,6 +149,5 @@ export function clearResidentBrowserWebviewsForTests(): void {
webview.remove();
}
residentWebviewsByBrowserId.clear();
residentWebviewSizesByBrowserId.clear();
readDocument()?.getElementById(RESIDENT_BROWSER_HOST_ID)?.remove();
}
@@ -1,118 +0,0 @@
import { useCallback, useMemo, useRef, useState, type ReactElement } from "react";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { ChevronDown, Server } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { HostProfile } from "@/types/host-connection";
import type { Theme } from "@/styles/theme";
import {
ALL_HOSTS_OPTION_ID,
getHostPickerLabel,
HostPicker,
HostStatusDotSlot,
} from "@/components/hosts/host-picker";
const ThemedServer = withUnistyles(Server);
const ThemedChevronDown = withUnistyles(ChevronDown);
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
export interface HostFilterProps {
hosts: HostProfile[];
selectedHost: string;
onSelectHost: (serverId: string) => void;
triggerTestID?: string;
}
/**
* The "All hosts / <host>" filter pill shared by the History and Schedules
* screens: an anchored HostPicker with `includeAllHost`, hidden by the caller
* when only one host exists. Copies the History layout exactly.
*/
export function HostFilter({
hosts,
selectedHost,
onSelectHost,
triggerTestID,
}: HostFilterProps): ReactElement {
const [isFilterOpen, setIsFilterOpen] = useState(false);
const filterAnchorRef = useRef<View>(null);
const selectedHostLabel = useMemo(
() => getHostPickerLabel(hosts, selectedHost, { includeAllHost: true }),
[hosts, selectedHost],
);
const handleFilterOpen = useCallback(() => setIsFilterOpen(true), []);
const filterTriggerStyle = useCallback(
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.filterTrigger,
Boolean(hovered) && styles.filterTriggerHovered,
pressed && styles.filterTriggerPressed,
],
[],
);
return (
<HostPicker
hosts={hosts}
value={selectedHost}
onSelect={onSelectHost}
open={isFilterOpen}
onOpenChange={setIsFilterOpen}
anchorRef={filterAnchorRef}
includeAllHost
searchable={false}
title="Filter by host"
desktopPlacement="bottom-start"
>
<View ref={filterAnchorRef} collapsable={false} style={styles.filterTriggerWrap}>
<Pressable
onPress={handleFilterOpen}
style={filterTriggerStyle}
testID={triggerTestID}
accessibilityRole="button"
accessibilityLabel={`Filter: ${selectedHostLabel}`}
>
{selectedHost === ALL_HOSTS_OPTION_ID ? (
<ThemedServer size={14} uniProps={mutedColorMapping} />
) : (
<HostStatusDotSlot serverId={selectedHost} />
)}
<Text style={styles.filterTriggerText} numberOfLines={1}>
{selectedHostLabel}
</Text>
<ThemedChevronDown size={14} uniProps={mutedColorMapping} />
</Pressable>
</View>
</HostPicker>
);
}
const styles = StyleSheet.create((theme) => ({
filterTriggerWrap: {
alignSelf: "flex-start",
},
filterTrigger: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1.5],
alignSelf: "flex-start",
paddingVertical: theme.spacing[1.5],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface1,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
},
filterTriggerHovered: {
backgroundColor: theme.colors.surface2,
},
filterTriggerPressed: {
backgroundColor: theme.colors.surface3,
},
filterTriggerText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
},
}));
@@ -0,0 +1,20 @@
import Svg, { Path } from "react-native-svg";
interface OpenRouterIconProps {
size?: number;
color?: string;
}
export function OpenRouterIcon({ size = 16, color = "currentColor" }: OpenRouterIconProps) {
return (
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<Path
d="M3 7H15.5L21 12L15.5 17H3M10 3V21M15 7V17"
stroke={color}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
);
}
@@ -0,0 +1,212 @@
import { describe, expect, it } from "vitest";
import type {
PaseoAgentCatalogEntry,
RedactedPaseoAgentProviderConfig,
} from "@getpaseo/protocol/messages";
import {
createPaseoAgentProviderInput,
getPaseoAgentApiKeyAuth,
getPaseoAgentOAuthAuth,
isPaseoAgentCatalogEntrySupported,
nextPaseoAgentProviderName,
parsePaseoAgentModelIds,
paseoAgentAuthBadge,
paseoAgentProviderLabel,
preferredPaseoAgentOAuthMode,
} from "./paseo-agent-settings-sheet-model";
function catalogEntry(overrides: Partial<PaseoAgentCatalogEntry>): PaseoAgentCatalogEntry {
return {
id: "catalog-alpha",
label: "Catalog Alpha",
api: "responses",
baseUrl: "https://alpha.example.test",
auth: { kind: "api_key", envVar: "ALPHA_API_KEY" },
models: [{ id: "alpha-fast", label: "Alpha Fast", reasoning: true }],
...overrides,
};
}
function providerConfig(
overrides: Partial<RedactedPaseoAgentProviderConfig>,
): RedactedPaseoAgentProviderConfig {
return {
name: "catalog-alpha",
providerType: "catalog-alpha",
models: [{ id: "alpha-fast" }],
available: true,
...overrides,
};
}
describe("paseo-agent-settings-sheet-model", () => {
it("recognizes supported catalog auth manifests", () => {
const apiKeyEntry = catalogEntry({
auth: {
kind: "api_key",
envVar: "ALPHA_API_KEY",
keyUrl: "https://alpha.example.test/key",
placeholder: "alpha-key",
hint: "Paste the key from Alpha",
},
});
const oauthEntry = catalogEntry({
auth: { kind: "oauth", flow: "alpha-oauth" },
});
expect(getPaseoAgentApiKeyAuth(apiKeyEntry)).toEqual({
kind: "api_key",
envVar: "ALPHA_API_KEY",
keyUrl: "https://alpha.example.test/key",
placeholder: "alpha-key",
hint: "Paste the key from Alpha",
});
expect(getPaseoAgentOAuthAuth(oauthEntry)).toEqual({
kind: "oauth",
flow: "alpha-oauth",
});
expect(isPaseoAgentCatalogEntrySupported(apiKeyEntry)).toBe(true);
expect(isPaseoAgentCatalogEntrySupported(oauthEntry)).toBe(true);
});
it("keeps unknown catalog auth kinds visible but unsupported", () => {
const entry = catalogEntry({ auth: { kind: "future_auth", prompt: "later" } });
expect(getPaseoAgentApiKeyAuth(entry)).toBeNull();
expect(getPaseoAgentOAuthAuth(entry)).toBeNull();
expect(isPaseoAgentCatalogEntrySupported(entry)).toBe(false);
});
it("prefers device-code OAuth only for a relay connection", () => {
expect(preferredPaseoAgentOAuthMode({ type: "relay" })).toBe("device_code");
expect(preferredPaseoAgentOAuthMode({ type: "directSocket" })).toBe("browser");
expect(preferredPaseoAgentOAuthMode({ type: "directPipe" })).toBe("browser");
expect(preferredPaseoAgentOAuthMode({ type: "directTcp" })).toBe("browser");
expect(preferredPaseoAgentOAuthMode(null)).toBe("browser");
});
it("parses model ids from comma and newline separated input", () => {
expect(
parsePaseoAgentModelIds(`
alpha/fast, beta/steady
alpha/fast
gamma/deep
`),
).toEqual(["alpha/fast", "beta/steady", "gamma/deep"]);
});
it("uses the catalog label for the first provider instance", () => {
expect(nextPaseoAgentProviderName(catalogEntry({ label: "Acme Cloud" }), [])).toBe(
"Acme Cloud",
);
});
it("numbers later provider instances from the catalog label", () => {
expect(
nextPaseoAgentProviderName(catalogEntry({ label: "Acme Cloud" }), [
providerConfig({ name: "Acme Cloud" }),
providerConfig({ name: "Acme Cloud (1)" }),
]),
).toBe("Acme Cloud (2)");
});
it("builds a generic provider payload with a trimmed explicit key", () => {
expect(
createPaseoAgentProviderInput({
entry: catalogEntry({ id: "catalog-beta" }),
name: " beta-main ",
apiKey: " beta-secret ",
}),
).toEqual({
name: "beta-main",
providerType: "catalog-beta",
options: {
apiKey: "beta-secret",
models: [{ id: "alpha-fast", label: "Alpha Fast", reasoning: true }],
},
});
});
it("builds a generic provider payload with custom model ids", () => {
expect(
createPaseoAgentProviderInput({
entry: catalogEntry({ models: [] }),
name: "alpha-main",
apiKey: "alpha-secret",
modelIds: ["alpha/fast", "beta/steady"],
}),
).toEqual({
name: "alpha-main",
providerType: "catalog-alpha",
options: {
apiKey: "alpha-secret",
models: [{ id: "alpha/fast" }, { id: "beta/steady" }],
},
});
});
it("omits model overrides when the catalog has no defaults", () => {
expect(
createPaseoAgentProviderInput({
entry: catalogEntry({ models: [] }),
name: "alpha-main",
apiKey: "alpha-secret",
}),
).toEqual({
name: "alpha-main",
providerType: "catalog-alpha",
options: {
apiKey: "alpha-secret",
},
});
});
it("builds a generic provider payload with an env reference for an empty key", () => {
expect(
createPaseoAgentProviderInput({
entry: catalogEntry({ auth: { kind: "api_key", envVar: "BETA_API_KEY" } }),
name: "beta-main",
apiKey: " ",
}),
).toEqual({
name: "beta-main",
providerType: "catalog-alpha",
options: {
apiKey: "$BETA_API_KEY",
models: [{ id: "alpha-fast", label: "Alpha Fast", reasoning: true }],
},
});
});
it("builds an oauth provider payload without key material", () => {
expect(
createPaseoAgentProviderInput({
entry: catalogEntry({ auth: { kind: "oauth", flow: "alpha-oauth" } }),
name: "alpha-login",
}),
).toEqual({
name: "alpha-login",
providerType: "catalog-alpha",
options: {
models: [{ id: "alpha-fast", label: "Alpha Fast", reasoning: true }],
},
});
});
it("uses catalog labels and generic auth badges for instance rows", () => {
expect(paseoAgentProviderLabel(providerConfig({}), catalogEntry({}))).toBe("Catalog Alpha");
expect(
paseoAgentProviderLabel(providerConfig({ providerType: "custom-alpha" }), undefined),
).toBe("custom-alpha");
expect(paseoAgentAuthBadge({ kind: "api_key", configured: true })).toEqual({
label: "Connected",
variant: "success",
});
expect(paseoAgentAuthBadge({ kind: "oauth", configured: false })).toEqual({
label: "Needs attention",
variant: "error",
});
expect(paseoAgentAuthBadge(undefined)).toBeNull();
});
});
@@ -0,0 +1,139 @@
import type {
PaseoAgentCatalogEntry,
RedactedPaseoAgentProviderConfig,
} from "@getpaseo/protocol/messages";
import type { PaseoAgentSetProviderInput } from "@/hooks/use-paseo-agent-providers";
export interface PaseoAgentApiKeyAuthManifest {
kind: "api_key";
envVar: string;
keyUrl?: string;
placeholder?: string;
hint?: string;
}
export interface PaseoAgentOAuthAuthManifest {
kind: "oauth";
flow: string;
}
export interface PaseoAgentAuthBadge {
label: string;
variant: "success" | "error" | "muted";
}
export type PaseoAgentOAuthMode = "browser" | "device_code";
export interface PaseoAgentOAuthConnectionSignal {
type: string;
}
export function preferredPaseoAgentOAuthMode(
activeConnection: PaseoAgentOAuthConnectionSignal | null,
): PaseoAgentOAuthMode {
return activeConnection?.type === "relay" ? "device_code" : "browser";
}
export function getPaseoAgentApiKeyAuth(
entry: PaseoAgentCatalogEntry,
): PaseoAgentApiKeyAuthManifest | null {
if (entry.auth.kind !== "api_key" || typeof entry.auth.envVar !== "string") {
return null;
}
return {
kind: "api_key",
envVar: entry.auth.envVar,
...(typeof entry.auth.keyUrl === "string" ? { keyUrl: entry.auth.keyUrl } : {}),
...(typeof entry.auth.placeholder === "string" ? { placeholder: entry.auth.placeholder } : {}),
...(typeof entry.auth.hint === "string" ? { hint: entry.auth.hint } : {}),
};
}
export function getPaseoAgentOAuthAuth(
entry: PaseoAgentCatalogEntry,
): PaseoAgentOAuthAuthManifest | null {
if (entry.auth.kind !== "oauth" || typeof entry.auth.flow !== "string") {
return null;
}
return { kind: "oauth", flow: entry.auth.flow };
}
export function isPaseoAgentCatalogEntrySupported(entry: PaseoAgentCatalogEntry): boolean {
return getPaseoAgentApiKeyAuth(entry) !== null || getPaseoAgentOAuthAuth(entry) !== null;
}
export function paseoAgentProviderLabel(
provider: RedactedPaseoAgentProviderConfig,
catalogEntry: PaseoAgentCatalogEntry | undefined,
): string {
return catalogEntry?.label ?? provider.providerType;
}
export function paseoAgentAuthBadge(
auth: RedactedPaseoAgentProviderConfig["auth"],
): PaseoAgentAuthBadge | null {
if (!auth || auth.kind === "none") {
return null;
}
return auth.configured
? { label: "Connected", variant: "success" }
: { label: "Needs attention", variant: "error" };
}
export function parsePaseoAgentModelIds(raw: string): string[] {
const seen = new Set<string>();
const ids: string[] = [];
for (const part of raw.split(/[\n,]/)) {
const id = part.trim();
if (id.length > 0 && !seen.has(id)) {
seen.add(id);
ids.push(id);
}
}
return ids;
}
export function nextPaseoAgentProviderName(
entry: PaseoAgentCatalogEntry,
providers: RedactedPaseoAgentProviderConfig[],
): string {
const baseName = entry.label.trim() || entry.id;
const existingNames = new Set(providers.map((provider) => provider.name));
if (!existingNames.has(baseName)) {
return baseName;
}
for (let index = 1; ; index += 1) {
const candidate = `${baseName} (${index})`;
if (!existingNames.has(candidate)) {
return candidate;
}
}
}
export function createPaseoAgentProviderInput(input: {
entry: PaseoAgentCatalogEntry;
name: string;
apiKey?: string;
modelIds?: string[];
}): PaseoAgentSetProviderInput {
const apiKeyAuth = getPaseoAgentApiKeyAuth(input.entry);
const trimmedKey = input.apiKey?.trim() ?? "";
let apiKey: string | undefined;
if (apiKeyAuth) {
apiKey = trimmedKey.length > 0 ? trimmedKey : `$${apiKeyAuth.envVar}`;
}
const models =
input.modelIds && input.modelIds.length > 0
? input.modelIds.map((id) => ({ id }))
: input.entry.models.map((model) => ({ ...model }));
return {
name: input.name.trim(),
providerType: input.entry.id,
options: {
...(models.length > 0 ? { models } : {}),
...(apiKey ? { apiKey } : {}),
},
};
}
@@ -0,0 +1,571 @@
/**
* @vitest-environment jsdom
*/
import React, { type ReactNode } from "react";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type {
PaseoAgentCatalogEntry,
RedactedPaseoAgentProviderConfig,
} from "@getpaseo/protocol/messages";
import type {
PaseoAgentOAuthCompleteResult,
PaseoAgentOAuthStartResult,
PaseoAgentSetProviderInput,
} from "@/hooks/use-paseo-agent-providers";
interface PaseoAgentProvidersHookMock {
supported: boolean;
catalogSupported: boolean;
providers: RedactedPaseoAgentProviderConfig[];
catalog: PaseoAgentCatalogEntry[];
defaultModel: string | null;
isLoading: boolean;
isCatalogLoading: boolean;
error: string | null;
catalogError: string | null;
refresh: () => Promise<void>;
setProvider: (
input: PaseoAgentSetProviderInput,
) => Promise<RedactedPaseoAgentProviderConfig | null>;
startOAuth: (name: string, mode?: string) => Promise<PaseoAgentOAuthStartResult>;
completeOAuth: (name: string) => Promise<PaseoAgentOAuthCompleteResult>;
}
const { hookState, hostRuntimeSnapshot, openExternalUrls, theme } = vi.hoisted(() => {
const initialHookState: { current: PaseoAgentProvidersHookMock } = {
current: {
supported: true,
catalogSupported: true,
providers: [] as RedactedPaseoAgentProviderConfig[],
catalog: [] as PaseoAgentCatalogEntry[],
defaultModel: null as string | null,
isLoading: false,
isCatalogLoading: false,
error: null as string | null,
catalogError: null as string | null,
refresh: vi.fn(async () => undefined),
setProvider: vi.fn(async () => null as RedactedPaseoAgentProviderConfig | null),
startOAuth: vi.fn(
async (): Promise<PaseoAgentOAuthStartResult> => ({
requestId: "oauth-start",
success: true,
name: "catalog-login",
authorization: null,
error: null,
}),
),
completeOAuth: vi.fn(
async (): Promise<PaseoAgentOAuthCompleteResult> => ({
requestId: "oauth-complete",
success: true,
name: "catalog-login",
auth: { kind: "oauth", configured: true },
error: null,
}),
),
},
};
return {
hookState: initialHookState,
hostRuntimeSnapshot: {
current: {
activeConnection: { type: "directSocket", endpoint: "socket", display: "socket" },
} as {
activeConnection: { type: string; endpoint: string; display: string } | null;
} | null,
},
openExternalUrls: [] as string[],
theme: {
spacing: { 1: 4, 2: 8, 3: 12, 4: 16 },
borderRadius: { md: 6, lg: 8 },
fontSize: { xs: 11, sm: 13, base: 15 },
fontWeight: { normal: "400", medium: "500" },
opacity: { 50: 0.5 },
colors: {
foreground: "#fff",
foregroundMuted: "#aaa",
surface1: "#111",
surface2: "#222",
border: "#444",
destructive: "#f00",
statusSuccess: "#0f0",
},
},
};
});
vi.mock("react-native", () => ({
View: ({
children,
testID,
role,
accessibilityRole,
accessibilityLabel,
}: {
children?: ReactNode;
testID?: string;
role?: string;
accessibilityRole?: string;
accessibilityLabel?: string;
}) =>
React.createElement(
"div",
{
"data-testid": testID,
role: role ?? accessibilityRole,
"aria-label": accessibilityLabel,
},
children,
),
Text: ({ children, testID }: { children?: ReactNode; testID?: string; numberOfLines?: number }) =>
React.createElement("span", { "data-testid": testID }, children),
}));
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) =>
typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory,
},
withUnistyles:
(Component: React.ComponentType<Record<string, unknown>>) =>
({
uniProps,
...rest
}: {
uniProps?: (theme: unknown) => Record<string, unknown>;
} & Record<string, unknown>) => {
const themed = uniProps ? uniProps(theme) : {};
return React.createElement(Component, { ...rest, ...themed });
},
}));
vi.mock("lucide-react-native", () => {
const icon = (name: string) => {
const Icon = () => React.createElement("span", { "data-icon": name });
Icon.displayName = name;
return Icon;
};
return {
Bot: icon("Bot"),
Plus: icon("Plus"),
};
});
vi.mock("@/components/adaptive-modal-sheet", () => ({
AdaptiveModalSheet: ({
visible,
header,
children,
footer,
testID,
}: {
visible: boolean;
header?: { title: string; back?: { label?: string; onPress: () => void } };
children: ReactNode;
footer?: ReactNode;
testID?: string;
}) =>
visible ? (
<section data-testid={testID}>
<h1>{header?.title}</h1>
{header?.back ? (
<button type="button" onClick={header.back.onPress}>
{header.back.label ?? "Back"}
</button>
) : null}
{children}
{footer ? <footer>{footer}</footer> : null}
</section>
) : null,
AdaptiveTextInput: ({
value,
onChangeText,
accessibilityLabel,
testID,
placeholder,
secureTextEntry,
editable = true,
multiline,
}: {
value?: string;
onChangeText?: (value: string) => void;
accessibilityLabel?: string;
testID?: string;
placeholder?: string;
secureTextEntry?: boolean;
editable?: boolean;
multiline?: boolean;
}) => {
function handleChange(event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>): void {
onChangeText?.(event.currentTarget.value);
}
const inputProps = {
"aria-label": accessibilityLabel,
"data-testid": testID,
disabled: !editable,
onChange: handleChange,
placeholder,
value: value ?? "",
};
if (multiline) {
return React.createElement("textarea", inputProps);
}
return React.createElement("input", {
...inputProps,
type: secureTextEntry ? "password" : "text",
});
},
}));
vi.mock("@/components/provider-icons", () => ({
getProviderIcon: (provider: string) => () =>
React.createElement("span", { "data-icon": `provider-${provider}` }),
}));
vi.mock("@/components/provider-icon-name", () => ({
resolveProviderIconName: (provider: string) =>
provider === "known-icon" ? { kind: "builtin", id: provider } : { kind: "bot" },
}));
vi.mock("@/components/ui/external-link", () => ({
ExternalLink: ({
href,
label,
testID,
accessibilityLabel,
}: {
href: string;
label: string;
testID?: string;
accessibilityLabel?: string;
}) => (
<a href={href} data-testid={testID} aria-label={accessibilityLabel ?? label}>
{label}
</a>
),
}));
vi.mock("@/components/ui/button", () => ({
Button: ({
children,
disabled,
onPress,
testID,
}: {
children?: ReactNode;
disabled?: boolean;
onPress?: () => void;
testID?: string;
}) => (
<button type="button" disabled={disabled} data-testid={testID} onClick={onPress}>
{children}
</button>
),
}));
vi.mock("@/components/ui/status-badge", () => ({
StatusBadge: ({ label }: { label: string }) => <span>{label}</span>,
}));
vi.mock("@/hooks/use-paseo-agent-providers", () => ({
usePaseoAgentProviders: () => hookState.current,
}));
vi.mock("@/runtime/host-runtime", () => ({
useHostRuntimeSnapshot: () => hostRuntimeSnapshot.current,
}));
vi.mock("@/utils/open-external-url", () => ({
openExternalUrl: async (url: string) => {
openExternalUrls.push(url);
},
}));
import { PaseoAgentSettingsSheet } from "./paseo-agent-settings-sheet";
function catalogEntry(overrides: Partial<PaseoAgentCatalogEntry>): PaseoAgentCatalogEntry {
return {
id: "catalog-alpha",
label: "Catalog Alpha",
iconName: "known-icon",
docsUrl: "https://alpha.example.test/docs",
api: "responses",
baseUrl: "https://alpha.example.test",
auth: { kind: "api_key", envVar: "ALPHA_API_KEY", keyUrl: "https://alpha.example.test/key" },
models: [{ id: "alpha-fast", label: "Alpha Fast" }],
...overrides,
};
}
function providerConfig(
overrides: Partial<RedactedPaseoAgentProviderConfig>,
): RedactedPaseoAgentProviderConfig {
return {
name: "catalog-alpha",
providerType: "catalog-alpha",
models: [{ id: "alpha-fast" }],
auth: { kind: "api_key", configured: true },
available: true,
...overrides,
};
}
function renderSheet() {
return render(<PaseoAgentSettingsSheet serverId="server-1" visible onClose={vi.fn()} />);
}
function resetHookState() {
hookState.current.supported = true;
hookState.current.catalogSupported = true;
hookState.current.providers = [];
hookState.current.catalog = [];
hookState.current.defaultModel = null;
hookState.current.isLoading = false;
hookState.current.isCatalogLoading = false;
hookState.current.error = null;
hookState.current.catalogError = null;
hookState.current.refresh = vi.fn(async () => undefined);
hookState.current.setProvider = vi.fn(async () => providerConfig({}));
hookState.current.startOAuth = vi.fn(async () => ({
requestId: "oauth-start",
success: true,
name: "catalog-login",
authorization: null,
error: null,
}));
hookState.current.completeOAuth = vi.fn(async () => ({
requestId: "oauth-complete",
success: true,
name: "catalog-login",
auth: { kind: "oauth", configured: true },
error: null,
}));
hostRuntimeSnapshot.current = {
activeConnection: { type: "directSocket", endpoint: "socket", display: "socket" },
};
openExternalUrls.splice(0);
}
describe("PaseoAgentSettingsSheet", () => {
beforeEach(() => {
resetHookState();
});
afterEach(() => {
cleanup();
});
it("shows the catalog feature-gate message exactly once", () => {
hookState.current.catalogSupported = false;
renderSheet();
expect(screen.getAllByText("Update the Paseo daemon to use this.")).toHaveLength(1);
expect(screen.queryByRole("button", { name: "Add model provider" })).toBeNull();
});
it("lists catalog entries in the provider picker and disables unknown auth kinds", () => {
hookState.current.catalog = [
catalogEntry({ label: "Alpha Provider" }),
catalogEntry({
id: "catalog-future",
label: "Future Provider",
auth: { kind: "future_auth", prompt: "not yet" },
}),
];
renderSheet();
fireEvent.click(screen.getByRole("button", { name: "Add model provider" }));
expect(screen.getByText("Alpha Provider")).toBeTruthy();
expect(screen.getByText("Future Provider")).toBeTruthy();
expect(screen.getByTestId("paseo-agent-catalog-docs-catalog-alpha").getAttribute("href")).toBe(
"https://alpha.example.test/docs",
);
expect(screen.getByText("Update the app to use this provider")).toBeTruthy();
expect(
(screen.getByTestId("paseo-agent-catalog-select-catalog-future") as HTMLButtonElement)
.disabled,
).toBe(true);
});
it("submits an api-key provider with an explicit key", async () => {
hookState.current.catalog = [catalogEntry({ models: [] })];
renderSheet();
fireEvent.click(screen.getByRole("button", { name: "Add model provider" }));
fireEvent.click(screen.getByTestId("paseo-agent-catalog-select-catalog-alpha"));
expect(screen.queryByLabelText("Provider name")).toBeNull();
expect(screen.queryByLabelText("Models")).toBeNull();
fireEvent.change(screen.getByLabelText("API key"), {
target: { value: " alpha-secret " },
});
fireEvent.click(screen.getByRole("button", { name: "Save provider" }));
await waitFor(() => {
expect(hookState.current.setProvider).toHaveBeenCalledWith({
name: "Catalog Alpha",
providerType: "catalog-alpha",
options: {
apiKey: "alpha-secret",
},
} satisfies PaseoAgentSetProviderInput);
});
});
it("submits an empty api-key field as a host env reference", async () => {
hookState.current.catalog = [catalogEntry({})];
renderSheet();
fireEvent.click(screen.getByRole("button", { name: "Add model provider" }));
fireEvent.click(screen.getByTestId("paseo-agent-catalog-select-catalog-alpha"));
fireEvent.click(screen.getByRole("button", { name: "Save provider" }));
await waitFor(() => {
expect(hookState.current.setProvider).toHaveBeenCalledWith({
name: "Catalog Alpha",
providerType: "catalog-alpha",
options: {
apiKey: "$ALPHA_API_KEY",
models: [{ id: "alpha-fast", label: "Alpha Fast" }],
},
} satisfies PaseoAgentSetProviderInput);
});
});
it("starts browser oauth, opens the returned auth URL, and renders it by authorization kind", async () => {
hookState.current.catalog = [
catalogEntry({
id: "catalog-login",
label: "Catalog Login",
auth: { kind: "oauth", flow: "login-flow" },
}),
];
hookState.current.startOAuth = vi.fn(async () => ({
requestId: "oauth-start",
success: true,
name: "catalog-login",
authorization: {
kind: "auth_url",
url: "https://login.example.test/oauth/authorize?state=abc",
instructions: "Open the sign-in page",
},
error: null,
}));
renderSheet();
fireEvent.click(screen.getByRole("button", { name: "Add model provider" }));
fireEvent.click(screen.getByTestId("paseo-agent-catalog-select-catalog-login"));
fireEvent.click(screen.getByRole("button", { name: "Sign in with browser" }));
await waitFor(() => {
expect(openExternalUrls).toEqual(["https://login.example.test/oauth/authorize?state=abc"]);
expect(hookState.current.startOAuth).toHaveBeenCalledWith("Catalog Login", "browser");
});
expect(screen.getByText("https://login.example.test/oauth/authorize?state=abc")).toBeTruthy();
expect(screen.getByTestId("paseo-agent-oauth-url").getAttribute("href")).toBe(
"https://login.example.test/oauth/authorize?state=abc",
);
});
it("renders a device-code oauth authorization and completes it", async () => {
hookState.current.catalog = [
catalogEntry({
id: "catalog-login",
label: "Catalog Login",
auth: { kind: "oauth", flow: "login-flow" },
}),
];
hookState.current.startOAuth = vi.fn(async () => ({
requestId: "oauth-start",
success: true,
name: "catalog-login",
authorization: {
kind: "device_code",
userCode: "CODE-123",
verificationUri: "https://login.example.test/device",
intervalSeconds: 5,
expiresInSeconds: 600,
instructions: "Enter the code",
},
error: null,
}));
renderSheet();
fireEvent.click(screen.getByRole("button", { name: "Add model provider" }));
fireEvent.click(screen.getByTestId("paseo-agent-catalog-select-catalog-login"));
fireEvent.click(screen.getByRole("button", { name: "Use a code instead" }));
expect((await screen.findByTestId("paseo-agent-oauth-user-code")).textContent).toBe("CODE-123");
expect(screen.getByText("https://login.example.test/device")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Complete sign in" }));
await waitFor(() => {
expect(hookState.current.setProvider).toHaveBeenCalledWith({
name: "Catalog Login",
providerType: "catalog-login",
options: {
models: [{ id: "alpha-fast", label: "Alpha Fast" }],
},
} satisfies PaseoAgentSetProviderInput);
expect(hookState.current.startOAuth).toHaveBeenCalledWith("Catalog Login", "device_code");
expect(hookState.current.completeOAuth).toHaveBeenCalledWith("Catalog Login");
});
});
it("offers device-code first over relay while keeping browser available", () => {
hostRuntimeSnapshot.current = {
activeConnection: { type: "relay", endpoint: "relay.example.test:443", display: "relay" },
};
hookState.current.catalog = [
catalogEntry({
id: "catalog-login",
label: "Catalog Login",
auth: { kind: "oauth", flow: "login-flow" },
}),
];
renderSheet();
fireEvent.click(screen.getByRole("button", { name: "Add model provider" }));
fireEvent.click(screen.getByTestId("paseo-agent-catalog-select-catalog-login"));
const actionButtons = screen
.getByTestId("paseo-agent-oauth-sign-in")
.querySelectorAll("button");
expect(Array.from(actionButtons).map((button) => button.textContent)).toEqual([
"Providers",
"Cancel",
"Use a code instead",
"Sign in with browser",
]);
});
it("shows auth-state badges from configured instances", () => {
hookState.current.catalog = [
catalogEntry({ id: "catalog-alpha", label: "Catalog Alpha" }),
catalogEntry({ id: "catalog-beta", label: "Catalog Beta" }),
];
hookState.current.providers = [
providerConfig({ name: "alpha", providerType: "catalog-alpha" }),
providerConfig({
name: "beta",
providerType: "catalog-beta",
auth: { kind: "api_key", configured: false },
}),
providerConfig({ name: "legacy", providerType: "legacy-provider", auth: undefined }),
];
renderSheet();
expect(screen.getByText("Catalog Alpha · 1 model")).toBeTruthy();
expect(screen.getByText("Catalog Beta · 1 model")).toBeTruthy();
expect(screen.getByText("legacy-provider · 1 model")).toBeTruthy();
expect(screen.getByText("Connected")).toBeTruthy();
expect(screen.getByText("Needs attention")).toBeTruthy();
});
});
File diff suppressed because it is too large Load Diff
@@ -13,6 +13,17 @@ describe("resolveProviderIconName", () => {
expect(resolveProviderIconName("claude")).toEqual({ kind: "builtin", id: "claude" });
expect(resolveProviderIconName("omp")).toEqual({ kind: "builtin", id: "omp" });
expect(resolveProviderIconName("minimax")).toEqual({ kind: "builtin", id: "minimax" });
expect(resolveProviderIconName("paseo")).toEqual({ kind: "builtin", id: "paseo" });
});
it("resolves Paseo Agent model-provider catalog icon names", () => {
expect(resolveProviderIconName("openai")).toEqual({ kind: "builtin", id: "openai" });
expect(resolveProviderIconName("openrouter")).toEqual({
kind: "builtin",
id: "openrouter",
});
expect(resolveProviderIconName("kimi")).toEqual({ kind: "catalog", id: "kimi" });
expect(resolveProviderIconName("opencode")).toEqual({ kind: "builtin", id: "opencode" });
});
it("returns the catalog identifier for ACP catalog provider ids that ship an icon", () => {
@@ -6,7 +6,9 @@ import { CodexIcon } from "@/components/icons/codex-icon";
import { CopilotIcon } from "@/components/icons/copilot-icon";
import { MiniMaxIcon } from "@/components/icons/minimax-icon";
import { OpenCodeIcon } from "@/components/icons/opencode-icon";
import { OpenRouterIcon } from "@/components/icons/openrouter-icon";
import { OmpIcon } from "@/components/icons/omp-icon";
import { PaseoLogo } from "@/components/icons/paseo-logo";
import { PiIcon } from "@/components/icons/pi-icon";
import { ACP_PROVIDER_CATALOG } from "@/data/acp-provider-catalog";
import { resolveProviderIconName } from "@/components/provider-icon-name";
@@ -25,7 +27,10 @@ const BUILTIN_PROVIDER_ICONS: Record<string, ProviderIconComponent> = {
kiro: PackagePlus,
minimax: MiniMaxIcon as unknown as ProviderIconComponent,
omp: OmpIcon as unknown as ProviderIconComponent,
openai: CodexIcon as unknown as ProviderIconComponent,
opencode: OpenCodeIcon as unknown as ProviderIconComponent,
openrouter: OpenRouterIcon as unknown as ProviderIconComponent,
paseo: PaseoLogo as unknown as ProviderIconComponent,
pi: PiIcon as unknown as ProviderIconComponent,
};
@@ -1,7 +1,10 @@
import { useCallback } from "react";
import { PaseoAgentSettingsSheet } from "@/components/paseo-agent-settings-sheet";
import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet";
import { useProviderSettingsStore } from "@/stores/provider-settings-store";
const PASEO_AGENT_PROVIDER = "paseo";
export function ProviderSettingsHost() {
const serverId = useProviderSettingsStore((state) => state.serverId);
const provider = useProviderSettingsStore((state) => state.provider);
@@ -16,6 +19,10 @@ export function ProviderSettingsHost() {
return null;
}
if (provider === PASEO_AGENT_PROVIDER) {
return <PaseoAgentSettingsSheet serverId={serverId} visible={visible} onClose={handleClose} />;
}
return (
<ProviderDiagnosticSheet
key={`${serverId}:${provider}`}
@@ -4,6 +4,7 @@ import type { PressableStateCallbackType } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
import { SegmentedControl } from "@/components/ui/segmented-control";
import { isWeb } from "@/constants/platform";
import {
describeCron,
everyMsToParts,
@@ -199,6 +200,7 @@ export function CadenceEditor({ value, onChange, error }: CadenceEditorProps) {
value={intervalUnit}
onValueChange={handleUnitChange}
options={UNIT_OPTIONS}
style={styles.unitControl}
testID="cadence-interval-unit"
/>
</View>
@@ -281,6 +283,8 @@ function CronPresetChip({
);
}
const MONOSPACE_FONT = isWeb ? "ui-monospace, SFMono-Regular, Menlo, monospace" : "Menlo";
const styles = StyleSheet.create((theme) => ({
container: {
gap: theme.spacing[3],
@@ -304,10 +308,15 @@ const styles = StyleSheet.create((theme) => ({
borderColor: theme.colors.border,
fontSize: theme.fontSize.base,
},
// The mode toggle hugs its options at the left rather than stretching to a
// full-width track; the interval row then reads as input + toggle.
// Both cadence segmented controls hug their options and stand at the form's
// field height, so the interval row reads as input + toggle rather than a
// full-width track with the controls floating inside it.
modeControl: {
alignSelf: "flex-start",
height: 44,
},
unitControl: {
height: 44,
},
presetRow: {
flexDirection: "row",
@@ -328,11 +337,9 @@ const styles = StyleSheet.create((theme) => ({
chipHover: {
backgroundColor: theme.colors.surface3,
},
// Selected preset reads as a chosen surface, not a second accent fill
// competing with the sheet's primary CTA.
chipSelected: {
backgroundColor: theme.colors.surface3,
borderColor: theme.colors.borderAccent,
backgroundColor: theme.colors.accent,
borderColor: theme.colors.accent,
},
chipLabel: {
fontSize: theme.fontSize.xs,
@@ -340,7 +347,7 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
},
chipLabelSelected: {
color: theme.colors.foreground,
color: theme.colors.accentForeground,
},
cronInput: {
minHeight: 44,
@@ -351,7 +358,7 @@ const styles = StyleSheet.create((theme) => ({
borderWidth: 1,
borderColor: theme.colors.border,
fontSize: theme.fontSize.sm,
fontFamily: theme.fontFamily.mono,
fontFamily: MONOSPACE_FONT,
},
preview: {
fontSize: theme.fontSize.xs,
@@ -363,6 +370,6 @@ const styles = StyleSheet.create((theme) => ({
},
error: {
fontSize: theme.fontSize.xs,
color: theme.colors.palette.red[300],
color: theme.colors.destructive,
},
}));
@@ -24,13 +24,7 @@ import { getProviderIcon } from "@/components/provider-icons";
import { CadenceEditor } from "@/components/schedules/cadence-editor";
import { useScheduleMutations } from "@/hooks/use-schedule-mutations";
import { useAgentFormState, type FormInitialValues } from "@/hooks/use-agent-form-state";
import { useAggregatedAgents } from "@/hooks/use-aggregated-agents";
import { useProjects } from "@/hooks/use-projects";
import {
buildScheduleProjectTargets,
PROJECT_OPTION_PREFIX,
type ScheduleProjectTarget,
} from "@/schedules/schedule-project-targets";
import { validateCron } from "@/utils/schedule-format";
import { toErrorMessage } from "@/utils/error-messages";
import { shortenPath } from "@/utils/shorten-path";
@@ -38,6 +32,7 @@ import type { ProjectSummary } from "@/utils/projects";
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
const DEFAULT_CADENCE: ScheduleCadence = { type: "every", everyMs: 60 * 60 * 1000 };
const PROJECT_OPTION_PREFIX = "project:";
export interface ScheduleFormSheetProps {
serverId?: string;
@@ -47,6 +42,15 @@ export interface ScheduleFormSheetProps {
schedule?: ScheduleSummary;
}
interface ScheduleProjectTarget {
optionId: string;
serverId: string;
serverName: string;
projectKey: string;
projectName: string;
cwd: string;
}
interface ScheduleProjectOptions {
targets: ScheduleProjectTarget[];
options: ComboboxOption[];
@@ -75,19 +79,44 @@ function buildInitialValues(schedule: ScheduleSummary | undefined): FormInitialV
};
}
function buildProjectOptionId(serverId: string, projectKey: string): string {
return `${PROJECT_OPTION_PREFIX}${serverId}:${projectKey}`;
}
function buildProjectOptionTestId(optionId: string): string {
const targetKey = optionId.slice(PROJECT_OPTION_PREFIX.length).replace(/^[^:]+:/, "");
return `schedule-project-option-${targetKey}`;
}
function buildScheduleProjectOptions(projects: readonly ProjectSummary[]): ScheduleProjectOptions {
const targets = buildScheduleProjectTargets(projects);
const targetByOptionId = new Map(targets.map((target) => [target.optionId, target]));
const options: ComboboxOption[] = targets.map((target) => ({
id: target.optionId,
label: target.projectName,
description: `${target.serverName} - ${shortenPath(target.cwd)}`,
}));
const targets: ScheduleProjectTarget[] = [];
const targetByOptionId = new Map<string, ScheduleProjectTarget>();
const options: ComboboxOption[] = [];
for (const project of projects) {
for (const host of project.hosts) {
const cwd = host.repoRoot.trim();
if (!host.isOnline || !cwd) {
continue;
}
const target: ScheduleProjectTarget = {
optionId: buildProjectOptionId(host.serverId, project.projectKey),
serverId: host.serverId,
serverName: host.serverName,
projectKey: project.projectKey,
projectName: project.projectName,
cwd,
};
targets.push(target);
targetByOptionId.set(target.optionId, target);
options.push({
id: target.optionId,
label: target.projectName,
description: `${target.serverName} - ${shortenPath(cwd)}`,
});
}
}
return { targets, options, targetByOptionId };
}
@@ -124,35 +153,6 @@ function isSelectedModelValidForProviders(input: {
return provider.modelSelection.rows.some((row) => row.modelId === selectedModel);
}
function parseMaxRuns(raw: string): number | null {
const parsed = Number.parseInt(raw, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
function canSubmitScheduleForm(input: {
isAgentTarget: boolean;
isEdit: boolean;
promptTrimmed: string;
cadenceError: string | null;
isSubmitting: boolean;
selectedModelIsValid: boolean;
hasWorkingDir: boolean;
hasSelectedProject: boolean;
}): boolean {
if (input.promptTrimmed.length === 0 || input.cadenceError !== null || input.isSubmitting) {
return false;
}
// Agent targets only edit name/prompt/cadence. New-agent edit accepts any
// non-empty stored cwd; create requires a matched project.
if (input.isAgentTarget) {
return true;
}
if (!input.selectedModelIsValid) {
return false;
}
return input.isEdit ? input.hasWorkingDir : input.hasSelectedProject;
}
export function ScheduleFormSheet({
serverId,
visible,
@@ -161,26 +161,10 @@ export function ScheduleFormSheet({
schedule,
}: ScheduleFormSheetProps): ReactElement {
const isEdit = mode === "edit";
// Agent-targeted schedules can only update name/prompt/cadence/maxRuns
// (service.ts rejects newAgentConfig for them), so the form drops the
// project/model/mode pickers and shows the target agent read-only instead.
const isAgentTarget = isEdit && schedule?.target.type === "agent";
const editConfig = newAgentConfig(schedule);
const { projects } = useProjects();
const { agents } = useAggregatedAgents({ includeArchived: true });
const projectOptions = useMemo(() => buildScheduleProjectOptions(projects), [projects]);
const agentTargetLabel = useMemo(() => {
if (!schedule || schedule.target.type !== "agent") {
return null;
}
const { agentId } = schedule.target;
const agent = agents.find((entry) => entry.serverId === serverId && entry.id === agentId);
if (!agent) {
return "Agent unavailable";
}
return agent.title?.trim() || "Untitled agent";
}, [agents, schedule, serverId]);
const onlineServerIds = useMemo(
() => Array.from(new Set(projectOptions.targets.map((target) => target.serverId))),
[projectOptions.targets],
@@ -213,9 +197,7 @@ export function ScheduleFormSheet({
setProviderAndModelFromUser,
clearProviderSelectionFromUser,
setModeFromUser,
setSelectedServerId,
setSelectedServerIdFromUser,
setWorkingDir,
setWorkingDirFromUser,
modeOptions,
modelSelectorProviders,
@@ -237,10 +219,7 @@ export function ScheduleFormSheet({
const handleSelectProject = useCallback(
(target: ScheduleProjectTarget) => {
// Compare against the current server, not the matched target: an unmatched
// stored cwd has no target but still lives on a host, and switching hosts
// must still clear a provider/model that may not exist on the new one.
if (selectedServerId && selectedServerId !== target.serverId) {
if (selectedProjectTarget && selectedProjectTarget.serverId !== target.serverId) {
clearProviderSelectionFromUser();
}
setSelectedServerIdFromUser(target.serverId);
@@ -248,7 +227,7 @@ export function ScheduleFormSheet({
},
[
clearProviderSelectionFromUser,
selectedServerId,
selectedProjectTarget,
setSelectedServerIdFromUser,
setWorkingDirFromUser,
],
@@ -314,127 +293,90 @@ export function ScheduleFormSheet({
setCadence(schedule?.cadence ?? DEFAULT_CADENCE);
setSubmitError(null);
setFieldResetKey((key) => key + 1);
// The sheet stays mounted, and the form reducer's reset-on-close only
// clears user-modified flags — not the picker values — so a create opened
// after an edit would inherit that schedule's server/cwd (including a
// stale ghost path). Clear them so create always starts fresh; provider
// and model re-resolve from preferences.
if (!isEdit) {
setSelectedServerId(null);
setWorkingDir("");
}
}
wasVisibleRef.current = visible;
}, [visible, schedule, isEdit, setSelectedServerId, setWorkingDir]);
}, [visible, schedule]);
const promptTrimmed = prompt.trim();
const trimmedWorkingDir = workingDir.trim();
const cadenceError = cadence.type === "cron" ? validateCron(cadence.expression) : null;
const selectedModelIsValid = isSelectedModelValidForProviders({
providers: modelSelectorProviders,
selectedProvider,
selectedModel,
});
const canSubmit = canSubmitScheduleForm({
isAgentTarget,
isEdit,
promptTrimmed,
cadenceError,
isSubmitting,
selectedModelIsValid,
hasWorkingDir: trimmedWorkingDir.length > 0,
hasSelectedProject: Boolean(selectedProjectTarget),
});
const canSubmit =
promptTrimmed.length > 0 &&
selectedModelIsValid &&
Boolean(selectedProjectTarget) &&
cadenceError === null &&
!isSubmitting;
// Agent target: the update RPC only accepts name/prompt/cadence/maxRuns.
const submitAgentTarget = useCallback(async (): Promise<boolean> => {
if (!schedule) {
return false;
const handleSubmit = useCallback(async () => {
if (!selectedProvider || !selectedProjectTarget || !promptTrimmed) {
return;
}
await updateSchedule({
id: schedule.id,
name: name.trim() || null,
prompt: promptTrimmed,
cadence,
maxRuns: parseMaxRuns(maxRuns),
});
return true;
}, [cadence, maxRuns, name, promptTrimmed, schedule, updateSchedule]);
setSubmitError(null);
try {
await persistFormPreferences();
const parsedMaxRuns = Number.parseInt(maxRuns, 10);
const maxRunsValue =
Number.isFinite(parsedMaxRuns) && parsedMaxRuns > 0 ? parsedMaxRuns : null;
// New-agent target: submit the current working directory. On edit an untouched
// picker leaves this as the stored cwd, so it round-trips unchanged.
const submitNewAgent = useCallback(async (): Promise<boolean> => {
if (!selectedProvider || !trimmedWorkingDir) {
return false;
if (isEdit && schedule) {
await updateSchedule({
id: schedule.id,
name: name.trim() || null,
prompt: promptTrimmed,
cadence,
newAgentConfig: {
provider: selectedProvider,
model: selectedModel || null,
modeId: selectedMode || null,
cwd: selectedProjectTarget.cwd,
},
maxRuns: maxRunsValue,
});
} else {
await createSchedule({
prompt: promptTrimmed,
name: name.trim() || undefined,
cadence,
target: {
type: "new-agent",
config: {
provider: selectedProvider,
cwd: selectedProjectTarget.cwd,
model: selectedModel || undefined,
modeId: selectedMode || undefined,
thinkingOptionId: selectedThinkingOptionId || undefined,
title: name.trim() || undefined,
},
},
...(maxRunsValue != null ? { maxRuns: maxRunsValue } : {}),
});
}
onClose();
} catch (error) {
setSubmitError(toErrorMessage(error));
}
await persistFormPreferences();
const maxRunsValue = parseMaxRuns(maxRuns);
if (isEdit && schedule) {
await updateSchedule({
id: schedule.id,
name: name.trim() || null,
prompt: promptTrimmed,
cadence,
newAgentConfig: {
provider: selectedProvider,
model: selectedModel || null,
modeId: selectedMode || null,
cwd: trimmedWorkingDir,
},
maxRuns: maxRunsValue,
});
return true;
}
await createSchedule({
prompt: promptTrimmed,
name: name.trim() || undefined,
cadence,
target: {
type: "new-agent",
config: {
provider: selectedProvider,
cwd: trimmedWorkingDir,
model: selectedModel || undefined,
modeId: selectedMode || undefined,
thinkingOptionId: selectedThinkingOptionId || undefined,
title: name.trim() || undefined,
},
},
...(maxRunsValue != null ? { maxRuns: maxRunsValue } : {}),
});
return true;
}, [
cadence,
createSchedule,
isEdit,
maxRuns,
name,
onClose,
persistFormPreferences,
promptTrimmed,
schedule,
selectedMode,
selectedModel,
selectedProjectTarget,
selectedProvider,
selectedThinkingOptionId,
trimmedWorkingDir,
updateSchedule,
]);
const handleSubmit = useCallback(async () => {
if (!promptTrimmed) {
return;
}
setSubmitError(null);
try {
const submitted = isAgentTarget ? await submitAgentTarget() : await submitNewAgent();
if (submitted) {
onClose();
}
} catch (error) {
setSubmitError(toErrorMessage(error));
}
}, [isAgentTarget, onClose, promptTrimmed, submitAgentTarget, submitNewAgent]);
const handleSubmitPress = useCallback(() => {
void handleSubmit();
}, [handleSubmit]);
@@ -512,53 +454,34 @@ export function ScheduleFormSheet({
/>
</View>
{isAgentTarget ? (
<View style={styles.field}>
<Text style={styles.label}>Target</Text>
<View style={styles.readonlyField} testID="schedule-agent-target">
<Text style={styles.selectTriggerText} numberOfLines={1}>
{agentTargetLabel}
</Text>
</View>
<Text style={styles.hint}>Runs against this existing agent.</Text>
</View>
) : (
<>
<View style={styles.field}>
<Text style={styles.label}>Project</Text>
<ProjectField
options={projectOptions.options}
targetByOptionId={projectOptions.targetByOptionId}
value={selectedProjectOptionId}
selectedTarget={selectedProjectTarget}
fallbackCwd={workingDir}
onSelect={handleSelectProject}
/>
</View>
<View style={styles.field}>
<Text style={styles.label}>Project</Text>
<ProjectField
options={projectOptions.options}
targetByOptionId={projectOptions.targetByOptionId}
value={selectedProjectOptionId}
selectedTarget={selectedProjectTarget}
onSelect={handleSelectProject}
/>
</View>
<View style={styles.field}>
<Text style={styles.label}>Model</Text>
<CombinedModelSelector
providers={modelSelectorProviders}
selectedProvider={selectedProvider ?? ""}
selectedModel={selectedModel}
onSelect={setProviderAndModelFromUser}
isLoading={isAllModelsLoading}
renderTrigger={renderModelTrigger}
triggerFill
serverId={mutationServerId}
/>
</View>
<View style={styles.field}>
<Text style={styles.label}>Model</Text>
<CombinedModelSelector
providers={modelSelectorProviders}
selectedProvider={selectedProvider ?? ""}
selectedModel={selectedModel}
onSelect={setProviderAndModelFromUser}
isLoading={isAllModelsLoading}
renderTrigger={renderModelTrigger}
triggerFill
serverId={mutationServerId}
/>
</View>
{modeOptions.length > 0 ? (
<ModeField
options={modeOptions}
selectedMode={selectedMode}
onSelect={setModeFromUser}
/>
) : null}
</>
)}
{modeOptions.length > 0 ? (
<ModeField options={modeOptions} selectedMode={selectedMode} onSelect={setModeFromUser} />
) : null}
<View style={styles.field}>
<Text style={styles.label}>Cadence</Text>
@@ -581,6 +504,10 @@ export function ScheduleFormSheet({
<Text style={styles.hint}>Leave blank to run indefinitely</Text>
</View>
{editConfig === null && isEdit ? (
<Text style={styles.hint}>This schedule does not target a new agent.</Text>
) : null}
{submitError ? <Text style={styles.error}>{submitError}</Text> : null}
</AdaptiveModalSheet>
);
@@ -667,15 +594,12 @@ function ProjectField({
targetByOptionId,
value,
selectedTarget,
fallbackCwd,
onSelect,
}: {
options: ComboboxOption[];
targetByOptionId: Map<string, ScheduleProjectTarget>;
value: string;
selectedTarget: ScheduleProjectTarget | null;
/** Stored cwd for an edited schedule whose path matches no known project. */
fallbackCwd: string;
onSelect: (target: ScheduleProjectTarget) => void;
}): ReactElement {
const anchorRef = useRef<View>(null);
@@ -705,13 +629,7 @@ function ProjectField({
[open],
);
// Honest hydration: a stored cwd that matches no known project shows the
// shortened path itself (not the blank "Select project"), and stays put until
// the user deliberately picks a project.
const storedPath = fallbackCwd.trim();
const displayValue =
selectedTarget?.projectName ?? (storedPath ? shortenPath(storedPath) : "Select project");
const isPlaceholder = !selectedTarget && !storedPath;
const displayValue = selectedTarget?.projectName ?? "Select project";
const description = selectedTarget
? `${selectedTarget.serverName} - ${shortenPath(selectedTarget.cwd)}`
: null;
@@ -744,7 +662,7 @@ function ProjectField({
testID="schedule-project-trigger"
>
<Text
style={isPlaceholder ? styles.selectTriggerPlaceholder : styles.selectTriggerText}
style={selectedTarget ? styles.selectTriggerText : styles.selectTriggerPlaceholder}
numberOfLines={1}
>
{displayValue}
@@ -891,20 +809,9 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs,
},
error: {
color: theme.colors.palette.red[300],
color: theme.colors.destructive,
fontSize: theme.fontSize.xs,
},
readonlyField: {
flexDirection: "row",
alignItems: "center",
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.border,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
minHeight: 44,
},
selectTrigger: {
flexDirection: "row",
alignItems: "center",
@@ -15,9 +15,7 @@ import { isNative } from "@/constants/platform";
import { useIsCompactFormFactor } from "@/constants/layout";
import { settingsStyles } from "@/styles/settings";
import type { Theme } from "@/styles/theme";
import type { ScheduleDerivedState } from "@/schedules/schedule-derivation";
import { formatCadence, formatNextRun, resolveScheduleTitle } from "@/utils/schedule-format";
import { formatTimeAgo } from "@/utils/time";
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
// Themed lucide wrappers — module-scope so only the icon re-renders on theme
@@ -55,62 +53,43 @@ export interface ScheduleRowActions {
interface ScheduleRowProps extends ScheduleRowActions {
schedule: ScheduleSummary;
/** Client-derived target line (agent title / project / shortened path). */
targetLabel: string;
/** Provider glyph, resolved from the schedule config or the target agent. */
provider: string | null;
/** Client-derived state — the single source for the badge and next-run copy. */
state: ScheduleDerivedState;
/** Host name, rendered when the list spans more than one host. */
serverName?: string;
/** True when only one host exists and the host name would be redundant. */
singleHost?: boolean;
pending?: ScheduleRowPending;
isFirst: boolean;
}
function stateBadge(state: ScheduleDerivedState): {
label: string;
variant: "success" | "error" | "muted";
} {
switch (state) {
case "active":
return { label: "Active", variant: "success" };
case "paused":
return { label: "Paused", variant: "muted" };
case "expired":
return { label: "Expired", variant: "muted" };
case "finished":
return { label: "Finished", variant: "muted" };
case "targetGone":
return { label: "Target gone", variant: "error" };
}
function resolveProvider(schedule: ScheduleSummary): string | null {
return schedule.target.type === "new-agent" ? schedule.target.config.provider : null;
}
// Meta reads left-to-right as identity → history → future: how often, when it
// was created, when it last ran, and (only while it can still run) when it runs
// next. Status lives on the badge, never repeated here.
function buildMeta(
schedule: ScheduleSummary,
state: ScheduleDerivedState,
serverName: string | undefined,
singleHost: boolean,
): string {
const parts = [
formatCadence(schedule.cadence),
`Created ${formatTimeAgo(new Date(schedule.createdAt))}`,
schedule.lastRunAt ? `Last run ${formatTimeAgo(new Date(schedule.lastRunAt))}` : "Never run",
];
if (state === "active") {
const next = formatNextRun(schedule.nextRunAt);
if (next) {
parts.push(`Next run ${next}`);
}
function resolveModelLabel(schedule: ScheduleSummary): string {
if (schedule.target.type === "new-agent" && schedule.target.config.model) {
return schedule.target.config.model;
}
if (serverName && !singleHost) {
parts.unshift(serverName);
return "Default model";
}
function statusVariant(status: ScheduleSummary["status"]): "success" | "muted" {
return status === "active" ? "success" : "muted";
}
function statusLabel(status: ScheduleSummary["status"]): string {
if (status === "active") {
return "Active";
}
return parts.join(" · ");
if (status === "paused") {
return "Paused";
}
return "Completed";
}
function nextRunLabel(schedule: ScheduleSummary): string {
if (schedule.status === "paused") {
return "Paused";
}
if (schedule.status === "completed") {
return "Completed";
}
return formatNextRun(schedule.nextRunAt) || "—";
}
/** Small provider glyph. Reads the icon color off a StyleSheet object so the
@@ -134,11 +113,6 @@ function ProviderGlyph({ provider }: { provider: string | null }): ReactElement
*/
export function ScheduleRow({
schedule,
targetLabel,
provider,
state,
serverName,
singleHost,
pending,
isFirst,
onEdit,
@@ -152,10 +126,15 @@ export function ScheduleRow({
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
const provider = resolveProvider(schedule);
const title = resolveScheduleTitle(schedule);
const badge = stateBadge(state);
const meta = buildMeta(schedule, state, serverName, singleHost ?? false);
const canRun = state === "active" || state === "paused";
const meta = [
resolveModelLabel(schedule),
formatCadence(schedule.cadence),
nextRunLabel(schedule),
]
.filter(Boolean)
.join(" · ");
const rowStyle = useCallback(
({ pressed }: PressableStateCallbackType) => [
@@ -189,9 +168,6 @@ export function ScheduleRow({
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
{title}
</Text>
<Text style={styles.target} numberOfLines={1}>
{targetLabel}
</Text>
<Text style={settingsStyles.rowHint} numberOfLines={1}>
{meta}
</Text>
@@ -199,10 +175,12 @@ export function ScheduleRow({
</View>
<View style={styles.trailing}>
<StatusBadge label={badge.label} variant={badge.variant} />
<StatusBadge
label={statusLabel(schedule.status)}
variant={statusVariant(schedule.status)}
/>
<ScheduleKebabMenu
schedule={schedule}
canRun={canRun}
pending={pending}
onEdit={onEdit}
onPause={onPause}
@@ -233,19 +211,13 @@ function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }): ReactElemen
function ScheduleKebabMenu({
schedule,
canRun,
pending,
onEdit,
onPause,
onResume,
onRunNow,
onDelete,
}: Pick<
ScheduleRowProps,
"schedule" | "pending" | "onEdit" | "onPause" | "onResume" | "onRunNow" | "onDelete"
> & {
canRun: boolean;
}): ReactElement {
}: Omit<ScheduleRowProps, "isFirst">): ReactElement {
return (
<DropdownMenu>
<DropdownMenuTrigger
@@ -268,7 +240,6 @@ function ScheduleKebabMenu({
{schedule.status === "paused" ? (
<DropdownMenuItem
leading={resumeLeading}
disabled={!canRun}
status={pending?.resume ? "pending" : "idle"}
pendingLabel="Resuming..."
onSelect={onResume}
@@ -279,7 +250,7 @@ function ScheduleKebabMenu({
) : (
<DropdownMenuItem
leading={pauseLeading}
disabled={schedule.status === "completed" || !canRun}
disabled={schedule.status === "completed"}
status={pending?.pause ? "pending" : "idle"}
pendingLabel="Pausing..."
onSelect={onPause}
@@ -290,7 +261,6 @@ function ScheduleKebabMenu({
)}
<DropdownMenuItem
leading={runLeading}
disabled={!canRun}
status={pending?.runNow ? "pending" : "idle"}
pendingLabel="Starting..."
onSelect={onRunNow}
@@ -354,11 +324,6 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
minWidth: 0,
},
target: {
marginTop: theme.spacing[1],
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
trailing: {
flexDirection: "row",
alignItems: "center",
@@ -3,48 +3,44 @@ import { View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { ScheduleRow, type ScheduleRowPending } from "@/components/schedules/schedule-row";
import { useScheduleMutations } from "@/hooks/use-schedule-mutations";
import type { AggregatedSchedule } from "@/hooks/use-schedules";
import type { ScheduleDerivedState } from "@/schedules/schedule-derivation";
import { settingsStyles } from "@/styles/settings";
import { confirmDialog } from "@/utils/confirm-dialog";
import { resolveScheduleTitle } from "@/utils/schedule-format";
/** A schedule plus the client-derived fields the row renders. */
export interface ScheduleRowView {
schedule: AggregatedSchedule;
targetLabel: string;
provider: string | null;
state: ScheduleDerivedState;
serverName: string;
/** True when only one host exists, so the host name is redundant in rows. */
singleHost: boolean;
}
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
interface SchedulesTableProps {
rows: ScheduleRowView[];
serverId: string;
schedules: ScheduleSummary[];
/**
* The form sheet is owned by the screen (it serves both create and edit and
* shares the screen's "New schedule" button), so the table delegates edit
* shares the header's "New schedule" button), so the table delegates edit
* upward rather than mounting a second sheet here.
*/
onEditSchedule: (schedule: AggregatedSchedule) => void;
onEditSchedule: (schedule: ScheduleSummary) => void;
}
/**
* The schedules list: a single settings-style card of rows across every
* connected host, in a full-width list matching the History screen. Rows own
* their host-scoped mutations (pause/resume/run/delete via the mutations hook +
* a destructive confirm) and delegate editing upward.
* The schedules list: a single settings-style card of rows in a centered,
* width-constrained reading column, matching the projects list. Owns row-level
* actions (pause/resume/run/delete via the mutations hook + a destructive
* confirm for delete) and delegates editing to the parent.
*/
export function SchedulesTable({ rows, onEditSchedule }: SchedulesTableProps): ReactElement {
export function SchedulesTable({
serverId,
schedules,
onEditSchedule,
}: SchedulesTableProps): ReactElement {
const mutations = useScheduleMutations({ serverId });
return (
<View style={styles.listContent} testID="schedules-table">
<View style={settingsStyles.card}>
{rows.map((row, index) => (
{schedules.map((schedule, index) => (
<SchedulesTableRow
key={`${row.schedule.serverId}:${row.schedule.id}`}
row={row}
key={schedule.id}
schedule={schedule}
isFirst={index === 0}
mutations={mutations}
onEditSchedule={onEditSchedule}
/>
))}
@@ -54,26 +50,28 @@ export function SchedulesTable({ rows, onEditSchedule }: SchedulesTableProps): R
}
// ---------------------------------------------------------------------------
// Per-row wrapper owns local in-flight state and binds mutations to this
// schedule's host. Local state keeps pending precise to the acting row even
// when several rows are acted on at once (the mutations hook exposes only a
// single global pending flag per action).
// Per-row wrapper owns local in-flight state and binds the table's mutation
// callbacks to this schedule. Local state keeps pending precise to the acting
// row even when several rows are acted on at once (the mutations hook exposes
// only a single global pending flag per action).
// ---------------------------------------------------------------------------
type ScheduleMutations = ReturnType<typeof useScheduleMutations>;
const NO_PENDING: ScheduleRowPending = {};
function SchedulesTableRow({
row,
schedule,
isFirst,
mutations,
onEditSchedule,
}: {
row: ScheduleRowView;
schedule: ScheduleSummary;
isFirst: boolean;
onEditSchedule: (schedule: AggregatedSchedule) => void;
mutations: ScheduleMutations;
onEditSchedule: (schedule: ScheduleSummary) => void;
}): ReactElement {
const { schedule } = row;
const { id, serverId } = schedule;
const mutations = useScheduleMutations({ serverId });
const { id } = schedule;
const [pending, setPending] = useState<ScheduleRowPending>(NO_PENDING);
const runAction = useCallback(
@@ -129,11 +127,6 @@ function SchedulesTableRow({
return (
<ScheduleRow
schedule={schedule}
targetLabel={row.targetLabel}
provider={row.provider}
state={row.state}
serverName={row.serverName}
singleHost={row.singleHost}
isFirst={isFirst}
pending={pending}
onEdit={handleEdit}
@@ -145,9 +138,14 @@ function SchedulesTableRow({
);
}
const CONTENT_MAX_WIDTH = 720;
const styles = StyleSheet.create((theme) => ({
// Full-width list padding matching the History screen.
// Center the card in a readable column, matching settings and projects.
listContent: {
paddingHorizontal: { xs: theme.spacing[3], md: theme.spacing[6] },
width: "100%",
maxWidth: CONTENT_MAX_WIDTH,
alignSelf: "center",
paddingHorizontal: theme.spacing[4],
},
}));
@@ -56,7 +56,6 @@ import { NestableScrollContainer } from "react-native-draggable-flatlist";
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
import type { DraggableListDragHandleProps } from "./draggable-list.types";
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
import { useHostFeatureMap } from "@/runtime/host-features";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useProjectIconDataByProjectKey } from "@/projects/project-icons";
import {
@@ -1882,7 +1881,6 @@ function ProjectBlock({
activeWorkspaceSelection,
hostLabelByServerId,
showHostLabels,
supportsMultiplicityByServerId,
}: {
project: SidebarProjectEntry;
collapsed: boolean;
@@ -1904,16 +1902,14 @@ function ProjectBlock({
activeWorkspaceSelection: ActiveWorkspaceSelection | null;
hostLabelByServerId: ReadonlyMap<string, string>;
showHostLabels: boolean;
supportsMultiplicityByServerId: ReadonlyMap<string, boolean>;
}) {
const rowModel = useMemo(
() =>
buildSidebarProjectRowModel({
project,
collapsed,
supportsMultiplicityByServerId,
}),
[collapsed, project, supportsMultiplicityByServerId],
[collapsed, project],
);
const active = isProjectSelectedByRoute({
@@ -2066,7 +2062,7 @@ function ProjectBlock({
containerStyle={styles.workspaceListContainer}
/>
);
} else if (rowModel.trailingAction.kind === "new_workspace") {
} else if (rowModel.trailingAction.kind === "new_worktree") {
projectChildren = (
<NewWorkspaceGhostRow
project={project}
@@ -2089,7 +2085,7 @@ function ProjectBlock({
chevron={rowModel.chevron}
onPress={handleToggleCollapsed}
worktreeTarget={
rowModel.trailingAction.kind === "new_workspace" ? rowModel.trailingAction.target : null
rowModel.trailingAction.kind === "new_worktree" ? rowModel.trailingAction.target : null
}
isProjectActive={active}
onWorkspacePress={onWorkspacePress}
@@ -2110,7 +2106,6 @@ function ProjectBlock({
type ProjectBlockProps = Parameters<typeof ProjectBlock>[0];
// oxlint-disable-next-line complexity
function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlockProps): boolean {
return (
previous.project === next.project &&
@@ -2122,7 +2117,6 @@ function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlo
previous.shortcutIndexByWorkspaceKey === next.shortcutIndexByWorkspaceKey &&
previous.hostLabelByServerId === next.hostLabelByServerId &&
previous.showHostLabels === next.showHostLabels &&
previous.supportsMultiplicityByServerId === next.supportsMultiplicityByServerId &&
previous.parentGestureRef === next.parentGestureRef &&
previous.onToggleCollapsed === next.onToggleCollapsed &&
previous.onWorkspacePress === next.onWorkspacePress &&
@@ -2189,8 +2183,6 @@ export function SidebarWorkspaceList({
}
return labels;
}, [hosts]);
const serverIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]);
const supportsMultiplicityByServerId = useHostFeatureMap(serverIds, "workspaceMultiplicity");
const showHostLabels = useMemo(() => shouldShowSidebarHostLabels(projects), [projects]);
const content =
@@ -2216,7 +2208,6 @@ export function SidebarWorkspaceList({
pathname={pathname}
hostLabelByServerId={hostLabelByServerId}
showHostLabels={showHostLabels}
supportsMultiplicityByServerId={supportsMultiplicityByServerId}
/>
);
@@ -2265,7 +2256,6 @@ function ProjectModeList({
pathname,
hostLabelByServerId,
showHostLabels,
supportsMultiplicityByServerId,
}: Omit<
SidebarWorkspaceListProps,
"statusWorkspacePlacements" | "projectNamesByKey" | "groupMode" | "isRefreshing" | "onRefresh"
@@ -2273,7 +2263,6 @@ function ProjectModeList({
pathname: string;
hostLabelByServerId: ReadonlyMap<string, string>;
showHostLabels: boolean;
supportsMultiplicityByServerId: ReadonlyMap<string, boolean>;
}) {
const { t } = useTranslation();
const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState<Set<string>>(() => new Set());
@@ -2462,7 +2451,6 @@ function ProjectModeList({
activeWorkspaceSelection={activeWorkspaceSelection}
hostLabelByServerId={hostLabelByServerId}
showHostLabels={showHostLabels}
supportsMultiplicityByServerId={supportsMultiplicityByServerId}
/>
);
},
@@ -2473,7 +2461,6 @@ function ProjectModeList({
handleWorkspaceReorder,
hostLabelByServerId,
showHostLabels,
supportsMultiplicityByServerId,
onWorkspacePress,
onToggleProjectCollapsed,
parentGestureRef,
+1 -1
View File
@@ -50,7 +50,7 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.md,
},
sm: {
paddingVertical: theme.spacing[1.5],
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
},
@@ -161,7 +161,7 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[1],
},
segmentSm: {
paddingVertical: theme.spacing[1.5],
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[4],
},
segmentMd: {
@@ -11,6 +11,7 @@ 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,
});
}
+21 -21
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.32.0",
version: "0.31.0",
iconId: "auggie",
installLink: "https://www.augmentcode.com/",
command: ["npx", "-y", "@augmentcode/auggie@0.32.0", "--acp"],
command: ["npx", "-y", "@augmentcode/auggie@0.31.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.37",
version: "3.0.34",
iconId: "cline",
installLink: "https://cline.bot/cli",
command: ["npx", "-y", "cline@3.0.37", "--acp"],
command: ["npx", "-y", "cline@3.0.34", "--acp"],
},
{
id: "codebuddy-code",
title: "Codebuddy Code",
description: "Tencent Cloud's official intelligent coding tool",
version: "2.116.0",
version: "2.114.0",
iconId: "codebuddy-code",
installLink: "https://www.codebuddy.cn/cli/",
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.116.0", "--acp"],
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.114.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.19",
version: "0.2.12",
iconId: "dimcode",
installLink: "https://dimcode.dev/docs/acp.html",
command: ["npx", "-y", "dimcode@0.2.19", "acp"],
command: ["npx", "-y", "dimcode@0.2.12", "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.13",
version: "0.4.12",
iconId: "dirac",
installLink: "https://dirac.run",
command: ["npx", "-y", "dirac-cli@0.4.13", "--acp"],
command: ["npx", "-y", "dirac-cli@0.4.12", "--acp"],
},
{
id: "factory-droid",
title: "Factory Droid",
description: "Factory Droid - AI coding agent powered by Factory AI",
version: "0.164.0",
version: "0.161.0",
iconId: "factory-droid",
installLink: "https://factory.ai/product/cli",
command: ["npx", "-y", "droid@0.164.0", "exec", "--output-format", "acp-daemon"],
command: ["npx", "-y", "droid@0.161.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.3",
version: "0.8.1",
iconId: "fast-agent",
installLink: "https://fast-agent.ai/acp/",
command: ["uvx", "--from", "fast-agent-acp==0.8.3", "fast-agent-acp", "-x"],
command: ["uvx", "--from", "fast-agent-acp==0.8.1", "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.24",
version: "1.1.22",
iconId: "nova",
installLink: "https://www.compassap.ai/portfolio/nova.html",
command: ["npx", "-y", "@compass-ai/nova@1.1.24", "acp"],
command: ["npx", "-y", "@compass-ai/nova@1.1.22", "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.37",
version: "1.0.33",
iconId: "qoder",
installLink: "https://qoder.com",
command: ["npx", "-y", "@qoder-ai/qodercli@1.0.37", "--acp"],
command: ["npx", "-y", "@qoder-ai/qodercli@1.0.33", "--acp"],
},
{
id: "qwen-code",
title: "Qwen Code",
description: "Alibaba's Qwen coding assistant",
version: "0.19.6",
version: "0.19.3",
iconId: "qwen-code",
installLink: "https://qwenlm.github.io/qwen-code-docs/en/users/overview",
command: ["npx", "-y", "@qwen-code/qwen-code@0.19.6", "--acp", "--experimental-skills"],
command: ["npx", "-y", "@qwen-code/qwen-code@0.19.3", "--acp", "--experimental-skills"],
},
{
id: "sigit",
@@ -341,7 +341,7 @@ const CATALOG_DATA = [
description: "ByteDance's official TRAE coding agent with native ACP support",
version: "manual",
iconId: "traecli",
installLink: "https://docs.trae.cn/cli_get-started-with-trae-cli",
installLink: "https://docs.trae.cn/cli",
command: ["traecli", "acp", "serve"],
},
{
+1 -1
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?: (
@@ -0,0 +1,210 @@
import { useCallback, useMemo } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import type {
PaseoAgentCatalogEntry,
PaseoAgentOAuthCompleteResponse,
PaseoAgentOAuthStartResponse,
PaseoAgentRenameProviderRequest,
PaseoAgentSetProviderRequest,
RedactedPaseoAgentProviderConfig,
} from "@getpaseo/protocol/messages";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
export function paseoAgentProvidersQueryKey(serverId: string | null) {
return ["paseo-agent-providers", serverId] as const;
}
export function paseoAgentCatalogQueryKey(serverId: string | null) {
return ["paseo-agent-catalog", serverId] as const;
}
function describeQueryError(error: unknown): string | null {
if (!error) {
return null;
}
return error instanceof Error ? error.message : String(error);
}
export type PaseoAgentSetProviderInput = Omit<PaseoAgentSetProviderRequest, "type" | "requestId">;
export type PaseoAgentRenameProviderInput = Omit<
PaseoAgentRenameProviderRequest,
"type" | "requestId"
>;
export type PaseoAgentOAuthStartResult = PaseoAgentOAuthStartResponse["payload"];
export type PaseoAgentOAuthCompleteResult = PaseoAgentOAuthCompleteResponse["payload"];
interface UsePaseoAgentProvidersResult {
supported: boolean;
catalogSupported: boolean;
providers: RedactedPaseoAgentProviderConfig[];
catalog: PaseoAgentCatalogEntry[];
defaultModel: string | null;
isLoading: boolean;
isCatalogLoading: boolean;
error: string | null;
catalogError: string | null;
refresh: () => Promise<void>;
setProvider: (
input: PaseoAgentSetProviderInput,
) => Promise<RedactedPaseoAgentProviderConfig | null>;
renameProvider: (
input: PaseoAgentRenameProviderInput,
) => Promise<RedactedPaseoAgentProviderConfig | null>;
startOAuth: (name: string, mode?: string) => Promise<PaseoAgentOAuthStartResult>;
completeOAuth: (name: string) => Promise<PaseoAgentOAuthCompleteResult>;
}
export function usePaseoAgentProviders(serverId: string | null): UsePaseoAgentProvidersResult {
const { t } = useTranslation();
const queryClient = useQueryClient();
const client = useHostRuntimeClient(serverId ?? "");
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
const hostDisconnectedMessage = t("workspace.terminal.hostDisconnected");
const saveProviderFailedMessage = t("settings.host.providers.addErrorTitle");
// COMPAT(paseoAgentConfig): added in v0.1.85, remove gate after 2026-11-30.
const supported = useSessionStore(
(state) => state.sessions[serverId ?? ""]?.serverInfo?.features?.paseoAgentConfig === true,
);
// COMPAT(paseoAgentCatalog): added in v0.1.104, drop the gate when floor >= v0.1.104.
const catalogSupported = useSessionStore(
(state) => state.sessions[serverId ?? ""]?.serverInfo?.features?.paseoAgentCatalog === true,
);
const queryKey = useMemo(() => paseoAgentProvidersQueryKey(serverId), [serverId]);
const catalogQueryKey = useMemo(() => paseoAgentCatalogQueryKey(serverId), [serverId]);
const query = useQuery({
queryKey,
enabled: Boolean(supported && serverId && client && isConnected),
staleTime: 30_000,
queryFn: async () => {
if (!client) {
throw new Error(hostDisconnectedMessage);
}
return client.getPaseoAgentProviders();
},
});
const catalogQuery = useQuery({
queryKey: catalogQueryKey,
enabled: Boolean(supported && catalogSupported && serverId && client && isConnected),
staleTime: 30_000,
queryFn: async () => {
if (!client) {
throw new Error(hostDisconnectedMessage);
}
return client.getPaseoAgentCatalog();
},
});
const refresh = useCallback(async () => {
await queryClient.invalidateQueries({ queryKey });
await queryClient.invalidateQueries({ queryKey: catalogQueryKey });
}, [catalogQueryKey, queryClient, queryKey]);
const error = query.data?.error ?? describeQueryError(query.error);
const catalogError = catalogQuery.data?.error ?? describeQueryError(catalogQuery.error);
const setProviderMutation = useMutation({
mutationFn: async (input: PaseoAgentSetProviderInput) => {
if (!client) {
throw new Error(hostDisconnectedMessage);
}
const result = await client.setPaseoAgentProvider(input);
if (!result.success) {
throw new Error(result.error ?? saveProviderFailedMessage);
}
return result.provider;
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey });
},
});
const { mutateAsync: setProviderAsync } = setProviderMutation;
const setProvider = useCallback(
(input: PaseoAgentSetProviderInput) => setProviderAsync(input),
[setProviderAsync],
);
const renameProviderMutation = useMutation({
mutationFn: async (input: PaseoAgentRenameProviderInput) => {
if (!client) {
throw new Error(hostDisconnectedMessage);
}
const result = await client.renamePaseoAgentProvider(input);
if (!result.success) {
throw new Error(result.error ?? saveProviderFailedMessage);
}
return result.provider;
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey });
},
});
const { mutateAsync: renameProviderAsync } = renameProviderMutation;
const renameProvider = useCallback(
(input: PaseoAgentRenameProviderInput) => renameProviderAsync(input),
[renameProviderAsync],
);
const startOAuthMutation = useMutation({
mutationFn: async (input: { name: string; mode?: string }) => {
if (!client) {
throw new Error(hostDisconnectedMessage);
}
const result = await client.startPaseoAgentOAuth(input.name, { mode: input.mode });
if (!result.success) {
throw new Error(result.error ?? saveProviderFailedMessage);
}
return result;
},
});
const { mutateAsync: startOAuthAsync } = startOAuthMutation;
const startOAuth = useCallback(
(name: string, mode?: string) => startOAuthAsync(mode ? { name, mode } : { name }),
[startOAuthAsync],
);
const completeOAuthMutation = useMutation({
mutationFn: async (name: string) => {
if (!client) {
throw new Error(hostDisconnectedMessage);
}
const result = await client.completePaseoAgentOAuth(name);
if (!result.success) {
throw new Error(result.error ?? saveProviderFailedMessage);
}
return result;
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey });
},
});
const { mutateAsync: completeOAuthAsync } = completeOAuthMutation;
const completeOAuth = useCallback(
(name: string) => completeOAuthAsync(name),
[completeOAuthAsync],
);
return {
supported,
catalogSupported,
providers: query.data?.providers ?? [],
catalog: catalogQuery.data?.catalog ?? [],
defaultModel: query.data?.defaultModel ?? null,
isLoading: query.isLoading,
isCatalogLoading: catalogQuery.isLoading,
error,
catalogError,
refresh,
setProvider,
renameProvider,
startOAuth,
completeOAuth,
};
}
@@ -13,10 +13,7 @@ import type {
} from "@getpaseo/client/internal/daemon-client";
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
import { schedulesQueryBaseKey } from "@/hooks/use-schedules";
import type {
AggregatedSchedule,
FetchAggregatedSchedulesResult,
} from "@/schedules/aggregated-schedules";
import type { FetchAggregatedSchedulesResult } from "@/schedules/aggregated-schedules";
import { useSessionStore } from "@/stores/session-store";
export type CreateScheduleInput = Omit<CreateScheduleOptions, "requestId">;
@@ -63,9 +60,11 @@ function restoreSchedules(queryClient: QueryClient, snapshot: ScheduleListSnapsh
}
}
function updateSchedulesData(
function updateScheduleSections(
queryClient: QueryClient,
updateSchedules: (schedules: AggregatedSchedule[]) => AggregatedSchedule[],
updateSection: (
section: FetchAggregatedSchedulesResult["sections"][number],
) => FetchAggregatedSchedulesResult["sections"][number],
): void {
queryClient.setQueriesData<FetchAggregatedSchedulesResult>(
{ queryKey: schedulesQueryBaseKey },
@@ -73,7 +72,7 @@ function updateSchedulesData(
if (!current) {
return current;
}
return { ...current, schedules: updateSchedules(current.schedules) };
return { sections: current.sections.map(updateSection) };
},
);
}
@@ -85,18 +84,26 @@ function optimisticallySetStatus(
status: ScheduleSummary["status"],
): void {
const pausedAt = status === "paused" ? new Date().toISOString() : null;
updateSchedulesData(queryClient, (schedules) =>
schedules.map((schedule) =>
schedule.serverId === serverId && schedule.id === id
? { ...schedule, status, pausedAt }
: schedule,
),
updateScheduleSections(queryClient, (section) =>
section.serverId === serverId
? {
...section,
schedules: section.schedules.map((schedule) =>
schedule.id === id ? { ...schedule, status, pausedAt } : schedule,
),
}
: section,
);
}
function optimisticallyRemove(queryClient: QueryClient, serverId: string, id: string): void {
updateSchedulesData(queryClient, (schedules) =>
schedules.filter((schedule) => !(schedule.serverId === serverId && schedule.id === id)),
updateScheduleSections(queryClient, (section) =>
section.serverId === serverId
? {
...section,
schedules: section.schedules.filter((schedule) => schedule.id !== id),
}
: section,
);
}
+15 -20
View File
@@ -1,30 +1,23 @@
import { useMemo, useSyncExternalStore } from "react";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
import {
fetchAggregatedSchedules,
type AggregatedSchedule,
type ScheduleHostError,
type ScheduleHostInput,
type ScheduleHostSection,
} from "@/schedules/aggregated-schedules";
export type { AggregatedSchedule, ScheduleHostError } from "@/schedules/aggregated-schedules";
export type { ScheduleHostSection } from "@/schedules/aggregated-schedules";
export const schedulesQueryBaseKey = ["schedules"] as const;
// Cache identity for the host set. The query also carries the runtime version
// (below) so it retries as connectivity changes and reliably fetches once a host
// comes online — even on a cold deep-link. The full-screen spinner flash that
// keying on the version used to cause is prevented by keepPreviousData plus the
// isInitialLoad(data === undefined) gate, not by dropping the version.
export function schedulesQueryKey(serverIds: readonly string[]) {
return [...schedulesQueryBaseKey, [...serverIds].sort().join("|")] as const;
export function schedulesQueryKey(hosts: readonly ScheduleHostInput[]) {
return [...schedulesQueryBaseKey, hosts.map((host) => host.serverId).join("|")] as const;
}
export interface UseSchedulesResult {
schedules: AggregatedSchedule[];
hostErrors: ScheduleHostError[];
isInitialLoad: boolean;
sections: ScheduleHostSection[];
isLoading: boolean;
isError: boolean;
error: Error | null;
refetch: () => void;
@@ -40,21 +33,23 @@ export function useSchedules(): UseSchedulesResult {
() => runtime.getVersion(),
);
const hostInputs = useMemo<ScheduleHostInput[]>(
() => hosts.map((host) => ({ serverId: host.serverId, serverName: host.label })),
() =>
hosts.map((host) => ({
serverId: host.serverId,
serverName: host.label,
})),
[hosts],
);
const query = useQuery({
queryKey: [...schedulesQueryKey(hostInputs.map((host) => host.serverId)), runtimeVersion],
queryKey: [...schedulesQueryKey(hostInputs), runtimeVersion] as const,
queryFn: () => fetchAggregatedSchedules({ hosts: hostInputs, runtime }),
staleTime: 5_000,
placeholderData: keepPreviousData,
});
return {
schedules: query.data?.schedules ?? [],
hostErrors: query.data?.hostErrors ?? [],
isInitialLoad: query.isLoading && query.data === undefined,
sections: query.data?.sections ?? [],
isLoading: query.isLoading,
isError: query.isError,
error: query.error,
refetch: () => {
+12 -7
View File
@@ -422,17 +422,22 @@ export const ar: TranslationResources = {
enterUrl: "أدخل URL",
openDevTools: "افتح أدوات تطوير المتصفح",
cancelSelector: "إلغاء محدد العنصر",
annotateElement: "التعليق على العنصر",
screenshotElement: "لقطة للعنصر",
screenshotCopied: "تم نسخ لقطة الشاشة إلى الحافظة",
elementCopied: "تم نسخ العنصر إلى الحافظة",
screenshotFailed: "تعذّر نسخ لقطة الشاشة",
selectElement: "حدد العنصر",
grabElement: "نسخ العنصر إلى الحافظة",
grabElementLabel: "العنصر",
grabFailed: "تعذّر نسخ العنصر",
},
annotate: {
title: "التعليق على العنصر",
placeholder: "رسالة إلى الوكيل حول هذا العنصر…",
title: "إرسال ملاحظات إلى الوكيل",
placeholder: "صف ما الذي ينبغي تغييره…",
submit: "إرفاق",
cancel: "إلغاء",
intents: {
fix: "إصلاح",
change: "تغيير",
question: "سؤال",
approve: "موافقة",
},
},
devices: {
label: "حجم الجهاز",
+12 -7
View File
@@ -422,17 +422,22 @@ export const en = {
enterUrl: "Enter URL",
openDevTools: "Open browser dev tools",
cancelSelector: "Cancel element selector",
annotateElement: "Annotate element",
screenshotElement: "Screenshot element",
screenshotCopied: "Copied screenshot to clipboard",
elementCopied: "Copied element to clipboard",
screenshotFailed: "Couldn't copy screenshot",
selectElement: "Select element",
grabElement: "Copy element to clipboard",
grabElementLabel: "element",
grabFailed: "Couldn't copy element",
},
annotate: {
title: "Annotate element",
placeholder: "Message to the agent about this element…",
title: "Send feedback to agent",
placeholder: "Describe what should change…",
submit: "Attach",
cancel: "Cancel",
intents: {
fix: "Fix",
change: "Change",
question: "Question",
approve: "Approve",
},
},
devices: {
label: "Device size",
+12 -7
View File
@@ -426,17 +426,22 @@ export const es: TranslationResources = {
enterUrl: "IngreseURL",
openDevTools: "Abrir herramientas de desarrollo del navegador",
cancelSelector: "Cancelar selector de elementos",
annotateElement: "Anotar elemento",
screenshotElement: "Capturar elemento",
screenshotCopied: "Captura copiada al portapapeles",
elementCopied: "Elemento copiado al portapapeles",
screenshotFailed: "No se pudo copiar la captura",
selectElement: "Seleccionar elemento",
grabElement: "Copiar elemento al portapapeles",
grabElementLabel: "elemento",
grabFailed: "No se pudo copiar el elemento",
},
annotate: {
title: "Anotar elemento",
placeholder: "Mensaje al agente sobre este elemento…",
title: "Enviar comentarios al agente",
placeholder: "Describe qué debería cambiar…",
submit: "Adjuntar",
cancel: "Cancelar",
intents: {
fix: "Corregir",
change: "Cambiar",
question: "Pregunta",
approve: "Aprobar",
},
},
devices: {
label: "Tamaño del dispositivo",
+12 -7
View File
@@ -426,17 +426,22 @@ export const fr: TranslationResources = {
enterUrl: "EntrezURL",
openDevTools: "Outils de développement du navigateur ouvert",
cancelSelector: "Annuler le sélecteur d'é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",
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",
},
annotate: {
title: "Annoter l'élément",
placeholder: "Message à l'agent concernant cet élément…",
title: "Envoyer un retour à l'agent",
placeholder: "Décrivez ce qui doit changer…",
submit: "Joindre",
cancel: "Annuler",
intents: {
fix: "Corriger",
change: "Modifier",
question: "Question",
approve: "Approuver",
},
},
devices: {
label: "Taille de l'appareil",
+12 -7
View File
@@ -426,17 +426,22 @@ export const ja: TranslationResources = {
enterUrl: "URLを入力",
openDevTools: "ブラウザ開発ツールを開く",
cancelSelector: "要素セレクターをキャンセル",
annotateElement: "要素に注釈を付ける",
screenshotElement: "要素のスクリーンショット",
screenshotCopied: "スクリーンショットをクリップボードにコピーしました",
elementCopied: "要素をクリップボードにコピーしました",
screenshotFailed: "スクリーンショットをコピーできませんでした",
selectElement: "要素を選択",
grabElement: "要素をクリップボードにコピー",
grabElementLabel: "要素",
grabFailed: "要素をコピーできませんでした",
},
annotate: {
title: "要素に注釈を付ける",
placeholder: "この要素についてエージェントへのメッセージ…",
title: "エージェントにフィードバックを送信",
placeholder: "変更すべき内容を記述…",
submit: "添付",
cancel: "キャンセル",
intents: {
fix: "修正",
change: "変更",
question: "質問",
approve: "承認",
},
},
devices: {
label: "デバイスサイズ",
+12 -7
View File
@@ -426,17 +426,22 @@ export const ptBR: TranslationResources = {
enterUrl: "Inserir URL",
openDevTools: "Abrir ferramentas de desenvolvedor do navegador",
cancelSelector: "Cancelar seletor de 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",
selectElement: "Selecionar elemento",
grabElement: "Copiar elemento para a área de transferência",
grabElementLabel: "elemento",
grabFailed: "Não foi possível copiar o elemento",
},
annotate: {
title: "Anotar elemento",
placeholder: "Mensagem ao agente sobre este elemento…",
title: "Enviar feedback ao agente",
placeholder: "Descreva o que deve mudar…",
submit: "Anexar",
cancel: "Cancelar",
intents: {
fix: "Corrigir",
change: "Alterar",
question: "Pergunta",
approve: "Aprovar",
},
},
devices: {
label: "Tamanho do dispositivo",
+12 -7
View File
@@ -426,17 +426,22 @@ export const ru: TranslationResources = {
enterUrl: "Введите URL",
openDevTools: "Открыть инструменты разработки браузера",
cancelSelector: "Отменить выбор элемента",
annotateElement: "Аннотировать элемент",
screenshotElement: "Снимок элемента",
screenshotCopied: "Снимок скопирован в буфер обмена",
elementCopied: "Элемент скопирован в буфер обмена",
screenshotFailed: "Не удалось скопировать снимок",
selectElement: "Выберите элемент",
grabElement: "Скопировать элемент в буфер обмена",
grabElementLabel: "элемент",
grabFailed: "Не удалось скопировать элемент",
},
annotate: {
title: "Аннотировать элемент",
placeholder: "Сообщение агенту об этом элементе…",
title: "Отправить отзыв агенту",
placeholder: "Опишите, что нужно изменить…",
submit: "Прикрепить",
cancel: "Отмена",
intents: {
fix: "Исправить",
change: "Изменить",
question: "Вопрос",
approve: "Одобрить",
},
},
devices: {
label: "Размер устройства",
+12 -7
View File
@@ -422,17 +422,22 @@ export const zhCN: TranslationResources = {
enterUrl: "输入 URL",
openDevTools: "打开浏览器开发者工具",
cancelSelector: "取消元素选择器",
annotateElement: "标注元素",
screenshotElement: "截图元素",
screenshotCopied: "已将截图复制到剪贴板",
elementCopied: "已将元素复制到剪贴板",
screenshotFailed: "无法复制截图",
selectElement: "选择元素",
grabElement: "复制元素到剪贴板",
grabElementLabel: "元素",
grabFailed: "复制元素失败",
},
annotate: {
title: "标注元素",
placeholder: "给智能体关于此元素的留言…",
title: "发送反馈给智能体",
placeholder: "描述需要修改的内容…",
submit: "附加",
cancel: "取消",
intents: {
fix: "修复",
change: "修改",
question: "提问",
approve: "认可",
},
},
devices: {
label: "设备尺寸",
+3 -9
View File
@@ -39,7 +39,6 @@ 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 {
@@ -523,15 +522,10 @@ function probeIntervalForConnection(
}
function createDefaultDeps(): HostRuntimeControllerDeps {
const browserHostAvailable =
const desktopBrowserAutomationAvailable =
typeof getDesktopHost()?.browser?.executeAutomationCommand === "function";
const browserAutomationCapabilities = browserHostAvailable
? {
[CLIENT_CAPS.browserHost]: {
supportedCommands: [...BROWSER_AUTOMATION_COMMAND_NAMES],
hostKind: "desktop app",
},
}
const browserAutomationCapabilities = desktopBrowserAutomationAvailable
? { [CLIENT_CAPS.desktopBrowserAutomation]: true }
: undefined;
return {
@@ -1,9 +1,8 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
import { isNewAgentSchedule } from "@/utils/schedule-format";
import { toErrorMessage } from "@/utils/error-messages";
export const ALL_SCHEDULE_HOSTS_FAILED_MESSAGE = "No connected hosts could load schedules";
export interface ScheduleHostInput {
serverId: string;
serverName: string;
@@ -18,76 +17,65 @@ export interface ScheduleRuntime {
getSnapshot(serverId: string): ScheduleRuntimeSnapshot | null | undefined;
}
/** A schedule tagged with the host it came from, so the flat list can render a
* per-row host label and scope mutations without host sections. */
export interface AggregatedSchedule extends ScheduleSummary {
export interface ScheduleHostSection {
serverId: string;
serverName: string;
}
export interface ScheduleHostError {
serverId: string;
serverName: string;
message: string;
}
export interface FetchAggregatedSchedulesResult {
schedules: AggregatedSchedule[];
hostErrors: ScheduleHostError[];
isOnline: boolean;
schedules: ScheduleSummary[];
error: string | null;
}
export interface FetchAggregatedSchedulesInput {
hosts: readonly ScheduleHostInput[];
hosts: ScheduleHostInput[];
runtime: ScheduleRuntime;
}
/**
* Fetch schedules across connected hosts and merge them into one flat list.
* Connectivity is checked here at execution time (not pre-filtered by the hook)
* so the query — retried as the runtime version changes — reliably picks a host
* up the moment it comes online, including on a cold deep-link.
*
* Offline hosts are skipped. A connected host that fails contributes to
* `hostErrors` (surfaced as a banner) while the rest still render; only when
* every connected host fails do we throw so the screen shows a full error.
*/
export interface FetchAggregatedSchedulesResult {
sections: ScheduleHostSection[];
}
export async function fetchAggregatedSchedules(
input: FetchAggregatedSchedulesInput,
): Promise<FetchAggregatedSchedulesResult> {
const schedules: AggregatedSchedule[] = [];
const hostErrors: ScheduleHostError[] = [];
let connectedAttempts = 0;
await Promise.all(
input.hosts.map(async (host) => {
const sections = await Promise.all(
input.hosts.map(async (host): Promise<ScheduleHostSection> => {
const snapshot = input.runtime.getSnapshot(host.serverId);
const isOnline = snapshot?.connectionStatus === "online";
const client = input.runtime.getClient(host.serverId);
if (!client || !isOnline) {
return;
return {
serverId: host.serverId,
serverName: host.serverName,
isOnline,
schedules: [],
error: null,
};
}
connectedAttempts += 1;
try {
const payload = await client.scheduleList();
if (payload.error) {
throw new Error(payload.error);
}
for (const schedule of payload.schedules) {
schedules.push({ ...schedule, serverId: host.serverId, serverName: host.serverName });
}
} catch (error) {
hostErrors.push({
return {
serverId: host.serverId,
serverName: host.serverName,
message: toErrorMessage(error),
});
isOnline,
schedules: payload.schedules.filter(isNewAgentSchedule),
error: null,
};
} catch (error) {
return {
serverId: host.serverId,
serverName: host.serverName,
isOnline,
schedules: [],
error: toErrorMessage(error),
};
}
}),
);
if (connectedAttempts > 0 && schedules.length === 0 && hostErrors.length === connectedAttempts) {
throw new Error(ALL_SCHEDULE_HOSTS_FAILED_MESSAGE);
}
return { schedules, hostErrors };
return { sections };
}
@@ -1,133 +0,0 @@
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
import { describe, expect, it } from "vitest";
import { resolveSchedule, scheduleBucket, type ScheduleTargetAgent } from "./schedule-derivation";
const NOW = Date.parse("2026-07-02T00:00:00.000Z");
const AGENT_ID = "00000000-0000-4000-8000-000000000000";
function makeSchedule(overrides: Partial<ScheduleSummary>): ScheduleSummary {
return {
id: "schedule-1",
name: "Nightly",
prompt: "Run the task",
cadence: { type: "every", everyMs: 60_000 },
target: { type: "new-agent", config: { provider: "codex", cwd: "/tmp/project" } },
status: "active",
createdAt: "2026-07-01T00:00:00.000Z",
updatedAt: "2026-07-01T00:00:00.000Z",
nextRunAt: "2026-07-02T01:00:00.000Z",
lastRunAt: null,
pausedAt: null,
expiresAt: null,
maxRuns: null,
...overrides,
};
}
function resolve(
schedule: ScheduleSummary,
options?: {
agents?: Array<[string, ScheduleTargetAgent]>;
projects?: Array<[string, string]>;
agentDataLoaded?: boolean;
},
) {
return resolveSchedule({
schedule,
serverId: "host-1",
now: NOW,
agentsByKey: new Map(options?.agents ?? []),
projectNameByCwd: new Map(options?.projects ?? []),
agentDataLoaded: options?.agentDataLoaded ?? true,
});
}
describe("resolveSchedule state", () => {
it("keeps active and paused schedules runnable", () => {
expect(resolve(makeSchedule({ status: "active" })).state).toBe("active");
expect(resolve(makeSchedule({ status: "paused" })).state).toBe("paused");
expect(scheduleBucket("active")).toBe("runnable");
expect(scheduleBucket("paused")).toBe("runnable");
});
it("treats a past expiresAt as expired regardless of status", () => {
const result = resolve(
makeSchedule({ status: "active", expiresAt: "2026-07-01T00:00:00.000Z" }),
);
expect(result.state).toBe("expired");
expect(result.bucket).toBe("ended");
});
it("ignores an unparseable expiresAt", () => {
expect(resolve(makeSchedule({ expiresAt: "not-a-date" })).state).toBe("active");
});
it("derives finished only from completed-and-not-expired", () => {
expect(resolve(makeSchedule({ status: "completed" })).state).toBe("finished");
expect(
resolve(makeSchedule({ status: "completed", expiresAt: "2026-07-01T00:00:00.000Z" })).state,
).toBe("expired");
});
it("marks an agent target gone when the client has no such agent", () => {
const schedule = makeSchedule({ target: { type: "agent", agentId: AGENT_ID } });
expect(resolve(schedule).state).toBe("targetGone");
expect(resolve(schedule).bucket).toBe("ended");
});
it("does not claim gone before the agent directory has loaded", () => {
const schedule = makeSchedule({ target: { type: "agent", agentId: AGENT_ID } });
expect(resolve(schedule, { agentDataLoaded: false }).state).toBe("active");
});
it("prefers target-gone over the raw paused/completed status for a live agent target", () => {
const paused = makeSchedule({
status: "paused",
target: { type: "agent", agentId: AGENT_ID },
});
expect(resolve(paused).state).toBe("targetGone");
});
it("never claims a new-agent cwd is gone", () => {
expect(resolve(makeSchedule({ status: "active" })).state).toBe("active");
});
});
describe("resolveSchedule target line", () => {
it("names an agent target by its client title and provider", () => {
const schedule = makeSchedule({ target: { type: "agent", agentId: AGENT_ID } });
const result = resolve(schedule, {
agents: [[`host-1:${AGENT_ID}`, { title: "Fix build", provider: "claude" }]],
});
expect(result.target).toEqual({ label: "Fix build", provider: "claude" });
expect(result.state).toBe("active");
});
it("falls back to Untitled agent when the agent has no title", () => {
const schedule = makeSchedule({ target: { type: "agent", agentId: AGENT_ID } });
const result = resolve(schedule, {
agents: [[`host-1:${AGENT_ID}`, { title: " ", provider: "codex" }]],
});
expect(result.target.label).toBe("Untitled agent");
});
it("labels a gone agent target as unavailable with no glyph", () => {
const schedule = makeSchedule({ target: { type: "agent", agentId: AGENT_ID } });
expect(resolve(schedule).target).toEqual({ label: "Agent unavailable", provider: null });
});
it("names a new-agent cwd by matched project, else the shortened path", () => {
const matched = makeSchedule({
target: { type: "new-agent", config: { provider: "codex", cwd: "/tmp/project" } },
});
expect(resolve(matched, { projects: [["host-1:/tmp/project", "My Project"]] }).target).toEqual({
label: "My Project",
provider: "codex",
});
const unmatched = makeSchedule({
target: { type: "new-agent", config: { provider: "codex", cwd: "/Users/alex/work/api" } },
});
expect(resolve(unmatched).target).toEqual({ label: "~/work/api", provider: "codex" });
});
});
@@ -1,109 +0,0 @@
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
import { describeScheduleCwd } from "@/schedules/schedule-project-targets";
// Derived from existing fields only — no new protocol state. "active"/"paused"
// mirror the stored status; the rest are computed truths the daemon does not
// spell out in a single field.
export type ScheduleDerivedState = "active" | "paused" | "expired" | "finished" | "targetGone";
export type ScheduleBucket = "runnable" | "ended";
export interface ScheduleTargetAgent {
title: string | null;
provider: string | null;
}
export interface ScheduleTargetResolution {
/** The target line: agent title, project name, or the shortened cwd. */
label: string;
/** Provider glyph for the row, when known. */
provider: string | null;
}
export interface ResolvedSchedule {
state: ScheduleDerivedState;
bucket: ScheduleBucket;
target: ScheduleTargetResolution;
}
export interface ResolveScheduleInput {
schedule: ScheduleSummary;
serverId: string;
now: number;
/** Client agent directory keyed by `${serverId}:${agentId}`. */
agentsByKey: ReadonlyMap<string, ScheduleTargetAgent>;
/** Known project roots keyed by `${serverId}:${cwd}`. */
projectNameByCwd: ReadonlyMap<string, string>;
/**
* Whether the agent directory has finished its first load. While false we do
* not claim an agent target is gone — absence would just be a cold cache.
*/
agentDataLoaded: boolean;
}
function agentKey(serverId: string, agentId: string): string {
return `${serverId}:${agentId}`;
}
function isExpired(schedule: ScheduleSummary, now: number): boolean {
if (!schedule.expiresAt) {
return false;
}
const expiresAt = Date.parse(schedule.expiresAt);
return Number.isFinite(expiresAt) && expiresAt <= now;
}
function isAgentTargetGone(input: ResolveScheduleInput): boolean {
const { schedule, serverId, agentsByKey, agentDataLoaded } = input;
if (schedule.target.type !== "agent" || !agentDataLoaded) {
return false;
}
return !agentsByKey.has(agentKey(serverId, schedule.target.agentId));
}
function resolveTarget(input: ResolveScheduleInput): ScheduleTargetResolution {
const { schedule, serverId, agentsByKey, projectNameByCwd } = input;
if (schedule.target.type === "agent") {
const agent = agentsByKey.get(agentKey(serverId, schedule.target.agentId));
if (agent) {
return { label: agent.title?.trim() || "Untitled agent", provider: agent.provider };
}
return { label: "Agent unavailable", provider: null };
}
return {
label: describeScheduleCwd({ serverId, cwd: schedule.target.config.cwd, projectNameByCwd }),
provider: schedule.target.config.provider,
};
}
// One badge, one truth. Order matters: expiry and a missing target are more
// informative than the raw "completed"/"paused" status, so they win.
function deriveState(input: ResolveScheduleInput): ScheduleDerivedState {
const { schedule, now } = input;
if (isExpired(schedule, now)) {
return "expired";
}
if (isAgentTargetGone(input)) {
return "targetGone";
}
if (schedule.status === "completed") {
return "finished";
}
if (schedule.status === "paused") {
return "paused";
}
return "active";
}
export function scheduleBucket(state: ScheduleDerivedState): ScheduleBucket {
return state === "active" || state === "paused" ? "runnable" : "ended";
}
export function resolveSchedule(input: ResolveScheduleInput): ResolvedSchedule {
const state = deriveState(input);
return {
state,
bucket: scheduleBucket(state),
target: resolveTarget(input),
};
}
@@ -1,73 +0,0 @@
import { describe, expect, it } from "vitest";
import type { ProjectSummary } from "@/utils/projects";
import {
buildProjectNameByCwd,
buildScheduleProjectTargets,
describeScheduleCwd,
} from "./schedule-project-targets";
function makeProject(overrides: Partial<ProjectSummary>): ProjectSummary {
return {
projectKey: "proj",
projectName: "Project",
hosts: [],
totalWorkspaceCount: 0,
hostCount: 0,
onlineHostCount: 0,
...overrides,
};
}
function makeHost(overrides: Partial<ProjectSummary["hosts"][number]>) {
return {
serverId: "host-1",
serverName: "Host 1",
isOnline: true,
repoRoot: "/tmp/project",
workspaceCount: 0,
workspaces: [],
...overrides,
};
}
describe("buildScheduleProjectTargets", () => {
it("emits one target per online host with a repo root", () => {
const targets = buildScheduleProjectTargets([
makeProject({
projectName: "Alpha",
hosts: [makeHost({ repoRoot: "/tmp/alpha" }), makeHost({ serverId: "host-2" })],
}),
]);
expect(targets).toHaveLength(2);
expect(targets[0]).toMatchObject({
serverId: "host-1",
cwd: "/tmp/alpha",
projectName: "Alpha",
});
});
it("skips offline hosts and blank repo roots", () => {
const targets = buildScheduleProjectTargets([
makeProject({
hosts: [makeHost({ isOnline: false }), makeHost({ serverId: "host-3", repoRoot: " " })],
}),
]);
expect(targets).toHaveLength(0);
});
});
describe("describeScheduleCwd", () => {
it("prefers a matched project name and shortens unmatched paths", () => {
const byCwd = buildProjectNameByCwd(
buildScheduleProjectTargets([
makeProject({ projectName: "Alpha", hosts: [makeHost({ repoRoot: "/tmp/alpha" })] }),
]),
);
expect(
describeScheduleCwd({ serverId: "host-1", cwd: "/tmp/alpha", projectNameByCwd: byCwd }),
).toBe("Alpha");
expect(
describeScheduleCwd({ serverId: "host-1", cwd: "/Users/sam/api", projectNameByCwd: byCwd }),
).toBe("~/api");
});
});
@@ -1,75 +0,0 @@
import type { ProjectSummary } from "@/utils/projects";
import { shortenPath } from "@/utils/shorten-path";
export const PROJECT_OPTION_PREFIX = "project:";
export interface ScheduleProjectTarget {
optionId: string;
serverId: string;
serverName: string;
projectKey: string;
projectName: string;
cwd: string;
}
export function buildProjectOptionId(serverId: string, projectKey: string): string {
return `${PROJECT_OPTION_PREFIX}${serverId}:${projectKey}`;
}
/**
* The project roots the schedule form can target: one per online host of each
* project, keyed by (serverId, cwd). The schedules list reuses this set to name
* a schedule's stored cwd; the two surfaces must agree on what "a project" is.
*/
export function buildScheduleProjectTargets(
projects: readonly ProjectSummary[],
): ScheduleProjectTarget[] {
const targets: ScheduleProjectTarget[] = [];
for (const project of projects) {
for (const host of project.hosts) {
const cwd = host.repoRoot.trim();
if (!host.isOnline || !cwd) {
continue;
}
targets.push({
optionId: buildProjectOptionId(host.serverId, project.projectKey),
serverId: host.serverId,
serverName: host.serverName,
projectKey: project.projectKey,
projectName: project.projectName,
cwd,
});
}
}
return targets;
}
function projectNameKey(serverId: string, cwd: string): string {
return `${serverId}:${cwd.trim()}`;
}
/** Map (serverId, cwd) -> project name for naming a schedule's stored cwd. */
export function buildProjectNameByCwd(
targets: readonly ScheduleProjectTarget[],
): Map<string, string> {
const byCwd = new Map<string, string>();
for (const target of targets) {
byCwd.set(projectNameKey(target.serverId, target.cwd), target.projectName);
}
return byCwd;
}
/**
* Name a stored cwd for display: the matching project name when the client
* knows this root on this host, otherwise the shortened path itself. Never
* blank, never a claim the client cannot back up.
*/
export function describeScheduleCwd(input: {
serverId: string;
cwd: string;
projectNameByCwd: ReadonlyMap<string, string>;
}): string {
return (
input.projectNameByCwd.get(projectNameKey(input.serverId, input.cwd)) ?? shortenPath(input.cwd)
);
}
+115 -262
View File
@@ -1,40 +1,14 @@
import {
useCallback,
useEffect,
useMemo,
useState,
useSyncExternalStore,
type ReactElement,
} from "react";
import { useCallback, useMemo, useState, type ReactElement } from "react";
import { ScrollView, Text, View } from "react-native";
import { useIsFocused } from "@react-navigation/native";
import { Plus } from "lucide-react-native";
import { StyleSheet } from "react-native-unistyles";
import { MenuHeader } from "@/components/headers/menu-header";
import { HostFilter } from "@/components/hosts/host-filter";
import { ALL_HOSTS_OPTION_ID } from "@/components/hosts/host-picker";
import { ScheduleFormSheet } from "@/components/schedules/schedule-form-sheet";
import { SchedulesTable, type ScheduleRowView } from "@/components/schedules/schedules-table";
import { SchedulesTable } from "@/components/schedules/schedules-table";
import { Button } from "@/components/ui/button";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { SegmentedControl } from "@/components/ui/segmented-control";
import { useAggregatedAgents } from "@/hooks/use-aggregated-agents";
import { useProjects } from "@/hooks/use-projects";
import {
useSchedules,
type AggregatedSchedule,
type ScheduleHostError,
} from "@/hooks/use-schedules";
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
import {
resolveSchedule,
type ScheduleBucket,
type ScheduleTargetAgent,
} from "@/schedules/schedule-derivation";
import {
buildProjectNameByCwd,
buildScheduleProjectTargets,
} from "@/schedules/schedule-project-targets";
import { useSchedules, type ScheduleHostSection } from "@/hooks/use-schedules";
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
type FormState =
@@ -42,11 +16,6 @@ type FormState =
| { mode: "create" }
| { mode: "edit"; serverId: string; schedule: ScheduleSummary };
const STATUS_FILTER_OPTIONS: { value: ScheduleBucket; label: string; testID: string }[] = [
{ value: "runnable", label: "Active", testID: "schedules-filter-active" },
{ value: "ended", label: "Ended", testID: "schedules-filter-ended" },
];
export function SchedulesScreen(): ReactElement {
const isFocused = useIsFocused();
@@ -58,120 +27,38 @@ export function SchedulesScreen(): ReactElement {
}
function SchedulesScreenContent(): ReactElement {
const { schedules, hostErrors, isInitialLoad, isError, refetch } = useSchedules();
const { agents } = useAggregatedAgents({ includeArchived: true });
const { projects } = useProjects();
const hosts = useHosts();
const runtime = getHostRuntimeStore();
const runtimeVersion = useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange),
() => runtime.getVersion(),
() => runtime.getVersion(),
);
// Per-host agent-directory readiness from the runtime, not the aggregate agent
// flag: the aggregate `isInitialLoad` flips false as soon as *any* host has
// agents, so a still-loading host would falsely mark its agent-target
// schedules "gone". `hasEverLoadedAgentDirectory` is true only once that
// host's directory has loaded at least once.
const agentDirReadyHosts = useMemo(() => {
void runtimeVersion;
const ready = new Set<string>();
for (const host of hosts) {
if (runtime.getSnapshot(host.serverId)?.hasEverLoadedAgentDirectory) {
ready.add(host.serverId);
}
}
return ready;
}, [hosts, runtime, runtimeVersion]);
const { sections, isLoading, isError, error, refetch } = useSchedules();
const [form, setForm] = useState<FormState>({ mode: "closed" });
const [selectedHost, setSelectedHost] = useState(ALL_HOSTS_OPTION_ID);
const [statusFilter, setStatusFilter] = useState<ScheduleBucket>("runnable");
useEffect(() => {
if (
selectedHost !== ALL_HOSTS_OPTION_ID &&
!hosts.some((host) => host.serverId === selectedHost)
) {
setSelectedHost(ALL_HOSTS_OPTION_ID);
}
}, [hosts, selectedHost]);
const openCreate = useCallback(() => setForm({ mode: "create" }), []);
const openEdit = useCallback((schedule: AggregatedSchedule) => {
setForm({ mode: "edit", serverId: schedule.serverId, schedule });
const openCreate = useCallback(() => {
setForm({ mode: "create" });
}, []);
const closeForm = useCallback(() => setForm({ mode: "closed" }), []);
const agentsByKey = useMemo(() => {
const map = new Map<string, ScheduleTargetAgent>();
for (const agent of agents) {
map.set(`${agent.serverId}:${agent.id}`, { title: agent.title, provider: agent.provider });
}
return map;
}, [agents]);
const openEdit = useCallback((serverId: string, schedule: ScheduleSummary) => {
setForm({ mode: "edit", serverId, schedule });
}, []);
const projectNameByCwd = useMemo(
() => buildProjectNameByCwd(buildScheduleProjectTargets(projects)),
[projects],
const closeForm = useCallback(() => {
setForm({ mode: "closed" });
}, []);
const headerAction = useMemo(
() => (
<Button leftIcon={Plus} onPress={openCreate} size="sm" testID="schedules-new">
New schedule
</Button>
),
[openCreate],
);
// Resolve every schedule's derived state and target line once, then partition
// by the host and status filters. Sorted newest-first for a stable order
// across hosts.
const resolvedRows = useMemo(() => {
const now = Date.now();
return schedules.map((schedule) => ({
schedule,
resolved: resolveSchedule({
schedule,
serverId: schedule.serverId,
now,
agentsByKey,
projectNameByCwd,
agentDataLoaded: agentDirReadyHosts.has(schedule.serverId),
}),
}));
}, [schedules, agentsByKey, projectNameByCwd, agentDirReadyHosts]);
const visibleRows = useMemo<ScheduleRowView[]>(() => {
const singleHost = hosts.length <= 1;
return resolvedRows
.filter(
({ schedule, resolved }) =>
(selectedHost === ALL_HOSTS_OPTION_ID || schedule.serverId === selectedHost) &&
resolved.bucket === statusFilter,
)
.sort((a, b) => Date.parse(b.schedule.createdAt) - Date.parse(a.schedule.createdAt))
.map(({ schedule, resolved }) => ({
schedule,
targetLabel: resolved.target.label,
provider: resolved.target.provider,
state: resolved.state,
serverName: schedule.serverName,
singleHost,
}));
}, [resolvedRows, selectedHost, statusFilter, hosts.length]);
const showLoadError = isError && schedules.length === 0;
const showHostFilter = hosts.length > 1;
return (
<View style={styles.container}>
<MenuHeader title="Schedules" />
<SchedulesScreenBody
rows={visibleRows}
hostErrors={hostErrors}
hasSchedules={schedules.length > 0}
isInitialLoad={isInitialLoad}
showLoadError={showLoadError}
statusFilter={statusFilter}
onStatusFilterChange={setStatusFilter}
showHostFilter={showHostFilter}
hosts={hosts}
selectedHost={selectedHost}
onSelectHost={setSelectedHost}
<MenuHeader title="Schedules" rightContent={headerAction} />
<SchedulesBody
sections={sections}
isLoading={isLoading}
isError={isError}
error={error}
onRetry={refetch}
onCreate={openCreate}
onEdit={openEdit}
@@ -187,38 +74,24 @@ function SchedulesScreenContent(): ReactElement {
);
}
function SchedulesScreenBody({
rows,
hostErrors,
hasSchedules,
isInitialLoad,
showLoadError,
statusFilter,
onStatusFilterChange,
showHostFilter,
hosts,
selectedHost,
onSelectHost,
function SchedulesBody({
sections,
isLoading,
isError,
error,
onRetry,
onCreate,
onEdit,
}: {
rows: ScheduleRowView[];
hostErrors: ScheduleHostError[];
hasSchedules: boolean;
isInitialLoad: boolean;
showLoadError: boolean;
statusFilter: ScheduleBucket;
onStatusFilterChange: (value: ScheduleBucket) => void;
showHostFilter: boolean;
hosts: ReturnType<typeof useHosts>;
selectedHost: string;
onSelectHost: (serverId: string) => void;
sections: ScheduleHostSection[];
isLoading: boolean;
isError: boolean;
error: Error | null;
onRetry: () => void;
onCreate: () => void;
onEdit: (schedule: AggregatedSchedule) => void;
onEdit: (serverId: string, schedule: ScheduleSummary) => void;
}): ReactElement {
if (isInitialLoad) {
if (isLoading) {
return (
<View style={styles.centered}>
<LoadingSpinner size="large" color={styles.spinner.color} />
@@ -226,85 +99,76 @@ function SchedulesScreenBody({
);
}
if (showLoadError) {
if (isError) {
return (
<View style={styles.centered}>
<Text style={styles.message}>Unable to load schedules</Text>
<Text style={styles.message}>{error?.message ?? "Could not load schedules"}</Text>
<Button variant="ghost" onPress={onRetry} testID="schedules-retry">
Try again
Retry
</Button>
</View>
);
}
if (!hasSchedules) {
if (sections.length === 0) {
return (
<View style={styles.centered} testID="schedules-empty">
{hostErrors.length > 0 ? <ScheduleHostErrorsBanner errors={hostErrors} /> : null}
<View style={styles.centered}>
<Text style={styles.message}>No schedules yet</Text>
<Button variant="ghost" leftIcon={Plus} onPress={onCreate} testID="schedules-empty-new">
Create a schedule
</Button>
</View>
);
}
const emptyFilterText = statusFilter === "ended" ? "No ended schedules" : "No active schedules";
return (
<View style={styles.body}>
<View style={styles.filterRow}>
<View style={styles.filterRowControls}>
{showHostFilter ? (
<HostFilter
hosts={hosts}
selectedHost={selectedHost}
onSelectHost={onSelectHost}
triggerTestID="schedules-host-filter-trigger"
/>
) : null}
<SegmentedControl
size="sm"
value={statusFilter}
onValueChange={onStatusFilterChange}
options={STATUS_FILTER_OPTIONS}
testID="schedules-status-filter"
/>
</View>
<Button leftIcon={Plus} onPress={onCreate} size="sm" testID="schedules-new">
<Button leftIcon={Plus} onPress={onCreate} testID="schedules-empty-new">
New schedule
</Button>
</View>
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
testID="schedules-list"
>
{hostErrors.length > 0 ? <ScheduleHostErrorsBanner errors={hostErrors} /> : null}
{rows.length > 0 ? (
<SchedulesTable rows={rows} onEditSchedule={onEdit} />
) : (
<View style={styles.filterEmpty}>
<Text style={styles.filterEmptyText}>{emptyFilterText}</Text>
</View>
)}
</ScrollView>
</View>
);
}
return (
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
testID="schedules-sections"
>
{sections.map((section) => (
<ScheduleHostSectionView key={section.serverId} section={section} onEdit={onEdit} />
))}
</ScrollView>
);
}
function ScheduleHostErrorsBanner({ errors }: { errors: ScheduleHostError[] }): ReactElement {
function ScheduleHostSectionView({
section,
onEdit,
}: {
section: ScheduleHostSection;
onEdit: (serverId: string, schedule: ScheduleSummary) => void;
}): ReactElement {
const handleEdit = useCallback(
(schedule: ScheduleSummary) => {
onEdit(section.serverId, schedule);
},
[onEdit, section.serverId],
);
const emptyMessage = section.isOnline ? "No schedules" : "Host offline";
return (
<View style={styles.errorsBannerWrap}>
<View style={styles.errorsBanner} testID="schedules-host-errors">
{errors.map((error) => (
<Text key={error.serverId} style={styles.errorsBannerText}>
{`${error.serverName}: Could not load schedules`}
</Text>
))}
</View>
<View style={styles.section} testID={`schedules-section-${section.serverId}`}>
<Text style={styles.sectionTitle} testID={`schedules-section-title-${section.serverId}`}>
{section.serverName}
</Text>
{section.error ? <Text style={styles.sectionError}>{section.error}</Text> : null}
{section.schedules.length > 0 ? (
<SchedulesTable
serverId={section.serverId}
schedules={section.schedules}
onEditSchedule={handleEdit}
/>
) : null}
{section.schedules.length === 0 && !section.error ? (
<View style={styles.sectionEmpty}>
<Text style={styles.sectionEmptyText}>{emptyMessage}</Text>
</View>
) : null}
</View>
);
}
@@ -314,10 +178,6 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
backgroundColor: theme.colors.surface0,
},
body: {
flex: 1,
minHeight: 0,
},
centered: {
flex: 1,
justifyContent: "center",
@@ -325,50 +185,43 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[6],
padding: theme.spacing[6],
},
filterRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[3],
paddingHorizontal: { xs: theme.spacing[3], md: theme.spacing[6] },
paddingTop: theme.spacing[4],
},
filterRowControls: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
flexShrink: 1,
flexWrap: "wrap",
},
scroll: {
flex: 1,
minHeight: 0,
},
scrollContent: {
gap: theme.spacing[3],
gap: theme.spacing[4],
paddingTop: theme.spacing[4],
paddingBottom: theme.spacing[6],
},
errorsBannerWrap: {
paddingHorizontal: { xs: theme.spacing[3], md: theme.spacing[6] },
section: {
gap: theme.spacing[2],
},
errorsBanner: {
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
padding: theme.spacing[3],
gap: theme.spacing[1],
sectionTitle: {
width: "100%",
maxWidth: 720,
alignSelf: "center",
paddingHorizontal: theme.spacing[4],
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
},
errorsBannerText: {
color: theme.colors.palette.red[300],
fontSize: theme.fontSize.xs,
sectionError: {
width: "100%",
maxWidth: 720,
alignSelf: "center",
paddingHorizontal: theme.spacing[4],
color: theme.colors.destructive,
fontSize: theme.fontSize.sm,
},
filterEmpty: {
paddingHorizontal: { xs: theme.spacing[3], md: theme.spacing[6] },
paddingVertical: theme.spacing[6],
alignItems: "center",
sectionEmpty: {
width: "100%",
maxWidth: 720,
alignSelf: "center",
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
},
filterEmptyText: {
sectionEmptyText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
+102 -7
View File
@@ -1,18 +1,23 @@
import { useMemo, useState, useCallback, useEffect } from "react";
import { View, Text } from "react-native";
import { useMemo, useState, useCallback, useEffect, useRef } from "react";
import { Pressable, type PressableStateCallbackType, View, Text } from "react-native";
import { useIsFocused } from "@react-navigation/native";
import { router } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ChevronLeft } from "lucide-react-native";
import { ChevronDown, ChevronLeft, Server } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { MenuHeader } from "@/components/headers/menu-header";
import { Button } from "@/components/ui/button";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { AgentList } from "@/components/agent-list";
import { HostFilter } from "@/components/hosts/host-filter";
import { ALL_HOSTS_OPTION_ID } from "@/components/hosts/host-picker";
import { HostStatusDotSlot } from "@/components/hosts/host-picker";
import {
ALL_HOSTS_OPTION_ID,
getHostPickerLabel,
HostPicker,
} from "@/components/hosts/host-picker";
import { useAgentHistory } from "@/hooks/use-agent-history";
import { useHosts } from "@/runtime/host-runtime";
import { type HostProfile } from "@/types/host-connection";
import { buildOpenProjectRoute } from "@/utils/host-routes";
export function SessionsScreen() {
@@ -25,6 +30,71 @@ export function SessionsScreen() {
return <SessionsScreenContent />;
}
function SessionsHostFilter({
hosts,
selectedHost,
onSelectHost,
}: {
hosts: HostProfile[];
selectedHost: string;
onSelectHost: (serverId: string) => void;
}) {
const { theme } = useUnistyles();
const [isFilterOpen, setIsFilterOpen] = useState(false);
const filterAnchorRef = useRef<View>(null);
const selectedHostLabel = useMemo(
() => getHostPickerLabel(hosts, selectedHost, { includeAllHost: true }),
[hosts, selectedHost],
);
const handleFilterOpen = useCallback(() => setIsFilterOpen(true), []);
const filterTriggerStyle = useCallback(
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.filterTrigger,
Boolean(hovered) && styles.filterTriggerHovered,
pressed && styles.filterTriggerPressed,
],
[],
);
return (
<HostPicker
hosts={hosts}
value={selectedHost}
onSelect={onSelectHost}
open={isFilterOpen}
onOpenChange={setIsFilterOpen}
anchorRef={filterAnchorRef}
includeAllHost
searchable={false}
title="Filter by host"
desktopPlacement="bottom-start"
>
<View ref={filterAnchorRef} collapsable={false} style={styles.filterTriggerWrap}>
<Pressable
onPress={handleFilterOpen}
style={filterTriggerStyle}
testID="sessions-host-filter-trigger"
accessibilityRole="button"
accessibilityLabel={`Filter: ${selectedHostLabel}`}
>
{selectedHost === ALL_HOSTS_OPTION_ID ? (
<Server size={14} color={theme.colors.foregroundMuted} />
) : (
<HostStatusDotSlot serverId={selectedHost} />
)}
<Text style={styles.filterTriggerText} numberOfLines={1}>
{selectedHostLabel}
</Text>
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
</Pressable>
</View>
</HostPicker>
);
}
function SessionsScreenContent() {
const { theme } = useUnistyles();
const { t } = useTranslation();
@@ -82,11 +152,10 @@ function SessionsScreenContent() {
<MenuHeader title={t("sessions.title")} />
{showHostFilter ? (
<View style={styles.filterContainer}>
<HostFilter
<SessionsHostFilter
hosts={hosts}
selectedHost={selectedHost}
onSelectHost={setSelectedHost}
triggerTestID="sessions-host-filter-trigger"
/>
</View>
) : null}
@@ -138,6 +207,32 @@ const styles = StyleSheet.create((theme) => ({
},
paddingTop: theme.spacing[4],
},
filterTriggerWrap: {
alignSelf: "flex-start",
},
filterTrigger: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1.5],
alignSelf: "flex-start",
paddingVertical: theme.spacing[1.5],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface1,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
},
filterTriggerHovered: {
backgroundColor: theme.colors.surface2,
},
filterTriggerPressed: {
backgroundColor: theme.colors.surface3,
},
filterTriggerText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
},
emptyContainer: {
flex: 1,
justifyContent: "center",
@@ -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 browser tabs, including logged-in browser state. Only enable this for agents you trust.";
"Allow agents to access and control Paseo desktop browser tabs, including logged-in browser state. Only enable this for agents you trust.";
export interface BrowserToolsCardState {
isVisible: boolean;
@@ -1,5 +1,4 @@
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 {
@@ -23,14 +22,10 @@ interface BrowserStoreState extends BrowserIndexState {
}
function createBrowserId(): string {
let browserId: string;
if (typeof globalThis.crypto?.randomUUID === "function") {
browserId = globalThis.crypto.randomUUID();
} else {
const randomSuffix = Math.random().toString(16).slice(2) || "0";
browserId = `${Date.now()}-${randomSuffix}`;
return globalThis.crypto.randomUUID();
}
return BrowserAutomationBrowserIdSchema.parse(browserId);
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
export const useBrowserStore = create<BrowserStoreState>()(
@@ -66,7 +66,7 @@ describe("buildSidebarProjectRowModel", () => {
});
});
it("renders a single-workspace git project as an expandable section with the new workspace action", () => {
it("renders a single-workspace git project as an expandable section with the new worktree action", () => {
const result = buildSidebarProjectRowModel({
project: project({
projectKind: "git",
@@ -79,49 +79,13 @@ describe("buildSidebarProjectRowModel", () => {
kind: "project_section",
chevron: "expand",
trailingAction: {
kind: "new_workspace",
kind: "new_worktree",
target: { serverId: "srv", iconWorkingDir: "/repo" },
},
});
});
it("shows the new workspace action for a non-git project when the host supports workspace multiplicity", () => {
const result = buildSidebarProjectRowModel({
project: project({ projectKind: "directory", workspaces: [] }),
collapsed: false,
supportsMultiplicityByServerId: new Map([["srv", true]]),
});
expect(result.trailingAction).toEqual({
kind: "new_workspace",
target: { serverId: "srv", iconWorkingDir: "/repo" },
});
});
it("hides the new workspace action for a non-git project when the host lacks workspace multiplicity", () => {
const result = buildSidebarProjectRowModel({
project: project({ projectKind: "directory", workspaces: [] }),
collapsed: false,
supportsMultiplicityByServerId: new Map([["srv", false]]),
});
expect(result.trailingAction).toEqual({ kind: "none" });
});
it("still shows the new workspace action for a git project regardless of multiplicity", () => {
const result = buildSidebarProjectRowModel({
project: project({ projectKind: "git" }),
collapsed: false,
supportsMultiplicityByServerId: new Map([["srv", false]]),
});
expect(result.trailingAction).toEqual({
kind: "new_workspace",
target: { serverId: "srv", iconWorkingDir: "/repo" },
});
});
it("targets the project host, not route state, for new workspace actions", () => {
it("targets the project host, not route state, for new worktree actions", () => {
const result = buildSidebarProjectRowModel({
project: project({
hosts: [
@@ -134,34 +98,13 @@ describe("buildSidebarProjectRowModel", () => {
expect(result).toMatchObject({
trailingAction: {
kind: "new_workspace",
kind: "new_worktree",
target: { serverId: "host-b", iconWorkingDir: "/repo/b" },
},
});
});
it("targets the first multiplicity-capable host for a non-git project", () => {
const result = buildSidebarProjectRowModel({
project: project({
projectKind: "directory",
hosts: [
{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: false },
{ serverId: "host-b", iconWorkingDir: "/repo/b", canCreateWorktree: false },
],
}),
collapsed: false,
supportsMultiplicityByServerId: new Map([["host-b", true]]),
});
expect(result).toMatchObject({
trailingAction: {
kind: "new_workspace",
target: { serverId: "host-b", iconWorkingDir: "/repo/b" },
},
});
});
it("renders a multi-workspace git project as an expandable section with a new workspace action", () => {
it("renders a multi-workspace git project as an expandable section with a new worktree action", () => {
const result = buildSidebarProjectRowModel({
project: project({
projectKind: "git",
@@ -177,7 +120,7 @@ describe("buildSidebarProjectRowModel", () => {
kind: "project_section",
chevron: "expand",
trailingAction: {
kind: "new_workspace",
kind: "new_worktree",
target: { serverId: "srv", iconWorkingDir: "/repo" },
},
});
@@ -206,7 +149,7 @@ describe("buildSidebarProjectRowModel", () => {
kind: "project_section",
chevron: "collapse",
trailingAction: {
kind: "new_workspace",
kind: "new_worktree",
target: { serverId: "srv", iconWorkingDir: "/repo" },
},
});
@@ -6,7 +6,7 @@ export interface SidebarProjectHostTarget {
}
export type SidebarProjectTrailingAction =
| { kind: "new_workspace"; target: SidebarProjectHostTarget }
| { kind: "new_worktree"; target: SidebarProjectHostTarget }
| { kind: "none" };
export interface SidebarProjectSectionRowModel {
@@ -17,8 +17,6 @@ export interface SidebarProjectSectionRowModel {
export type SidebarProjectRowModel = SidebarProjectSectionRowModel;
const EMPTY_MULTIPLICITY_MAP: ReadonlyMap<string, boolean> = new Map();
function hostTarget(input: {
serverId: string;
iconWorkingDir: string;
@@ -42,18 +40,9 @@ export function resolveSidebarProjectIconTarget(
return null;
}
// A project can host a brand-new workspace on a host when that host can create a
// git worktree (git projects) OR the host supports running multiple independent
// workspaces per directory (`workspaceMultiplicity`), which is what lets non-git
// directories add a second workspace. Mirrors the gate used by the global "New
// workspace" affordances (use-global-new-workspace-action.ts and left-sidebar's
// SidebarNewWorkspaceHeaderRow): `canCreateWorktree || supportsMultiplicity`.
function resolveNewWorkspaceTarget(
project: SidebarProjectEntry,
supportsMultiplicityByServerId: ReadonlyMap<string, boolean>,
): SidebarProjectHostTarget | null {
function resolveNewWorktreeTarget(project: SidebarProjectEntry): SidebarProjectHostTarget | null {
for (const host of project.hosts) {
if (!host.canCreateWorktree && !supportsMultiplicityByServerId.get(host.serverId)) {
if (!host.canCreateWorktree) {
continue;
}
const target = hostTarget(host);
@@ -64,25 +53,18 @@ function resolveNewWorkspaceTarget(
return null;
}
function projectTrailingAction(
project: SidebarProjectEntry,
supportsMultiplicityByServerId: ReadonlyMap<string, boolean>,
): SidebarProjectTrailingAction {
const target = resolveNewWorkspaceTarget(project, supportsMultiplicityByServerId);
return target ? { kind: "new_workspace", target } : { kind: "none" };
function projectTrailingAction(project: SidebarProjectEntry): SidebarProjectTrailingAction {
const target = resolveNewWorktreeTarget(project);
return target ? { kind: "new_worktree", target } : { kind: "none" };
}
export function buildSidebarProjectRowModel(input: {
project: SidebarProjectEntry;
collapsed: boolean;
supportsMultiplicityByServerId?: ReadonlyMap<string, boolean>;
}): SidebarProjectRowModel {
return {
kind: "project_section",
chevron: input.collapsed ? "expand" : "collapse",
trailingAction: projectTrailingAction(
input.project,
input.supportsMultiplicityByServerId ?? EMPTY_MULTIPLICITY_MAP,
),
trailingAction: projectTrailingAction(input.project),
};
}
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.104-beta.4",
"version": "0.1.103",
"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.104-beta.4",
"@getpaseo/protocol": "0.1.104-beta.4",
"@getpaseo/server": "0.1.104-beta.4",
"@getpaseo/client": "0.1.103",
"@getpaseo/protocol": "0.1.103",
"@getpaseo/server": "0.1.103",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -25,7 +25,6 @@ export interface LocalDaemonPidInfo {
hostname?: string;
uid?: number;
listen?: string;
executablePath?: string;
desktopManaged?: boolean;
}
@@ -256,7 +255,6 @@ function readPidFile(pidPath: string): LocalDaemonPidInfo | null {
hostname: typeof parsed.hostname === "string" ? parsed.hostname : undefined,
uid: typeof parsed.uid === "number" ? parsed.uid : undefined,
listen: resolveListenField(parsed.listen, parsed.sockPath),
executablePath: typeof parsed.executablePath === "string" ? parsed.executablePath : undefined,
desktopManaged: parsed.desktopManaged === true ? true : undefined,
};
} catch {
+7 -8
View File
@@ -33,10 +33,9 @@ export async function runPairCommand(options: PairOptions): Promise<void> {
if (host) {
const client = await tryConnectToDaemon({ host, timeout: 1500 });
if (client) {
const supportsDaemonStatusRpc =
client.getLastServerInfoMessage()?.features?.daemonStatusRpc === true;
if (supportsDaemonStatusRpc) {
try {
try {
const serverInfo = await client.waitForServerInfo(PAIRING_DAEMON_RPC_TIMEOUT_MS);
if (serverInfo.features?.daemonStatusRpc === true) {
const offer = await client.getDaemonPairingOffer({
timeout: PAIRING_DAEMON_RPC_TIMEOUT_MS,
});
@@ -46,11 +45,11 @@ export async function runPairCommand(options: PairOptions): Promise<void> {
options,
);
return;
} catch {
// COMPAT(daemon-rpc-rollout): fall back to CLI-side pairing generation while
// old daemons lack daemonStatusRpc. Remove once the daemon floor is past
// v0.1.76; pairing should come from daemon.get_pairing_offer.
}
} catch {
// COMPAT(daemon-rpc-rollout): fall back to CLI-side pairing generation while
// old daemons lack daemonStatusRpc. Remove once the daemon floor is past
// v0.1.76; pairing should come from daemon.get_pairing_offer.
}
await client.close().catch(() => {});
}
@@ -1,99 +0,0 @@
import type { Command } from "commander";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, test, vi } from "vitest";
import type { CommandOptions } from "../../output/index.js";
import { runStatusCommand } from "./status.js";
const mocks = vi.hoisted(() => ({
execCommand: vi.fn(async () => ({ stdout: process.execPath, stderr: "" })),
findExecutable: vi.fn(async () => null),
getOrCreateServerId: vi.fn(() => "srv_test"),
loadConfig: vi.fn(() => ({
listen: "127.0.0.1:6767",
relayEnabled: true,
relayEndpoint: "relay.paseo.sh:443",
relayUseTls: false,
relayPublicUseTls: false,
})),
resolvePaseoHome: vi.fn((env: NodeJS.ProcessEnv) => env.PASEO_HOME ?? "/tmp/paseo"),
spawnProcess: vi.fn(),
}));
vi.mock("@getpaseo/server", () => ({
execCommand: mocks.execCommand,
findExecutable: mocks.findExecutable,
getOrCreateServerId: mocks.getOrCreateServerId,
loadConfig: mocks.loadConfig,
resolvePaseoHome: mocks.resolvePaseoHome,
spawnProcess: mocks.spawnProcess,
}));
const tempRoots: string[] = [];
async function createStatusHome(pidLock: Record<string, unknown>): Promise<string> {
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-daemon-status-"));
tempRoots.push(root);
const home = path.join(root, ".paseo");
await mkdir(home, { recursive: true });
await writeFile(path.join(home, "paseo.pid"), JSON.stringify(pidLock));
return home;
}
async function readStatusJson(home: string): Promise<Record<string, unknown>> {
const previousPath = process.env.PATH;
try {
process.env.PATH = "";
const result = await runStatusCommand({ home } as CommandOptions, {} as Command);
return result.schema.serialize?.(result.data[0]) as Record<string, unknown>;
} finally {
if (previousPath === undefined) {
delete process.env.PATH;
} else {
process.env.PATH = previousPath;
}
}
}
describe("daemon status desktop management fields", () => {
afterEach(async () => {
await Promise.all(
tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
);
});
test("reports executable identity without deriving desktop management in the CLI", async () => {
const executablePath =
"/Applications/Paseo.app/Contents/Frameworks/Paseo Helper.app/Contents/MacOS/Paseo Helper";
const home = await createStatusHome({
pid: process.pid,
startedAt: "2026-07-06T00:00:00.000Z",
hostname: "dev-host",
uid: 501,
listen: "/tmp/paseo-status.sock",
executablePath,
});
await expect(readStatusJson(home)).resolves.toMatchObject({
daemonExecutablePath: executablePath,
desktopManaged: false,
});
});
test("keeps reporting legacy desktopManaged locks for old readers", async () => {
const home = await createStatusHome({
pid: process.pid,
startedAt: "2026-07-06T00:00:00.000Z",
hostname: "dev-host",
uid: 501,
listen: "/tmp/paseo-status.sock",
desktopManaged: true,
});
await expect(readStatusJson(home)).resolves.toMatchObject({
daemonExecutablePath: null,
desktopManaged: true,
});
});
});
+4 -9
View File
@@ -1,6 +1,7 @@
import type { Command } from "commander";
import { createRequire } from "node:module";
import { getOrCreateServerId, findExecutable, execCommand } from "@getpaseo/server";
import { AGENT_PROVIDER_DEFINITIONS } from "@getpaseo/protocol/provider-manifest";
import { connectToDaemon } from "../../utils/client.js";
import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js";
import { resolveLocalDaemonState, resolveTcpHostFromListen } from "./local-daemon.js";
@@ -28,7 +29,6 @@ interface DaemonStatus {
owner: string | null;
logPath: string;
daemonNode: string;
daemonExecutablePath: string | null;
cliNode: string;
cliVersion: string;
daemonVersion: string | null;
@@ -127,7 +127,6 @@ function toStatusRows(status: DaemonStatus): StatusRow[] {
{ key: "Owner", value: status.owner ?? "-" },
{ key: "Logs", value: status.logPath },
{ key: "Daemon Node", value: status.daemonNode },
{ key: "PID Lock Executable", value: status.daemonExecutablePath ?? "-" },
{ key: "CLI Node", value: status.cliNode },
{ key: "CLI", value: status.cliVersion },
{ key: "Daemon Version", value: status.daemonVersion ?? "-" },
@@ -158,11 +157,9 @@ function toStatusRows(status: DaemonStatus): StatusRow[] {
return rows;
}
const PROVIDER_BINARIES: { label: string; binary: string }[] = [
{ label: "Claude", binary: "claude" },
{ label: "Codex", binary: "codex" },
{ label: "OpenCode", binary: "opencode" },
];
const PROVIDER_BINARIES: { label: string; binary: string }[] = AGENT_PROVIDER_DEFINITIONS.filter(
(provider) => provider.voice?.enabled === true && provider.defaultModeId !== null,
).map((provider) => ({ label: provider.label, binary: provider.id }));
async function checkProviderBinary(
binary: string,
@@ -407,11 +404,9 @@ export async function runStatusCommand(
owner,
logPath: state.logPath,
daemonNode,
daemonExecutablePath: state.pidInfo?.executablePath ?? null,
cliNode,
cliVersion,
daemonVersion,
// COMPAT(desktopManagedPidLock): added in v0.1.105, remove after 2027-01-06 once locks without executablePath have aged out.
desktopManaged: state.pidInfo?.desktopManaged === true,
providers,
note,
+1 -4
View File
@@ -50,10 +50,7 @@ export function addLoopRunOptions(command: Command): Command {
.argument("<prompt>", "Prompt for each fresh worker iteration")
.option("--provider <provider>", "Default provider for worker and verifier agents")
.option("--model <model>", "Default model for worker and verifier agents")
.option(
"--mode <mode>",
"Provider-specific mode for the worker agent (e.g. claude bypassPermissions, opencode build)",
)
.option("--mode <mode>", "Provider-specific mode for the worker agent")
.option("--verify-provider <provider>", "Provider for the verifier agent")
.option("--verify-model <model>", "Model for the verifier agent")
.option("--verify-mode <mode>", "Provider-specific mode for the verifier agent")
@@ -0,0 +1,710 @@
import { describe, expect, it } from "vitest";
import { render } from "../../output/index.js";
import { runAddCommand } from "./add.js";
interface CatalogEntry {
id: string;
label: string;
api: string;
baseUrl: string;
auth: Record<string, unknown>;
models: Array<{ id: string; label?: string; reasoning?: boolean }>;
}
interface RecordingClientInput {
catalog: CatalogEntry[];
features?: Record<string, unknown>;
setProvider?: (input: {
name: string;
providerType: string;
options: { apiKey?: string; models?: Array<{ id: string }> };
}) => Promise<unknown>;
startOAuth?: (
name: string,
options?: { mode?: string; requestId?: string } | string,
) => Promise<unknown>;
completeOAuth?: (name: string) => Promise<unknown>;
storeCredential?: (input: { name: string; credential: unknown }) => Promise<unknown>;
}
function createClient(input: RecordingClientInput) {
return {
waitForServerInfo: async () => ({
status: "server_info",
serverId: "test-daemon",
features: input.features ?? { paseoAgentCatalog: true },
}),
getPaseoAgentCatalog: async () => ({
requestId: "catalog-1",
catalog: input.catalog,
error: null,
}),
setPaseoAgentProvider: async (providerInput: {
name: string;
providerType: string;
options: { apiKey?: string; models?: Array<{ id: string }> };
}) => {
if (input.setProvider) {
return input.setProvider(providerInput);
}
return {
requestId: "set-1",
success: true,
provider: {
name: providerInput.name,
providerType: providerInput.providerType,
models: providerInput.options.models ?? [],
auth: { kind: "api_key", configured: true, source: "literal" },
available: true,
error: null,
},
error: null,
};
},
startPaseoAgentOAuth: async (
name: string,
options?: { mode?: string; requestId?: string } | string,
) =>
input.startOAuth?.(name, options) ?? {
requestId: "oauth-start-1",
success: true,
name,
authorization: {
kind: "device_code",
userCode: "ABCD-EFGH",
verificationUri: "https://auth.example.test/device",
intervalSeconds: 5,
expiresInSeconds: 900,
},
error: null,
},
completePaseoAgentOAuth: async (name: string) =>
input.completeOAuth?.(name) ?? {
requestId: "oauth-complete-1",
success: true,
name,
auth: { kind: "oauth", configured: true, source: "stored" },
error: null,
},
storePaseoAgentOAuthCredential: async (credentialInput: {
name: string;
credential: unknown;
}) =>
input.storeCredential?.(credentialInput) ?? {
requestId: "oauth-store-1",
success: true,
name: credentialInput.name,
auth: { kind: "oauth", configured: true, source: "stored" },
error: null,
},
close: async () => {},
};
}
function apiKeyEntry(overrides: Partial<CatalogEntry> = {}): CatalogEntry {
return {
id: "alpha-key",
label: "Alpha Key",
api: "test-api",
baseUrl: "https://alpha.example.test",
auth: {
kind: "api_key",
envVar: "ALPHA_API_KEY",
hint: "Create an Alpha key before continuing.",
keyUrl: "https://alpha.example.test/keys",
placeholder: "Alpha API key",
},
models: [{ id: "alpha-model", label: "Alpha Model", reasoning: true }],
...overrides,
};
}
function oauthEntry(overrides: Partial<CatalogEntry> = {}): CatalogEntry {
return {
id: "beta-oauth",
label: "Beta OAuth",
api: "test-oauth-api",
baseUrl: "https://beta.example.test",
auth: { kind: "oauth", flow: "beta-flow" },
models: [{ id: "beta-model" }],
...overrides,
};
}
describe("provider add", () => {
it("configures an API-key provider from a hidden prompt without echoing the key", async () => {
const setCalls: unknown[] = [];
const prompts: string[] = [];
const output: string[] = [];
const result = await runAddCommand(
"alpha-key",
{ host: "localhost:7777", name: "alpha-main" },
{} as never,
{
write: (message) => output.push(message),
promptSecret: async (message) => {
prompts.push(message);
return "redaction-sentinel";
},
promptText: async () => {
throw new Error("text prompt should not be used");
},
readStdin: async () => {
throw new Error("stdin should not be read");
},
connectDaemon: async (options) => {
expect(options.host).toBe("localhost:7777");
return createClient({
catalog: [apiKeyEntry()],
setProvider: async (input) => {
setCalls.push(input);
return {
requestId: "set-1",
success: true,
provider: {
name: input.name,
providerType: input.providerType,
models: input.options.models ?? [],
auth: { kind: "api_key", configured: true, source: "literal" },
available: true,
error: null,
},
error: null,
};
},
});
},
},
);
expect(setCalls).toEqual([
{
name: "alpha-main",
providerType: "alpha-key",
options: {
apiKey: "redaction-sentinel",
models: [{ id: "alpha-model", label: "Alpha Model", reasoning: true }],
},
},
]);
expect(prompts).toEqual(["Enter Alpha API key (leave empty to use $ALPHA_API_KEY):"]);
expect(output.join("\n")).toContain("Create an Alpha key");
expect(output.join("\n")).toContain("https://alpha.example.test/keys");
expect(render(result, { format: "json" })).not.toContain("redaction-sentinel");
expect(render(result, { format: "table", noColor: true })).toContain("alpha-main");
});
it("stores an environment reference when the API-key prompt is empty", async () => {
const setCalls: unknown[] = [];
const result = await runAddCommand("alpha-key", {}, {} as never, {
promptSecret: async () => "",
promptText: async () => {
throw new Error("text prompt should not be used");
},
readStdin: async () => {
throw new Error("stdin should not be read");
},
write: () => {},
connectDaemon: async () =>
createClient({
catalog: [apiKeyEntry()],
setProvider: async (input) => {
setCalls.push(input);
return {
requestId: "set-1",
success: true,
provider: {
name: input.name,
providerType: input.providerType,
models: input.options.models ?? [],
auth: { kind: "api_key", configured: false, source: "env" },
available: false,
error: null,
},
error: null,
};
},
}),
});
expect(setCalls).toEqual([
{
name: "alpha-key",
providerType: "alpha-key",
options: {
apiKey: "$ALPHA_API_KEY",
models: [{ id: "alpha-model", label: "Alpha Model", reasoning: true }],
},
},
]);
expect(result.data.auth).toBe("Needs attention");
});
it("reads an API key from stdin for scripts", async () => {
const setCalls: unknown[] = [];
await runAddCommand("alpha-key", { apiKeyStdin: true, model: ["one,two"] }, {} as never, {
readStdin: async () => "stdin-secret\n",
promptSecret: async () => {
throw new Error("prompt should not be used with --api-key-stdin");
},
promptText: async () => {
throw new Error("text prompt should not be used");
},
write: () => {},
connectDaemon: async () =>
createClient({
catalog: [apiKeyEntry({ models: [] })],
setProvider: async (input) => {
setCalls.push(input);
return {
requestId: "set-1",
success: true,
provider: {
name: input.name,
providerType: input.providerType,
models: input.options.models ?? [],
auth: { kind: "api_key", configured: true, source: "literal" },
available: true,
error: null,
},
error: null,
};
},
}),
});
expect(setCalls).toEqual([
{
name: "alpha-key",
providerType: "alpha-key",
options: {
apiKey: "stdin-secret",
models: [{ id: "one" }, { id: "two" }],
},
},
]);
});
it("configures an API-key provider without model defaults", async () => {
const setCalls: unknown[] = [];
const result = await runAddCommand("alpha-key", { apiKeyStdin: true }, {} as never, {
readStdin: async () => "stdin-secret\n",
promptSecret: async () => {
throw new Error("prompt should not be used with --api-key-stdin");
},
promptText: async () => {
throw new Error("text prompt should not be used");
},
write: () => {},
connectDaemon: async () =>
createClient({
catalog: [apiKeyEntry({ models: [] })],
setProvider: async (input) => {
setCalls.push(input);
return {
requestId: "set-1",
success: true,
provider: {
name: input.name,
providerType: input.providerType,
models: [],
auth: { kind: "api_key", configured: true, source: "literal" },
available: true,
error: null,
},
error: null,
};
},
}),
});
expect(setCalls).toEqual([
{
name: "alpha-key",
providerType: "alpha-key",
options: {
apiKey: "stdin-secret",
},
},
]);
expect(result.data).toMatchObject({
name: "alpha-key",
auth: "Connected",
available: "yes",
models: "-",
});
});
it("runs browser OAuth locally and pushes the credential to the selected daemon", async () => {
const order: string[] = [];
const stored: unknown[] = [];
const openedUrls: string[] = [];
const result = await runAddCommand(
"beta-oauth",
{ host: "tcp://remote:7777?ssl=true&password=secret", name: "beta-main" },
{} as never,
{
write: (message) => order.push(`write:${message}`),
openBrowser: (url) => {
openedUrls.push(url);
return true;
},
loginBrowserCredential: async (options) => {
expect(options.flow).toBe("beta-flow");
order.push("browser-login");
options.onAuthUrl("https://auth.example.test/browser", "Authorize in the browser.");
return { type: "oauth", access: "access-token", refresh: "refresh-token", expires: 123 };
},
promptText: async () => {
throw new Error("manual prompt should not be used");
},
promptSecret: async () => {
throw new Error("secret prompt should not be used");
},
readStdin: async () => {
throw new Error("stdin should not be read");
},
connectDaemon: async () =>
createClient({
catalog: [oauthEntry()],
setProvider: async (input) => {
order.push("set-provider");
return {
requestId: "set-1",
success: true,
provider: {
name: input.name,
providerType: input.providerType,
models: input.options.models ?? [],
auth: { kind: "oauth", configured: false },
available: false,
error: null,
},
error: null,
};
},
storeCredential: async (input) => {
stored.push(input);
return {
requestId: "store-1",
success: true,
name: input.name,
auth: { kind: "oauth", configured: true, source: "stored" },
error: null,
};
},
}),
},
);
expect(order).toEqual([
"set-provider",
"browser-login",
"write:Authorize in the browser.",
"write: https://auth.example.test/browser",
"write:Waiting for you to approve in the browser...",
"write:Credential accepted by selected daemon (tcp://remote:7777?ssl=true).",
]);
expect(stored).toEqual([
{
name: "beta-main",
credential: {
type: "oauth",
access: "access-token",
refresh: "refresh-token",
expires: 123,
},
},
]);
expect(openedUrls).toEqual(["https://auth.example.test/browser"]);
expect(result.data.auth).toBe("Connected");
expect(order.join("\n")).not.toContain("secret");
});
it("uses daemon-run OAuth when --device-code is passed", async () => {
const output: string[] = [];
const oauthCalls: string[] = [];
const result = await runAddCommand("beta-oauth", { deviceCode: true }, {} as never, {
write: (message) => output.push(message),
openBrowser: () => {
throw new Error("browser should not be opened for --device-code");
},
loginBrowserCredential: async () => {
throw new Error("browser login should not run for --device-code");
},
promptText: async () => {
throw new Error("prompt should not be used");
},
promptSecret: async () => {
throw new Error("secret prompt should not be used");
},
readStdin: async () => {
throw new Error("stdin should not be read");
},
connectDaemon: async () =>
createClient({
catalog: [oauthEntry()],
startOAuth: async (name, options) => {
oauthCalls.push(
`start:${name}:${typeof options === "string" ? options : options?.mode}`,
);
return {
requestId: "start-1",
success: true,
name,
authorization: {
kind: "device_code",
userCode: "ABCD-EFGH",
verificationUri: "https://auth.example.test/device",
expiresInSeconds: 900,
},
error: null,
};
},
completeOAuth: async (name) => {
oauthCalls.push(`complete:${name}`);
return {
requestId: "complete-1",
success: true,
name,
auth: { kind: "oauth", configured: true, source: "stored" },
error: null,
};
},
}),
});
expect(oauthCalls).toEqual(["start:beta-oauth:device_code", "complete:beta-oauth"]);
expect(output.join("\n")).toContain("ABCD-EFGH");
expect(output.join("\n")).toContain("https://auth.example.test/device");
expect(result.data.auth).toBe("Connected");
});
it("falls back to daemon-run OAuth when the browser cannot open", async () => {
const output: string[] = [];
const oauthCalls: string[] = [];
await runAddCommand("beta-oauth", {}, {} as never, {
write: (message) => output.push(message),
openBrowser: () => false,
loginBrowserCredential: async (options) => {
options.onAuthUrl("https://auth.example.test/browser");
throw new Error("onAuthUrl should have switched to device-code");
},
promptText: async () => {
throw new Error("prompt should not be used");
},
promptSecret: async () => {
throw new Error("secret prompt should not be used");
},
readStdin: async () => {
throw new Error("stdin should not be read");
},
connectDaemon: async () =>
createClient({
catalog: [oauthEntry()],
startOAuth: async (name, options) => {
oauthCalls.push(
`start:${name}:${typeof options === "string" ? options : options?.mode}`,
);
return {
requestId: "start-1",
success: true,
name,
authorization: {
kind: "device_code",
userCode: "WXYZ-1234",
verificationUri: "https://auth.example.test/device",
},
error: null,
};
},
completeOAuth: async (name) => {
oauthCalls.push(`complete:${name}`);
return {
requestId: "complete-1",
success: true,
name,
auth: { kind: "oauth", configured: true, source: "stored" },
error: null,
};
},
}),
});
expect(oauthCalls).toEqual(["start:beta-oauth:device_code", "complete:beta-oauth"]);
expect(output.join("\n")).toContain("Browser could not be opened");
expect(output.join("\n")).toContain("WXYZ-1234");
});
it("updates the same instance on repeated add calls", async () => {
const setCalls: unknown[] = [];
const client = createClient({
catalog: [apiKeyEntry()],
setProvider: async (input) => {
setCalls.push(input);
return {
requestId: "set-1",
success: true,
provider: {
name: input.name,
providerType: input.providerType,
models: input.options.models ?? [],
auth: { kind: "api_key", configured: true, source: "literal" },
available: true,
error: null,
},
error: null,
};
},
});
const dependencies = {
promptSecret: async () => "secret",
promptText: async () => {
throw new Error("text prompt should not be used");
},
readStdin: async () => {
throw new Error("stdin should not be read");
},
write: () => {},
connectDaemon: async () => client,
};
await runAddCommand("alpha-key", { name: "same-name" }, {} as never, dependencies);
await runAddCommand("alpha-key", { name: "same-name" }, {} as never, dependencies);
expect(setCalls).toHaveLength(2);
expect(setCalls).toEqual([
expect.objectContaining({ name: "same-name", providerType: "alpha-key" }),
expect.objectContaining({ name: "same-name", providerType: "alpha-key" }),
]);
});
it("mentions known provider ids for an unknown catalog id", async () => {
await expect(
runAddCommand("missing-key", {}, {} as never, {
promptSecret: async () => {
throw new Error("prompt should not run");
},
promptText: async () => {
throw new Error("text prompt should not run");
},
readStdin: async () => {
throw new Error("stdin should not run");
},
write: () => {},
connectDaemon: async () =>
createClient({
catalog: [apiKeyEntry({ id: "alpha-key" }), apiKeyEntry({ id: "gamma-key" })],
setProvider: async () => {
throw new Error("set should not run");
},
}),
}),
).rejects.toMatchObject({
code: "UNKNOWN_PROVIDER",
message:
'Unknown model provider type "missing-key". Known provider ids: alpha-key, gamma-key.',
});
});
it("requires the catalog feature flag before reading the catalog", async () => {
const calls: string[] = [];
await expect(
runAddCommand("alpha-key", {}, {} as never, {
promptSecret: async () => {
throw new Error("prompt should not run");
},
promptText: async () => {
throw new Error("text prompt should not run");
},
readStdin: async () => {
throw new Error("stdin should not run");
},
write: () => {},
connectDaemon: async () => ({
waitForServerInfo: async () => ({
status: "server_info",
serverId: "test-daemon",
features: {},
}),
getPaseoAgentCatalog: async () => {
calls.push("catalog");
throw new Error("catalog should not be read");
},
setPaseoAgentProvider: async () => {
calls.push("set");
throw new Error("set should not run");
},
startPaseoAgentOAuth: async () => {
throw new Error("oauth should not run");
},
completePaseoAgentOAuth: async () => {
throw new Error("oauth should not run");
},
storePaseoAgentOAuthCredential: async () => {
throw new Error("oauth should not run");
},
close: async () => {},
}),
}),
).rejects.toMatchObject({
code: "HOST_UPDATE_REQUIRED",
message: "Update the Paseo daemon to use this command.",
});
expect(calls).toEqual([]);
});
it("lets the user choose a provider when no id is passed", async () => {
const setCalls: unknown[] = [];
const output: string[] = [];
await runAddCommand(undefined, {}, {} as never, {
write: (message) => output.push(message),
promptText: async (message) => {
expect(message).toBe("Select provider:");
return "2";
},
promptSecret: async () => "chosen-secret",
readStdin: async () => {
throw new Error("stdin should not be read");
},
connectDaemon: async () =>
createClient({
catalog: [
apiKeyEntry({ id: "first-key", label: "First Key" }),
apiKeyEntry({ id: "second-key", label: "Second Key" }),
],
setProvider: async (input) => {
setCalls.push(input);
return {
requestId: "set-1",
success: true,
provider: {
name: input.name,
providerType: input.providerType,
models: input.options.models ?? [],
auth: { kind: "api_key", configured: true, source: "literal" },
available: true,
error: null,
},
error: null,
};
},
}),
});
expect(output.join("\n")).toContain("First Key (first-key)");
expect(output.join("\n")).toContain("Second Key (second-key)");
expect(setCalls).toEqual([
expect.objectContaining({ name: "second-key", providerType: "second-key" }),
]);
});
});
+510
View File
@@ -0,0 +1,510 @@
import { createInterface } from "node:readline/promises";
import { Writable } from "node:stream";
import type { Command } from "commander";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type {
PaseoAgentCatalogEntry,
PaseoAgentOAuthCredential,
PaseoAgentProviderAuthState,
RedactedPaseoAgentProviderConfig,
} from "@getpaseo/protocol/messages";
import { loginOAuthBrowser } from "@getpaseo/server";
import { connectToDaemon } from "../../utils/client.js";
import { collectMultiple } from "../../utils/command-options.js";
import { openBrowserUrl } from "../../utils/open-browser.js";
import { requirePaseoAgentCatalogFeature } from "./feature.js";
import type {
CommandError,
CommandOptions,
OutputSchema,
SingleResult,
} from "../../output/index.js";
interface ProviderAddOptions extends CommandOptions {
name?: string;
apiKeyStdin?: boolean;
deviceCode?: boolean;
model?: string[];
}
interface ProviderModelInput {
id: string;
label?: string;
api?: string;
reasoning?: boolean;
contextWindow?: number;
maxTokens?: number;
}
interface ProviderConfiguredItem {
name: string;
providerType: string;
label: string;
auth: string;
available: string;
models: string;
}
interface ProviderAddClient extends Pick<
DaemonClient,
| "waitForServerInfo"
| "getPaseoAgentCatalog"
| "setPaseoAgentProvider"
| "startPaseoAgentOAuth"
| "completePaseoAgentOAuth"
| "storePaseoAgentOAuthCredential"
| "close"
> {}
export interface ProviderAddDependencies {
connectDaemon: (options: { host?: string }) => Promise<ProviderAddClient>;
readStdin: () => Promise<string>;
promptText: (message: string) => Promise<string>;
promptSecret: (message: string) => Promise<string>;
loginBrowserCredential: typeof loginOAuthBrowser;
openBrowser: (url: string) => boolean;
write: (message: string) => void;
}
const defaultDependencies: ProviderAddDependencies = {
connectDaemon: connectToDaemon,
readStdin,
promptText,
promptSecret,
loginBrowserCredential: loginOAuthBrowser,
openBrowser: openBrowserUrl,
write: (message) => console.error(message),
};
export const providerConfiguredSchema: OutputSchema<ProviderConfiguredItem> = {
idField: "name",
columns: [
{ header: "NAME", field: "name", width: 20 },
{ header: "TYPE", field: "providerType", width: 16 },
{ header: "LABEL", field: "label", width: 22 },
{ header: "AUTH", field: "auth", width: 16 },
{ header: "AVAILABLE", field: "available", width: 10 },
{ header: "MODELS", field: "models", width: 50 },
],
};
async function readStdin(): Promise<string> {
process.stdin.setEncoding("utf8");
let value = "";
for await (const chunk of process.stdin) {
value += chunk;
}
return value;
}
async function promptText(message: string): Promise<string> {
const rl = createInterface({ input: process.stdin, output: process.stdout });
try {
return (await rl.question(`${message} `)).trim();
} finally {
rl.close();
}
}
async function promptSecret(message: string): Promise<string> {
const output = new Writable({
write(chunk, _encoding, callback) {
const text = String(chunk);
if (text.includes(message)) {
process.stdout.write(text);
}
callback();
},
}) as Writable & { isTTY?: boolean };
output.isTTY = true;
const rl = createInterface({ input: process.stdin, output, terminal: true });
try {
return (await rl.question(`${message} `)).trim();
} finally {
process.stdout.write("\n");
rl.close();
}
}
function authField(entry: PaseoAgentCatalogEntry, field: string): string | undefined {
const auth = entry.auth;
const value = auth[field];
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
function requireAuthField(
entry: PaseoAgentCatalogEntry,
field: string,
description: string,
): string {
const value = authField(entry, field);
if (value) return value;
throw {
code: "UNSUPPORTED_PROVIDER_AUTH",
message: `Provider ${entry.id} is missing ${description}. Update the Paseo daemon to use this command.`,
} satisfies CommandError;
}
function apiKeyEnvVar(entry: PaseoAgentCatalogEntry): string {
return requireAuthField(entry, "envVar", "an API key environment variable");
}
function oauthFlow(entry: PaseoAgentCatalogEntry): string {
return requireAuthField(entry, "flow", "an OAuth flow");
}
function normalizeModels(rawModels: string[] | undefined): string[] {
return (rawModels ?? [])
.flatMap((value) => value.split(","))
.map((value) => value.trim())
.filter(Boolean);
}
function catalogModels(entry: PaseoAgentCatalogEntry): ProviderModelInput[] {
return entry.models.map((model) => ({
id: model.id,
...(model.label ? { label: model.label } : {}),
...(model.api ? { api: model.api } : {}),
...(model.reasoning !== undefined ? { reasoning: model.reasoning } : {}),
...(model.contextWindow !== undefined ? { contextWindow: model.contextWindow } : {}),
...(model.maxTokens !== undefined ? { maxTokens: model.maxTokens } : {}),
}));
}
function resolveModels(
entry: PaseoAgentCatalogEntry,
options: ProviderAddOptions,
): ProviderModelInput[] | undefined {
const modelIds = normalizeModels(options.model);
if (modelIds.length > 0) {
return modelIds.map((id) => ({ id }));
}
const models = catalogModels(entry);
if (models.length > 0) {
return models;
}
return undefined;
}
async function selectCatalogEntry(
catalog: PaseoAgentCatalogEntry[],
dependencies: ProviderAddDependencies,
): Promise<PaseoAgentCatalogEntry> {
if (catalog.length === 0) {
throw {
code: "EMPTY_PROVIDER_CATALOG",
message: "The Paseo daemon returned an empty provider catalog.",
} satisfies CommandError;
}
dependencies.write("Available model providers:");
catalog.forEach((entry, index) => {
dependencies.write(` ${index + 1}. ${entry.label} (${entry.id})`);
});
const answer = await dependencies.promptText("Select provider:");
const selectedIndex = Number(answer);
const byIndex = Number.isInteger(selectedIndex) ? catalog[selectedIndex - 1] : undefined;
const byId = catalog.find((entry) => entry.id === answer);
const selected = byIndex ?? byId;
if (selected) {
return selected;
}
throw {
code: "INVALID_PROVIDER_SELECTION",
message: `Invalid provider selection: ${answer}`,
} satisfies CommandError;
}
async function resolveEntry(
id: string | undefined,
catalog: PaseoAgentCatalogEntry[],
dependencies: ProviderAddDependencies,
): Promise<PaseoAgentCatalogEntry> {
if (!id) {
return selectCatalogEntry(catalog, dependencies);
}
const entry = catalog.find((candidate) => candidate.id === id);
if (entry) {
return entry;
}
const knownIds = catalog.map((candidate) => candidate.id).join(", ");
throw {
code: "UNKNOWN_PROVIDER",
message: `Unknown model provider type "${id}". Known provider ids: ${knownIds}.`,
} satisfies CommandError;
}
function formatAuthState(provider: RedactedPaseoAgentProviderConfig): string {
if (!provider.auth) {
return "not configured";
}
return provider.auth.configured ? "Connected" : "Needs attention";
}
function toConfiguredItem(
provider: RedactedPaseoAgentProviderConfig,
entry: PaseoAgentCatalogEntry,
): ProviderConfiguredItem {
return {
name: provider.name,
providerType: provider.providerType,
label: entry.label,
auth: formatAuthState(provider),
available: provider.available ? "yes" : "no",
models: provider.models.map((model) => model.id).join(", ") || "-",
};
}
function formatDaemonTarget(host: string | undefined): string {
if (!host) {
return "local daemon";
}
try {
if (host.startsWith("tcp://")) {
const url = new URL(host);
url.searchParams.delete("password");
return `selected daemon (${url.toString()})`;
}
} catch {
return `selected daemon (${host})`;
}
return `selected daemon (${host})`;
}
async function resolveApiKey(
entry: PaseoAgentCatalogEntry,
options: ProviderAddOptions,
dependencies: ProviderAddDependencies,
): Promise<string> {
const envVar = apiKeyEnvVar(entry);
if (options.apiKeyStdin) {
const value = (await dependencies.readStdin()).trim();
if (value) {
return value;
}
throw {
code: "MISSING_API_KEY",
message: "No API key was read from stdin.",
} satisfies CommandError;
}
const hint = authField(entry, "hint");
const keyUrl = authField(entry, "keyUrl");
if (hint) {
dependencies.write(hint);
}
if (keyUrl) {
dependencies.write(`API key URL: ${keyUrl}`);
}
const placeholder = authField(entry, "placeholder") ?? "API key";
const value = await dependencies.promptSecret(
`Enter ${placeholder} (leave empty to use $${envVar}):`,
);
return value || `$${envVar}`;
}
function isBrowserOpenError(error: unknown): boolean {
return (
typeof error === "object" &&
error !== null &&
"code" in error &&
(error as CommandError).code === "BROWSER_OPEN_FAILED"
);
}
function printOAuthAuthorization(
authorization: Awaited<ReturnType<ProviderAddClient["startPaseoAgentOAuth"]>>["authorization"],
dependencies: ProviderAddDependencies,
): void {
if (!authorization) {
dependencies.write("Authorization completed; waiting for the daemon to store credentials...");
return;
}
if (authorization.instructions) {
dependencies.write(authorization.instructions);
}
if (authorization.verificationUri) {
dependencies.write(`Open: ${authorization.verificationUri}`);
}
if (authorization.userCode) {
dependencies.write(`Code: ${authorization.userCode}`);
}
if (authorization.url) {
dependencies.write(`Open: ${authorization.url}`);
}
if (authorization.expiresInSeconds) {
dependencies.write(
`Expires in about ${Math.round(authorization.expiresInSeconds / 60)} minutes.`,
);
}
dependencies.write("Waiting for authorization...");
}
async function runDaemonOAuth(
client: ProviderAddClient,
name: string,
dependencies: ProviderAddDependencies,
): Promise<PaseoAgentProviderAuthState | undefined> {
const started = await client.startPaseoAgentOAuth(name, { mode: "device_code" });
if (!started.success) {
throw {
code: "OAUTH_START_FAILED",
message: started.error ?? "Daemon rejected the OAuth start request.",
} satisfies CommandError;
}
printOAuthAuthorization(started.authorization, dependencies);
const completed = await client.completePaseoAgentOAuth(name);
if (!completed.success) {
throw {
code: "OAUTH_COMPLETE_FAILED",
message: completed.error ?? "Daemon did not complete OAuth.",
} satisfies CommandError;
}
return completed.auth;
}
async function runBrowserOAuth(
client: ProviderAddClient,
entry: PaseoAgentCatalogEntry,
name: string,
options: ProviderAddOptions,
dependencies: ProviderAddDependencies,
): Promise<PaseoAgentProviderAuthState | undefined> {
const target = formatDaemonTarget(options.host);
const credential: PaseoAgentOAuthCredential = await dependencies.loginBrowserCredential({
flow: oauthFlow(entry),
onAuthUrl: (url, instructions) => {
const opened = dependencies.openBrowser(url);
if (!opened) {
throw {
code: "BROWSER_OPEN_FAILED",
message: "Browser could not be opened.",
} satisfies CommandError;
}
dependencies.write(instructions ?? "Opening your browser to authorize Paseo.");
dependencies.write(` ${url}`);
dependencies.write("Waiting for you to approve in the browser...");
},
onProgress: (message) => dependencies.write(message),
promptForCode: dependencies.promptText,
});
const result = await client.storePaseoAgentOAuthCredential({ name, credential });
if (!result.success) {
throw {
code: "OAUTH_STORE_FAILED",
message: result.error ?? "Daemon rejected the OAuth credential.",
} satisfies CommandError;
}
dependencies.write(`Credential accepted by ${target}.`);
return result.auth;
}
async function configureProvider(
client: ProviderAddClient,
entry: PaseoAgentCatalogEntry,
name: string,
options: ProviderAddOptions,
dependencies: ProviderAddDependencies,
): Promise<RedactedPaseoAgentProviderConfig> {
const models = resolveModels(entry, options);
const apiKey =
entry.auth.kind === "api_key" ? await resolveApiKey(entry, options, dependencies) : undefined;
const result = await client.setPaseoAgentProvider({
name,
providerType: entry.id,
options: {
...(apiKey ? { apiKey } : {}),
...(models ? { models } : {}),
},
});
if (!result.success || !result.provider) {
throw {
code: "PROVIDER_CONFIG_FAILED",
message: result.error ?? "Daemon rejected the provider config.",
} satisfies CommandError;
}
return result.provider;
}
async function authenticateOAuthProvider(
client: ProviderAddClient,
entry: PaseoAgentCatalogEntry,
name: string,
options: ProviderAddOptions,
dependencies: ProviderAddDependencies,
): Promise<PaseoAgentProviderAuthState | undefined> {
if (options.deviceCode) {
return runDaemonOAuth(client, name, dependencies);
}
try {
return await runBrowserOAuth(client, entry, name, options, dependencies);
} catch (error) {
if (!isBrowserOpenError(error)) {
throw error;
}
dependencies.write("Browser could not be opened; using device-code authorization.");
return runDaemonOAuth(client, name, dependencies);
}
}
export async function runAddCommand(
id: string | undefined,
options: ProviderAddOptions,
_command: Command,
dependencies: Partial<ProviderAddDependencies> = {},
): Promise<SingleResult<ProviderConfiguredItem>> {
const deps = { ...defaultDependencies, ...dependencies };
const client = await deps.connectDaemon({ host: options.host });
try {
await requirePaseoAgentCatalogFeature(client);
const catalogResult = await client.getPaseoAgentCatalog();
if (catalogResult.error) {
throw {
code: "PROVIDER_CATALOG_FAILED",
message: catalogResult.error,
} satisfies CommandError;
}
const entry = await resolveEntry(id, catalogResult.catalog, deps);
const name = options.name?.trim() || entry.id;
const provider = await configureProvider(client, entry, name, options, deps);
if (entry.auth.kind !== "api_key" && entry.auth.kind !== "oauth") {
throw {
code: "UNSUPPORTED_PROVIDER_AUTH",
message: `Provider ${entry.label} uses an auth type this CLI does not understand. Update the Paseo daemon to use this command.`,
} satisfies CommandError;
}
const auth =
entry.auth.kind === "oauth"
? await authenticateOAuthProvider(client, entry, name, options, deps)
: provider.auth;
return {
type: "single",
data: toConfiguredItem(auth ? { ...provider, auth } : provider, entry),
schema: providerConfiguredSchema,
};
} finally {
await client.close().catch(() => {});
}
}
export function addProviderAddOptions(command: Command): Command {
return command
.description("Configure a Paseo Agent model provider")
.argument("[id]", "Catalog provider id; omit to choose interactively")
.option("--name <instanceName>", "Provider instance name (default: provider id)")
.option(
"--model <id>",
"Model ID to expose (repeatable, comma-separated; defaults to catalog models)",
collectMultiple,
[],
)
.option("--api-key-stdin", "Read API key from stdin")
.option("--device-code", "Use daemon-run device-code OAuth instead of browser OAuth");
}
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import type { ServerInfoStatusPayload } from "@getpaseo/protocol/messages";
import { requirePaseoAgentCatalogFeature } from "./feature.js";
function createServerInfo(features: ServerInfoStatusPayload["features"]): ServerInfoStatusPayload {
return {
status: "server_info",
serverId: "test-daemon",
features,
};
}
describe("provider feature gate", () => {
it("waits for delayed server_info before allowing catalog commands", async () => {
let resolveServerInfo: ((serverInfo: ServerInfoStatusPayload) => void) | null = null;
const allowed = requirePaseoAgentCatalogFeature({
waitForServerInfo: async () =>
new Promise<ServerInfoStatusPayload>((resolve) => {
resolveServerInfo = resolve;
}),
});
expect(resolveServerInfo).not.toBeNull();
resolveServerInfo?.(createServerInfo({ paseoAgentCatalog: true }));
await expect(allowed).resolves.toBeUndefined();
});
it("surfaces the server_info wait timeout", async () => {
const timeoutError = new Error("Timed out waiting for server_info status message (5ms)");
await expect(
requirePaseoAgentCatalogFeature({
waitForServerInfo: async () => {
throw timeoutError;
},
}),
).rejects.toThrow("Timed out waiting for server_info status message (5ms)");
});
it("requires the catalog feature when server_info arrives without it", async () => {
await expect(
requirePaseoAgentCatalogFeature({
waitForServerInfo: async () => createServerInfo({ paseoAgentCatalog: false }),
}),
).rejects.toMatchObject({
code: "HOST_UPDATE_REQUIRED",
message: "Update the Paseo daemon to use this command.",
});
});
});
@@ -0,0 +1,17 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { CommandError } from "../../output/index.js";
export interface PaseoAgentCatalogFeatureClient extends Pick<DaemonClient, "waitForServerInfo"> {}
export async function requirePaseoAgentCatalogFeature(
client: PaseoAgentCatalogFeatureClient,
): Promise<void> {
const serverInfo = await client.waitForServerInfo();
if (serverInfo.features?.paseoAgentCatalog === true) {
return;
}
throw {
code: "HOST_UPDATE_REQUIRED",
message: "Update the Paseo daemon to use this command.",
} satisfies CommandError;
}
+35 -5
View File
@@ -1,23 +1,53 @@
import { Command } from "commander";
import { runLsCommand } from "./ls.js";
import { runModelsCommand } from "./models.js";
import { addProviderAddOptions, runAddCommand, type ProviderAddDependencies } from "./add.js";
import { runRmCommand, type ProviderRmDependencies } from "./rm.js";
import type { ProviderListItem, ProviderLsDependencies } from "./ls.js";
import { withOutput } from "../../output/index.js";
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
export function createProviderCommand(): Command {
const provider = new Command("provider").description("Manage agent providers");
export function createProviderCommand(
dependencies: Partial<
ProviderAddDependencies & ProviderLsDependencies & ProviderRmDependencies
> = {},
): Command {
const provider = new Command("provider").description(
"Manage Paseo Agent model providers and agent provider models",
);
addJsonAndDaemonHostOptions(
provider.command("ls").description("List available providers and status"),
).action(withOutput(runLsCommand));
provider.command("ls").description("List configured Paseo Agent model providers"),
).action(
withOutput<ProviderListItem, []>((options, command) =>
runLsCommand(options, command, dependencies),
),
);
addJsonAndDaemonHostOptions(
provider
.command("models")
.description("List models for a provider")
.argument("<provider>", "Provider name (claude, codex, opencode)")
.argument("<provider>", "Provider name")
.option("--thinking", "Include thinking option IDs for each model"),
).action(withOutput(runModelsCommand));
addJsonAndDaemonHostOptions(addProviderAddOptions(provider.command("add"))).action(
withOutput<Awaited<ReturnType<typeof runAddCommand>>["data"], [string | undefined]>(
(id, options, command) => runAddCommand(id, options, command, dependencies),
),
);
addJsonAndDaemonHostOptions(
provider
.command("rm")
.description("Remove a Paseo Agent model provider")
.argument("<name>", "Provider instance name"),
).action(
withOutput<Awaited<ReturnType<typeof runRmCommand>>["data"], [string]>(
(name, options, command) => runRmCommand(name, options, command, dependencies),
),
);
return provider;
}
@@ -0,0 +1,163 @@
import { describe, expect, it } from "vitest";
import { render } from "../../output/index.js";
import { runLsCommand } from "./ls.js";
import { runRmCommand } from "./rm.js";
function createServerInfo() {
return {
status: "server_info",
serverId: "test-daemon",
features: { paseoAgentCatalog: true },
};
}
describe("provider ls", () => {
it("renders an empty configured-provider table with headers", async () => {
const result = await runLsCommand({ host: "localhost:7777" }, {} as never, {
connectDaemon: async () => ({
waitForServerInfo: async () => createServerInfo(),
getPaseoAgentCatalog: async () => ({
requestId: "catalog-1",
catalog: [],
error: null,
}),
getPaseoAgentProviders: async () => ({
requestId: "providers-1",
defaultModel: null,
providers: [],
error: null,
}),
close: async () => {},
}),
});
expect(result.data).toEqual([]);
expect(render(result, { format: "table", noColor: true })).toContain("NAME");
expect(render(result, { format: "json" })).toBe("[]");
});
it("lists configured model providers with catalog labels and auth states", async () => {
const result = await runLsCommand({ host: "localhost:7777" }, {} as never, {
connectDaemon: async (options) => {
expect(options.host).toBe("localhost:7777");
return {
waitForServerInfo: async () => createServerInfo(),
getPaseoAgentCatalog: async () => ({
requestId: "catalog-1",
catalog: [
{
id: "alpha-key",
label: "Alpha Key",
api: "test-api",
baseUrl: "https://alpha.example.test",
auth: { kind: "api_key", envVar: "ALPHA_API_KEY" },
models: [{ id: "alpha-model" }],
},
{
id: "beta-oauth",
label: "Beta OAuth",
api: "test-oauth-api",
baseUrl: "https://beta.example.test",
auth: { kind: "oauth", flow: "beta-flow" },
models: [{ id: "beta-model" }],
},
],
error: null,
}),
getPaseoAgentProviders: async () => ({
requestId: "providers-1",
defaultModel: null,
providers: [
{
name: "alpha-main",
providerType: "alpha-key",
models: [{ id: "alpha-model" }],
auth: { kind: "api_key", configured: true, source: "literal" },
available: true,
error: null,
},
{
name: "beta-main",
providerType: "beta-oauth",
models: [{ id: "beta-model" }],
auth: { kind: "oauth", configured: false, hint: "sign in again" },
available: false,
error: "auth missing",
},
{
name: "manual-main",
providerType: "manual-type",
models: [{ id: "manual-model" }],
available: true,
error: null,
},
],
error: null,
}),
close: async () => {},
};
},
});
expect(result.data).toEqual([
{
name: "alpha-main",
providerType: "alpha-key",
label: "Alpha Key",
auth: "Connected",
available: "yes",
models: "alpha-model",
},
{
name: "beta-main",
providerType: "beta-oauth",
label: "Beta OAuth",
auth: "Needs attention",
available: "no",
models: "beta-model",
},
{
name: "manual-main",
providerType: "manual-type",
label: "manual-type",
auth: "not configured",
available: "yes",
models: "manual-model",
},
]);
const table = render(result, { format: "table", noColor: true });
expect(table).toContain("Connected");
expect(table).toContain("Needs attention");
expect(table).toContain("not configured");
});
});
describe("provider rm", () => {
it("removes a configured model provider", async () => {
const removedNames: string[] = [];
const result = await runRmCommand("alpha-main", { host: "localhost:7777" }, {} as never, {
connectDaemon: async (options) => {
expect(options.host).toBe("localhost:7777");
return {
waitForServerInfo: async () => createServerInfo(),
removePaseoAgentProvider: async (name: string) => {
removedNames.push(name);
return {
requestId: "remove-1",
success: true,
removed: true,
error: null,
};
},
close: async () => {},
};
},
});
expect(removedNames).toEqual(["alpha-main"]);
expect(result.data).toEqual({ name: "alpha-main", removed: "yes" });
});
});
+79 -60
View File
@@ -1,52 +1,57 @@
import type { Command } from "commander";
import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js";
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
import { AGENT_PROVIDER_DEFINITIONS } from "@getpaseo/protocol/provider-manifest";
import { tryConnectToDaemon } from "../../utils/client.js";
import {
renderTable,
renderTableHeader,
type CommandOptions,
type ListResult,
type OutputSchema,
} from "../../output/index.js";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type {
PaseoAgentCatalogEntry,
RedactedPaseoAgentProviderConfig,
} from "@getpaseo/protocol/messages";
import { connectToDaemon } from "../../utils/client.js";
import { requirePaseoAgentCatalogFeature } from "./feature.js";
export interface ProviderListItem {
provider: ProviderSnapshotEntry["provider"];
name: string;
providerType: string;
label: string;
status: string;
enabled: "Enabled" | "Disabled";
defaultMode: string;
modes: string;
auth: string;
available: string;
models: string;
}
/** Derive provider list from the manifest — single source of truth */
const PROVIDERS: ProviderListItem[] = AGENT_PROVIDER_DEFINITIONS.map((def) => ({
provider: def.id,
label: def.label,
status: "available",
enabled: def.enabledByDefault === false ? "Disabled" : "Enabled",
defaultMode: def.defaultModeId ?? "-",
modes: def.modes.length > 0 ? def.modes.map((m) => m.label).join(", ") : "-",
}));
interface ProviderLsClient extends Pick<
DaemonClient,
"waitForServerInfo" | "getPaseoAgentCatalog" | "getPaseoAgentProviders" | "close"
> {}
function getStaticProviders(): ProviderListItem[] {
return PROVIDERS;
export interface ProviderLsDependencies {
connectDaemon: (options: { host?: string }) => Promise<ProviderLsClient>;
}
/** Schema for provider ls output */
const defaultDependencies: ProviderLsDependencies = {
connectDaemon: connectToDaemon,
};
export const providerLsSchema: OutputSchema<ProviderListItem> = {
idField: "provider",
idField: "name",
columns: [
{ header: "PROVIDER", field: "provider", width: 12 },
{ header: "LABEL", field: "label", width: 16 },
{
header: "STATUS",
field: "status",
width: 12,
color: (value) => {
if (value === "available") return "green";
if (value === "unavailable") return "red";
return undefined;
},
},
{ header: "ENABLED", field: "enabled", width: 10 },
{ header: "DEFAULT MODE", field: "defaultMode", width: 14 },
{ header: "MODES", field: "modes", width: 30 },
{ header: "NAME", field: "name", width: 20 },
{ header: "TYPE", field: "providerType", width: 16 },
{ header: "LABEL", field: "label", width: 22 },
{ header: "AUTH", field: "auth", width: 16 },
{ header: "AVAILABLE", field: "available", width: 10 },
{ header: "MODELS", field: "models", width: 30 },
],
renderHuman: (result, options) => {
if (result.type === "list" && result.data.length === 0) {
return options.noHeaders ? "" : renderTableHeader(providerLsSchema, options);
}
return renderTable(result, options);
},
};
export type ProviderLsResult = ListResult<ProviderListItem>;
@@ -55,40 +60,54 @@ export interface ProviderLsOptions extends CommandOptions {
host?: string;
}
function authState(provider: RedactedPaseoAgentProviderConfig): string {
if (!provider.auth) {
return "not configured";
}
return provider.auth.configured ? "Connected" : "Needs attention";
}
function catalogLabel(catalog: PaseoAgentCatalogEntry[], providerType: string): string {
return catalog.find((entry) => entry.id === providerType)?.label ?? providerType;
}
export async function runLsCommand(
options: ProviderLsOptions,
_command: Command,
dependencies: Partial<ProviderLsDependencies> = {},
): Promise<ProviderLsResult> {
const client = await tryConnectToDaemon({ host: options.host });
if (!client) {
return {
type: "list",
data: getStaticProviders(),
schema: providerLsSchema,
};
}
const deps = { ...defaultDependencies, ...dependencies };
const client = await deps.connectDaemon({ host: options.host });
try {
const snapshot = await client.getProvidersSnapshot();
await requirePaseoAgentCatalogFeature(client);
const catalogResult = await client.getPaseoAgentCatalog();
if (catalogResult.error) {
throw {
code: "PROVIDER_CATALOG_FAILED",
message: catalogResult.error,
};
}
const providersResult = await client.getPaseoAgentProviders();
if (providersResult.error) {
throw {
code: "PROVIDER_LIST_FAILED",
message: providersResult.error,
};
}
return {
type: "list",
data: snapshot.entries.map((entry) => ({
provider: entry.provider,
label: entry.label ?? entry.provider,
status: entry.status === "ready" ? "available" : entry.status,
enabled: !entry.enabled ? "Disabled" : "Enabled",
defaultMode: entry.defaultModeId ?? "default",
modes: (entry.modes ?? []).map((mode) => mode.label).join(", "),
data: providersResult.providers.map((provider) => ({
name: provider.name,
providerType: provider.providerType,
label: catalogLabel(catalogResult.catalog, provider.providerType),
auth: authState(provider),
available: provider.available ? "yes" : "no",
models: provider.models.map((model) => model.id).join(", ") || "-",
})),
schema: providerLsSchema,
};
} catch {
return {
type: "list",
data: getStaticProviders(),
schema: providerLsSchema,
};
} finally {
await client.close().catch(() => {});
}
+71
View File
@@ -0,0 +1,71 @@
import type { Command } from "commander";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { connectToDaemon } from "../../utils/client.js";
import type {
CommandError,
CommandOptions,
OutputSchema,
SingleResult,
} from "../../output/index.js";
import { requirePaseoAgentCatalogFeature } from "./feature.js";
interface ProviderRmOptions extends CommandOptions {
host?: string;
}
interface ProviderRemoveItem {
name: string;
removed: string;
}
interface ProviderRmClient extends Pick<
DaemonClient,
"waitForServerInfo" | "removePaseoAgentProvider" | "close"
> {}
export interface ProviderRmDependencies {
connectDaemon: (options: { host?: string }) => Promise<ProviderRmClient>;
}
const defaultDependencies: ProviderRmDependencies = {
connectDaemon: connectToDaemon,
};
export const providerRemoveSchema: OutputSchema<ProviderRemoveItem> = {
idField: "name",
columns: [
{ header: "NAME", field: "name", width: 20 },
{ header: "REMOVED", field: "removed", width: 10 },
],
};
export async function runRmCommand(
name: string,
options: ProviderRmOptions,
_command: Command,
dependencies: Partial<ProviderRmDependencies> = {},
): Promise<SingleResult<ProviderRemoveItem>> {
const deps = { ...defaultDependencies, ...dependencies };
const client = await deps.connectDaemon({ host: options.host });
try {
await requirePaseoAgentCatalogFeature(client);
const result = await client.removePaseoAgentProvider(name);
if (!result.success) {
throw {
code: "PROVIDER_REMOVE_FAILED",
message: result.error ?? "Daemon rejected the provider removal request.",
} satisfies CommandError;
}
return {
type: "single",
data: {
name,
removed: result.removed ? "yes" : "no",
},
schema: providerRemoveSchema,
};
} finally {
await client.close().catch(() => {});
}
}
+1 -4
View File
@@ -28,10 +28,7 @@ export function createScheduleCommand(): Command {
"--provider <provider>",
"Agent provider, or provider/model (e.g. codex or codex/gpt-5.4)",
)
.option(
"--mode <mode>",
"Provider-specific mode (e.g. claude bypassPermissions, opencode build)",
)
.option("--mode <mode>", "Provider-specific mode")
.option("--cwd <path>", "Working directory (default: current; required with --host)")
.option("--run-now", "Fire one immediate run on creation (only with --cron)")
.option("--no-run-now", "Wait the full interval before the first run (only with --every)")
@@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import { browserOpenCommand, openBrowserUrl } from "./open-browser.js";
describe("browserOpenCommand", () => {
it("opens Windows URLs without cmd.exe shell parsing", () => {
const url =
"https://auth.openai.com/oauth/authorize?client_id=paseo&state=abc%20123&redirect_uri=http%3A%2F%2F127.0.0.1%3A49152%2Fcallback";
expect(browserOpenCommand(url, "win32")).toEqual({
command: "rundll32.exe",
args: ["url.dll,FileProtocolHandler", url],
});
});
it("keeps macOS opener behavior unchanged", () => {
const url = "https://auth.openai.com/oauth/authorize?client_id=paseo&state=abc";
expect(browserOpenCommand(url, "darwin")).toEqual({
command: "open",
args: [url],
});
});
it("keeps Linux opener behavior unchanged", () => {
const url = "https://auth.openai.com/oauth/authorize?client_id=paseo&state=abc";
expect(browserOpenCommand(url, "linux")).toEqual({
command: "xdg-open",
args: [url],
});
});
});
describe("openBrowserUrl", () => {
it("spawns the resolved opener without launching a real browser", () => {
const spawned: Array<{
command: string;
args: string[];
options: { stdio: "ignore"; detached: true };
}> = [];
const child = {
on: () => child,
unref: () => {},
};
const opened = openBrowserUrl(
"https://auth.openai.com/oauth/authorize?client_id=paseo&state=abc",
{
platform: "win32",
spawn: (command, args, options) => {
spawned.push({ command, args, options });
return child;
},
},
);
expect(opened).toBe(true);
expect(spawned).toEqual([
{
command: "rundll32.exe",
args: [
"url.dll,FileProtocolHandler",
"https://auth.openai.com/oauth/authorize?client_id=paseo&state=abc",
],
options: { stdio: "ignore", detached: true },
},
]);
});
it("returns false when spawning the opener throws", () => {
const opened = openBrowserUrl("https://auth.openai.com/oauth/authorize", {
platform: "linux",
spawn: () => {
throw new Error("spawn failed");
},
});
expect(opened).toBe(false);
});
});
+50
View File
@@ -0,0 +1,50 @@
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
interface BrowserOpenCommand {
command: string;
args: string[];
}
type BrowserOpenSpawn = (
command: string,
args: string[],
options: { stdio: "ignore"; detached: true },
) => Pick<ChildProcessWithoutNullStreams, "on" | "unref">;
interface BrowserOpenDependencies {
platform?: NodeJS.Platform;
spawn?: BrowserOpenSpawn;
}
/**
* Best-effort cross-platform browser opener for CLI OAuth flows. Returns true if the
* opener process was spawned, false otherwise. Callers must always print the URL too,
* so a failed/headless open still lets the user copy it.
*/
export function browserOpenCommand(
url: string,
platform: NodeJS.Platform = process.platform,
): BrowserOpenCommand {
switch (platform) {
case "darwin":
return { command: "open", args: [url] };
case "win32":
return { command: "rundll32.exe", args: ["url.dll,FileProtocolHandler", url] };
default:
return { command: "xdg-open", args: [url] };
}
}
export function openBrowserUrl(url: string, dependencies: BrowserOpenDependencies = {}): boolean {
const spawnBrowser = dependencies.spawn ?? spawn;
const { command, args } = browserOpenCommand(url, dependencies.platform);
try {
const child = spawnBrowser(command, args, { stdio: "ignore", detached: true });
child.on("error", () => {});
child.unref();
return true;
} catch {
return false;
}
}
+164 -141
View File
@@ -3,15 +3,18 @@
/**
* Phase 15: Provider Command Tests
*
* Tests provider commands for listing providers and models.
* Provider ls data is static, while provider models are fetched via daemon integration.
* This test uses an isolated daemon to avoid coupling to a user's long-running daemon.
* Tests provider commands for configured Paseo Agent model providers and agent
* provider model listing. This test uses an isolated daemon to avoid coupling to
* a user's long-running daemon.
*
* Tests:
* - provider --help shows subcommands
* - provider ls lists all providers
* - provider ls --json outputs valid JSON
* - provider ls --quiet outputs provider names only
* - provider ls on a fresh daemon prints an empty configured-provider table
* - provider add stores an API-key model provider without network validation
* - repeated provider add updates the same provider instance
* - provider add rejects unknown catalog ids with known ids
* - provider rm removes a configured model provider
* - provider add uses catalog default models when present
* - provider models claude lists claude models
* - provider models codex lists codex models
* - provider models opencode lists opencode models
@@ -20,14 +23,7 @@
*/
import assert from "node:assert";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import {
createE2ETestContext,
createTempDirs,
runPaseoCli,
startTestDaemon,
} from "./helpers/test-daemon.ts";
import { createE2ETestContext } from "./helpers/test-daemon.ts";
console.log("=== Provider Commands ===\n");
@@ -38,10 +34,26 @@ interface ProviderModel {
}
interface ProviderListRow {
provider: string;
name: string;
providerType: string;
label: string;
status: string;
enabled: string;
auth: string;
available: string;
models: string;
}
interface CliResult {
exitCode: number;
stdout: string;
stderr: string;
}
function assertExitCode(result: CliResult, expected: number, message: string): void {
assert.strictEqual(result.exitCode, expected, `${message}\nstderr:\n${result.stderr}`);
}
function assertNonZeroExitCode(result: CliResult, message: string): void {
assert.notStrictEqual(result.exitCode, 0, `${message}\nstderr:\n${result.stderr}`);
}
const EXPECTED_CLAUDE_MODELS = [
@@ -131,6 +143,24 @@ async function runProviderModelsJson(provider: string): Promise<ProviderModel[]>
return attemptRun(1);
}
function parseProviderListJson(stdout: string): ProviderListRow[] {
const data = JSON.parse(stdout.trim()) as ProviderListRow[];
assert(Array.isArray(data), "provider ls --json output should be an array");
return data;
}
async function getProviderRows(): Promise<ProviderListRow[]> {
const result = await ctx.paseo(["provider", "ls", "--json"]);
assertExitCode(result, 0, "provider ls --json should exit 0");
return parseProviderListJson(result.stdout);
}
function assertProviderTableHeader(stdout: string): void {
for (const header of ["NAME", "TYPE", "LABEL", "AUTH", "AVAILABLE", "MODELS"]) {
assert(stdout.includes(header), `provider ls table should include ${header}`);
}
}
function assertClaudeModels(data: ProviderModel[]): void {
assert.strictEqual(
data.length,
@@ -165,144 +195,137 @@ try {
{
console.log("Test 1: provider --help shows subcommands");
const result = await ctx.paseo(["provider", "--help"]);
assert.strictEqual(result.exitCode, 0, "provider --help should exit 0");
assertExitCode(result, 0, "provider --help should exit 0");
assert(result.stdout.includes("ls"), "help should mention ls");
assert(result.stdout.includes("add"), "help should mention add");
assert(result.stdout.includes("rm"), "help should mention rm");
assert(result.stdout.includes("models"), "help should mention models");
console.log("✓ provider --help shows subcommands\n");
}
// Test 2: provider ls lists all providers
// Test 2: provider ls on a fresh daemon shows no configured providers
{
console.log("Test 2: provider ls lists all providers");
console.log("Test 2: provider ls on a fresh daemon shows no configured providers");
const result = await ctx.paseo(["provider", "ls"]);
assert.strictEqual(result.exitCode, 0, "provider ls should exit 0");
assert(result.stdout.includes("claude"), "output should include claude");
assert(result.stdout.includes("codex"), "output should include codex");
assert(result.stdout.includes("opencode"), "output should include opencode");
assert(result.stdout.includes("ENABLED"), "output should include ENABLED column");
assert(result.stdout.includes("Enabled"), "output should show enabled providers");
assert(
result.stdout.includes("available") ||
result.stdout.includes("loading") ||
result.stdout.includes("unavailable"),
"output should show a provider status",
);
console.log("✓ provider ls lists all providers\n");
assertExitCode(result, 0, "provider ls should exit 0");
assertProviderTableHeader(result.stdout);
assert(!result.stdout.includes("OpenRouter"), "fresh output should have no provider rows");
const jsonResult = await ctx.paseo(["provider", "ls", "--json"]);
assertExitCode(jsonResult, 0, "provider ls --json should exit 0");
assert.deepStrictEqual(parseProviderListJson(jsonResult.stdout), []);
console.log("✓ provider ls on a fresh daemon shows no configured providers\n");
}
// Test 3: provider ls --json outputs valid JSON
// Test 3: provider add openrouter stores a dummy key without network validation
{
console.log("Test 3: provider ls --json outputs valid JSON");
const result = await ctx.paseo(["provider", "ls", "--json"]);
assert.strictEqual(result.exitCode, 0, "should exit 0");
const data = JSON.parse(result.stdout.trim());
assert(Array.isArray(data), "output should be an array");
assert(data.length >= 3, `should have at least 3 providers, got ${data.length}`);
assert(
data.some((p: { provider: string }) => p.provider === "claude"),
"should include claude",
);
assert(
data.some((p: { provider: string }) => p.provider === "codex"),
"should include codex",
);
assert(
data.some((p: { provider: string }) => p.provider === "opencode"),
"should include opencode",
);
const rows = data as ProviderListRow[];
for (const provider of ["claude", "codex", "opencode"] as const) {
const row = rows.find((p) => p.provider === provider);
assert(row, `should include ${provider}`);
assert.strictEqual(row.enabled, "Enabled", `${provider} should report Enabled`);
}
console.log("Test 3: provider add openrouter stores a dummy key without network validation");
const result = await ctx.paseo(["provider", "add", "openrouter", "--api-key-stdin"], {
stdin: "dummy-openrouter-key\n",
});
assertExitCode(result, 0, "provider add should exit 0");
assert(result.stdout.includes("openrouter"), "add output should include the instance name");
assert(result.stdout.includes("OpenRouter"), "add output should include the catalog label");
assert(result.stdout.includes("Connected"), "add output should show connected auth state");
assert(result.stdout.includes("yes"), "add output should show the provider as available");
const omp = rows.find((p) => p.provider === "omp");
assert(omp, "should include omp");
assert.strictEqual(omp.enabled, "Disabled", "omp should report Disabled by default");
console.log("✓ provider ls --json outputs valid JSON\n");
const rows = await getProviderRows();
assert.strictEqual(rows.length, 1, "provider ls should show exactly one configured instance");
assert.deepStrictEqual(rows[0], {
name: "openrouter",
providerType: "openrouter",
label: "OpenRouter",
auth: "Connected",
available: "yes",
models: "-",
});
console.log("✓ provider add openrouter stores a dummy key without network validation\n");
}
// Test 4: provider ls includes disabled providers
// Test 4: provider add is idempotent for the same instance name
{
console.log("Test 4: provider ls includes disabled providers");
const { paseoHome, workDir } = await createTempDirs();
await writeFile(
join(paseoHome, "config.json"),
JSON.stringify(
{
version: 1,
agents: {
providers: {
claude: {
enabled: false,
},
},
},
},
null,
2,
) + "\n",
console.log("Test 4: provider add is idempotent for the same instance name");
const result = await ctx.paseo(["provider", "add", "openrouter", "--api-key-stdin"], {
stdin: "dummy-openrouter-key-2\n",
});
assertExitCode(result, 0, "provider add should exit 0");
const rows = await getProviderRows();
assert.strictEqual(rows.length, 1, "re-running add should not create another instance");
assert.strictEqual(rows[0]?.name, "openrouter");
assert.strictEqual(rows[0]?.label, "OpenRouter");
console.log("✓ provider add is idempotent for the same instance name\n");
}
// Test 5: provider add rejects unknown catalog ids with known ids
{
console.log("Test 5: provider add rejects unknown catalog ids with known ids");
const result = await ctx.paseo(["provider", "add", "nonsense-id", "--api-key-stdin"], {
stdin: "dummy-key\n",
});
assertNonZeroExitCode(result, "provider add should fail for unknown ids");
const output = result.stdout + result.stderr;
assert(output.includes("nonsense-id"), "error should mention the requested id");
assert(output.includes("Known provider ids"), "error should mention known provider ids");
assert(output.includes("openrouter"), "known ids should include openrouter");
assert(output.includes("kimi"), "known ids should include kimi");
console.log("✓ provider add rejects unknown catalog ids with known ids\n");
}
// Test 6: provider rm removes a configured provider
{
console.log("Test 6: provider rm removes a configured provider");
const result = await ctx.paseo(["provider", "rm", "openrouter"]);
assertExitCode(result, 0, "provider rm should exit 0");
assert(result.stdout.includes("openrouter"), "rm output should include the instance name");
assert(result.stdout.includes("yes"), "rm output should report removal");
const rows = await getProviderRows();
assert.deepStrictEqual(rows, [], "provider ls should be empty after removing openrouter");
const table = await ctx.paseo(["provider", "ls"]);
assertExitCode(table, 0, "provider ls should stay successful after removal");
assertProviderTableHeader(table.stdout);
console.log("✓ provider rm removes a configured provider\n");
}
// Test 7: provider add uses catalog default models when present
{
console.log("Test 7: provider add uses catalog default models when present");
const result = await ctx.paseo(["provider", "add", "kimi", "--api-key-stdin"], {
stdin: "dummy-kimi-key\n",
});
assertExitCode(result, 0, "provider add kimi should exit 0");
assert(result.stdout.includes("kimi"), "add output should include the instance name");
assert(result.stdout.includes("Kimi Coding Plan"), "add output should include the label");
const rows = await getProviderRows();
const kimi = rows.find((row) => row.name === "kimi");
assert(kimi, "provider ls should include the kimi instance");
assert.strictEqual(kimi.label, "Kimi Coding Plan");
assert.strictEqual(kimi.auth, "Connected");
assert.strictEqual(kimi.available, "yes");
assert.notStrictEqual(kimi.models, "-", "kimi should expose catalog-derived default models");
assert(
kimi.models
.split(",")
.map((model) => model.trim())
.filter(Boolean).length > 0,
"kimi should list at least one catalog-derived model id",
);
const disabledCtx = await startTestDaemon({ paseoHome, workDir, timeout: 120000 });
try {
const result = await runPaseoCli(disabledCtx, ["provider", "ls", "--json"]);
assert.strictEqual(result.exitCode, 0, "provider ls should exit 0");
const data = JSON.parse(result.stdout.trim()) as ProviderListRow[];
const claude = data.find((p) => p.provider === "claude");
assert(claude, "disabled claude provider should stay in provider ls");
assert.strictEqual(claude.enabled, "Disabled", "disabled provider should report Disabled");
const opencode = data.find((p) => p.provider === "opencode");
assert(opencode, "enabled opencode provider should stay in provider ls");
assert.strictEqual(opencode.enabled, "Enabled", "enabled provider should report Enabled");
const modelsResult = await runPaseoCli(disabledCtx, ["provider", "models", "claude"]);
assert.notStrictEqual(
modelsResult.exitCode,
0,
"provider models should fail for disabled providers",
);
const output = modelsResult.stdout + modelsResult.stderr;
assert(
output.includes("Provider claude is disabled"),
"provider models should surface the daemon disabled error",
);
assert(
!output.includes("claude-sonnet"),
"provider models should not print fallback models for disabled providers",
);
} finally {
await disabledCtx.stop();
}
console.log("✓ provider ls includes disabled providers\n");
console.log("✓ provider add uses catalog default models when present\n");
}
// Test 5: provider ls --quiet outputs provider names only
// Test 8: provider models claude lists canonical model aliases
{
console.log("Test 5: provider ls --quiet outputs provider names only");
const result = await ctx.paseo(["provider", "ls", "--quiet"]);
assert.strictEqual(result.exitCode, 0, "should exit 0");
const lines = result.stdout.trim().split("\n");
assert(lines.length >= 3, `should have at least 3 lines, got ${lines.length}`);
assert(lines.includes("claude"), "should include claude");
assert(lines.includes("codex"), "should include codex");
assert(lines.includes("opencode"), "should include opencode");
console.log("✓ provider ls --quiet outputs provider names only\n");
}
// Test 6: provider models claude lists canonical model aliases
{
console.log("Test 6: provider models claude lists canonical model aliases");
console.log("Test 8: provider models claude lists canonical model aliases");
const data = await runProviderModelsJson("claude");
assertClaudeModels(data);
console.log("✓ provider models claude lists canonical model aliases\n");
}
// Test 7: provider models codex includes concrete codex model IDs
// Test 9: provider models codex includes concrete codex model IDs
{
console.log("Test 7: provider models codex includes concrete codex model IDs");
console.log("Test 9: provider models codex includes concrete codex model IDs");
const data = await runProviderModelsJson("codex");
assert(data.length >= 1, "codex model list should not be empty");
const ids = data.map((m) => m.id);
@@ -322,9 +345,9 @@ try {
console.log("✓ provider models codex includes concrete codex model IDs\n");
}
// Test 8: provider models opencode returns namespaced model IDs
// Test 10: provider models opencode returns namespaced model IDs
{
console.log("Test 8: provider models opencode returns namespaced model IDs");
console.log("Test 10: provider models opencode returns namespaced model IDs");
const data = await runProviderModelsJson("opencode");
assert(data.length >= 1, "opencode model list should not be empty");
const ids = data.map((m) => m.id);
@@ -343,11 +366,11 @@ try {
console.log("✓ provider models opencode returns namespaced model IDs\n");
}
// Test 9: provider models unknown fails with error
// Test 11: provider models unknown fails with error
{
console.log("Test 9: provider models unknown fails with error");
console.log("Test 11: provider models unknown fails with error");
const result = await ctx.paseo(["provider", "models", "unknown"]);
assert.notStrictEqual(result.exitCode, 0, "should fail for unknown provider");
assertNonZeroExitCode(result, "should fail for unknown provider");
const output = result.stdout + result.stderr;
assert(
output.toLowerCase().includes("unknown") || output.toLowerCase().includes("provider"),
@@ -356,9 +379,9 @@ try {
console.log("✓ provider models unknown fails with error\n");
}
// Test 10: provider models --json outputs valid JSON
// Test 12: provider models --json outputs valid JSON
{
console.log("Test 10: provider models --json outputs valid JSON");
console.log("Test 12: provider models --json outputs valid JSON");
const data = await runProviderModelsJson("claude");
assert(Array.isArray(data), "output should be an array");
assert(
@@ -371,15 +394,15 @@ try {
console.log("✓ provider models --json outputs valid JSON\n");
}
// Test 11: provider models --quiet outputs model IDs only
// Test 13: provider models --quiet outputs model IDs only
{
console.log("Test 11: provider models --quiet outputs model IDs only");
console.log("Test 13: provider models --quiet outputs model IDs only");
assert(
claudeModelIdsFromJson.length > 0,
"claude model IDs should be captured from --json output",
);
const result = await ctx.paseo(["provider", "models", "claude", "--quiet"]);
assert.strictEqual(result.exitCode, 0, "should exit 0");
assertExitCode(result, 0, "provider models claude --quiet should exit 0");
const lines = result.stdout.trim().split("\n").filter(Boolean);
assert.strictEqual(
lines.length,
+27 -7
View File
@@ -43,6 +43,13 @@ const TEST_DAEMON_ENV_DEFAULTS: Record<string, string> = {
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? "0",
};
const TEST_DAEMON_HOST = "127.0.0.1";
// Keep in sync with catalog.ts api_key auth envVar hints. These are scrubbed so
// local developer credentials cannot hide CI-missing-auth failures.
const PASEO_AGENT_PROVIDER_AUTH_ENV_KEYS = [
"OPENROUTER_API_KEY",
"KIMI_API_KEY",
"OPENCODE_API_KEY",
] as const;
const DEFAULT_OUTPUT_CAPTURE_LIMIT = 256 * 1024;
const TEST_OUTPUT_CAPTURE_LIMIT = Number.parseInt(
@@ -59,6 +66,14 @@ function createOutputCapture(): OutputCapture {
return { value: "", truncated: false };
}
function scrubPaseoAgentProviderAuthEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const scrubbed = { ...env };
for (const key of PASEO_AGENT_PROVIDER_AUTH_ENV_KEYS) {
delete scrubbed[key];
}
return scrubbed;
}
function appendOutputCapture(target: OutputCapture, chunk: Buffer): void {
const next = target.value + chunk.toString();
if (next.length <= TEST_OUTPUT_CAPTURE_LIMIT) {
@@ -234,7 +249,7 @@ export async function startTestDaemon(options?: {
// Start daemon process using tsx to run TypeScript directly
const daemonProcess = spawn("npx", ["tsx", cliSrcPath, "daemon", "start", "--foreground"], {
env: {
env: scrubPaseoAgentProviderAuthEnv({
...process.env,
...TEST_DAEMON_ENV_DEFAULTS,
PASEO_HOME: paseoHome,
@@ -242,7 +257,7 @@ export async function startTestDaemon(options?: {
// Force no TTY to prevent QR code output
CI: "true",
...options?.env,
},
}),
stdio: ["ignore", "pipe", "pipe"],
detached: process.platform !== "win32",
});
@@ -336,6 +351,7 @@ export async function runPaseoCli(
timeout?: number;
cwd?: string;
env?: NodeJS.ProcessEnv;
stdin?: string;
},
): Promise<{ exitCode: number; stdout: string; stderr: string }> {
const timeout = options?.timeout ?? 60000;
@@ -346,15 +362,15 @@ export async function runPaseoCli(
return new Promise((resolve, reject) => {
const proc = spawn("npx", ["tsx", cliSrcPath, ...args], {
env: {
env: scrubPaseoAgentProviderAuthEnv({
...process.env,
...TEST_DAEMON_ENV_DEFAULTS,
PASEO_HOST: `${TEST_DAEMON_HOST}:${ctx.port}`,
PASEO_HOME: ctx.paseoHome,
...options?.env,
},
}),
cwd,
stdio: ["ignore", "pipe", "pipe"],
stdio: [options?.stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"],
detached: process.platform !== "win32",
});
@@ -369,6 +385,10 @@ export async function runPaseoCli(
appendOutputCapture(stderr, data);
});
if (options?.stdin !== undefined) {
proc.stdin?.end(options.stdin);
}
const timeoutId = setTimeout(() => {
if (proc.pid) {
signalProcessTree(proc.pid, "SIGKILL");
@@ -406,7 +426,7 @@ export async function createE2ETestContext(options?: {
/** Run a paseo CLI command against this daemon */
paseo: (
args: string[],
opts?: { timeout?: number; cwd?: string; env?: NodeJS.ProcessEnv },
opts?: { timeout?: number; cwd?: string; env?: NodeJS.ProcessEnv; stdin?: string },
) => Promise<{
exitCode: number;
stdout: string;
@@ -418,7 +438,7 @@ export async function createE2ETestContext(options?: {
const paseo = (
args: string[],
opts?: { timeout?: number; cwd?: string; env?: NodeJS.ProcessEnv },
opts?: { timeout?: number; cwd?: string; env?: NodeJS.ProcessEnv; stdin?: string },
) => runPaseoCli(ctx, args, opts);
return {

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