Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] aa06701c9d fix(server): preserve worktree include cleanup 2026-09-09 23:48:56 +02:00
Michael WuandMohamed Boudra a29729ed48 Carry local files and shared state into new worktrees (#2419)
* feat(server): support symlink worktree includes

* refactor(server): simplify worktree include handling

* fix(server): constrain worktree include traversal

* fix(server): clean up failed worktree branches

* fix(server): skip missing worktree include entries

* fix(server): make worktree includes best effort

* fix(server): skip failed worktree includes

* fix(server): harden worktree include planning

* fix(server): preserve reused worktree result shape

Materialization reports describe one creation attempt, not durable worktree identity. Carry them beside the worktree so newly created and reused results keep the same stable shape.

* fix(server): harden worktree include boundaries

Replace directory snapshots exactly, keep canonical Git metadata protected, report recovery skips, and only roll back branches owned before worktree creation.

* fix(server): follow safe include aliases

Traverse canonical in-checkout directory links during glob planning and treat coded revalidation failures as per-entry skips.

* fix(server): make include preflight race safe

Create fetched checkout refs atomically, retain overlapping copy entries for independent fallback, and protect the full managed-worktree base.

* fix(server): preserve staged recovery state

Validate completed directory snapshots, retain backups after failed restoration, and derive atomic ref guards from the repository object ID width.

* fix(server): preserve partial include progress

Keep safe glob matches, enforce recursive-directory types, validate staged roots, and retain OID guards through rollback.

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-09-09 23:48:56 +02:00
1851 changed files with 33924 additions and 106601 deletions
+1 -2
View File
@@ -34,7 +34,6 @@ hub:
- "packages/server/src/server/hub/**"
server:
- "plugins/**"
- "packages/server/**"
- "packages/app/e2e/support/fixtures/recording.*"
@@ -53,7 +52,7 @@ app:
sdk:
- "packages/plugin/**"
- "plugin-examples/**"
- "public-docs/plugins/**"
- "public-docs/plugins/v0.8/**"
- "packages/client/**"
- "packages/highlight/**"
- "packages/protocol/**"
+29 -86
View File
@@ -52,7 +52,7 @@ jobs:
filters: .github/ci-paths.yml
- name: Validate CI contracts
run: node --test scripts/ci-workflow.test.mjs scripts/daemon-launch-contract.test.mjs scripts/sync-fdroid-changelogs.test.mjs scripts/upload-release-assets.test.mjs
run: node --test scripts/ci-workflow.test.mjs scripts/daemon-launch-contract.test.mjs scripts/sync-fdroid-changelogs.test.mjs
format:
name: format
@@ -117,21 +117,9 @@ jobs:
- name: Build server stack
run: npm run build:server
- name: Verify built-in plugins from built output
run: node --test scripts/builtin-plugins-dist.test.mjs
- name: Typecheck all packages
run: npm run typecheck
- name: Run website tests
run: npx vitest run packages/website/src --bail=1
- name: Install website test browsers
run: npx playwright install --with-deps chromium webkit
- name: Verify website mockups
run: npm run test:e2e --workspace=@getpaseo/website
- name: Verify public package contents
run: |
npm pack --dry-run --ignore-scripts --workspace=@getpaseo/protocol
@@ -175,10 +163,6 @@ jobs:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
- name: Run built-in plugin tests
if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }}
run: npm run test --workspace=@getpaseo/builtin-plugins
- name: Run Hub CLI/daemon contract
if: ${{ needs.changes.outputs.full == 'false' && needs.changes.outputs.server == 'false' && needs.changes.outputs.hub != 'false' }}
run: npm run test:hub-cli-contract --workspace=@getpaseo/server
@@ -190,42 +174,11 @@ jobs:
runs-on: windows-latest
steps: *server_test_steps
# native-recursive.ts serves darwin and win32, but windows-latest above is the only
# runner that exercises it. macOS — the platform the reported watcher bug was on —
# otherwise has no server-test coverage. Scoped to the observer/watcher suites to
# bound runtime.
server-tests-macos:
name: server-tests (macos-14, file observation)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false') }}
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- name: Install dependencies
run: node scripts/npm-retry.mjs ci
- name: Build server dependencies
run: npm run build:server-deps
- name: Run file observation tests
run: |
npm run test:unit --workspace=@getpaseo/server -- \
src/server/file-observer \
src/server/workspace-git-service.observation.test.ts \
src/server/workspace-git-service.observation.integration.test.ts \
--bail=1
desktop-tests-ubuntu:
name: desktop-tests (ubuntu-latest)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.desktop != 'false') }}
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
@@ -248,35 +201,12 @@ jobs:
- name: Install virtual display
if: ${{ runner.os == 'Linux' }}
run: sudo apt-get update && sudo apt-get install -y xvfb xauth xdotool
run: sudo apt-get update && sudo apt-get install -y xvfb xauth
- name: Install Playwright browser
if: ${{ runner.os == 'Linux' }}
run: npx playwright install chromium
- name: Verify Windows daemon process control
if: ${{ runner.os == 'Windows' }}
run: npx vitest run packages/cli/src/commands/daemon/lifecycle.e2e.test.ts --bail=1 --maxWorkers=1 -t "Windows unbound|managed two-home"
- name: Run desktop lifecycle E2E (Linux)
if: ${{ runner.os == 'Linux' }}
run: xvfb-run -a --server-args="-screen 0 1280x800x24" npm run test:e2e:lifecycle --workspace=@getpaseo/desktop
env:
PASEO_DESKTOP_LIFECYCLE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-lifecycle
- name: Upload desktop lifecycle screenshots
uses: actions/upload-artifact@v4
if: ${{ !cancelled() && runner.os == 'Linux' }}
with:
name: desktop-lifecycle
path: ${{ runner.temp }}/desktop-lifecycle
if-no-files-found: ignore
retention-days: 7
- name: Run desktop lifecycle E2E (Windows)
if: ${{ runner.os == 'Windows' }}
run: npm run test:e2e:lifecycle --workspace=@getpaseo/desktop
- name: Run desktop renderer E2E
if: ${{ runner.os == 'Linux' }}
run: npm run test:e2e:renderer --workspace=@getpaseo/desktop
@@ -296,6 +226,23 @@ jobs:
if-no-files-found: ignore
retention-days: 7
- name: Build and smoke unpacked desktop app
if: ${{ runner.os == 'Linux' }}
run: npm run build:desktop -- --publish never --linux --x64 --dir
env:
EP_GH_IGNORE_TIME: true
PASEO_DESKTOP_SMOKE: "1"
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke
- name: Upload packaged smoke diagnostics
uses: actions/upload-artifact@v4
if: ${{ failure() && runner.os == 'Linux' }}
with:
name: desktop-packaged-smoke-linux-x64
path: ${{ runner.temp }}/desktop-smoke
if-no-files-found: ignore
retention-days: 7
desktop-tests-windows:
name: desktop-tests (windows-latest)
needs: changes
@@ -349,7 +296,7 @@ jobs:
run: node scripts/npm-retry.mjs ci
- name: Build client dependencies
run: npm run build:relay && npm run build:client
run: npm run build:client
- name: Build plugin SDK
run: npm run build:plugin
@@ -357,6 +304,9 @@ jobs:
- name: Run plugin SDK tests
run: npm run test --workspace=@getpaseo/plugin
- name: Typecheck plugin examples
run: npm run typecheck --workspace=@getpaseo/plugin
- name: Run protocol tests
run: npm run test --workspace=@getpaseo/protocol
@@ -405,9 +355,6 @@ jobs:
run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ env.PLAYWRIGHT_SHARD }}
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
# Weight file groups by observed duration; raw test counts overload
# early agent/Changes specs. Required check names remain unchanged.
PWTEST_SHARD_WEIGHTS: "111:172:137:160"
- name: Upload test artifacts
uses: actions/upload-artifact@v4
@@ -493,18 +440,14 @@ jobs:
- name: Install dependencies
run: node scripts/npm-retry.mjs ci
- name: Build server stack
run: npm run build:server
- name: Install agent CLIs for provider tests
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
# The local runner builds its dependencies and shards its own E2E files.
- name: Run CLI E2E tests
run: npm run test:local --workspace=@getpaseo/cli
# Source tests do not read PASEO_CLI_TEST_SHARD. Run them once, on the
# shortest E2E shard, after its runner has built the server stack.
- name: Run CLI source tests
if: ${{ env.PASEO_CLI_TEST_SHARD == '3' }}
run: npm run test:unit --workspace=@getpaseo/cli
- name: Run CLI tests
run: npm run test --workspace=@getpaseo/cli
cli-tests-2:
name: cli-tests (shard 2/3)
-90
View File
@@ -1,90 +0,0 @@
name: Desktop Packages
on:
push:
branches: [main]
# Packaging leaves ordinary PR CI, but a PR that changes what gets packaged
# or how it is smoked still proves the real artifacts before merge.
pull_request:
branches: [main]
paths:
- "packages/desktop/**"
- "nix/**"
- ".github/workflows/desktop-packages.yml"
concurrency:
group: desktop-packages-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
env:
ONNXRUNTIME_NODE_INSTALL: skip
jobs:
linux:
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "22"
cache: "npm"
- name: Install dependencies
run: node scripts/npm-retry.mjs ci
- name: Install package smoke dependencies
run: sudo apt-get update && sudo apt-get install -y xvfb xauth rpm
- name: Build Linux desktop artifacts
# Smoke package contents and installation without spending minutes on xz compression.
run: |
printf '{"extends":"%s/packages/desktop/electron-builder.yml","deb":{"compression":"gz"},"rpm":{"fpm":["--rpm-compression=gzip"]}}' "$GITHUB_WORKSPACE" > "$RUNNER_TEMP/desktop-smoke-builder.json"
npm run build:desktop -- --publish never --linux --x64 --config "$RUNNER_TEMP/desktop-smoke-builder.json"
env:
EP_GH_IGNORE_TIME: true
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke
- name: Smoke installed artifacts with restricted user namespaces
run: |
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=1
if unshare --user --map-root-user --net true; then
echo "Expected the launching user's namespace probe to be denied"
exit 1
fi
sudo apt-get install -y ./packages/desktop/release/*.deb
node packages/desktop/e2e/linux-artifact-smoke.js packages/desktop/release disabled
env:
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke/restricted
- name: Smoke sandbox-preserving portable launches
run: |
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
unshare --user --map-root-user --net true
node packages/desktop/e2e/linux-artifact-smoke.js packages/desktop/release enabled
env:
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke/userns
- name: Smoke installed RPM with restricted user namespaces
run: |
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=1
sudo dpkg --remove paseo
# Ubuntu supplies the runtime libraries under Debian package names.
# RPM still performs the real payload installation and postinstall.
sudo rpm --install --nodeps packages/desktop/release/*.rpm
node packages/desktop/e2e/linux-artifact-smoke.js packages/desktop/release disabled --installed-only
env:
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke/rpm
- name: Upload packaged smoke diagnostics
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
if: ${{ !cancelled() }}
with:
name: desktop-packaged-smoke-linux-x64
path: ${{ runner.temp }}/desktop-smoke
if-no-files-found: ignore
retention-days: 7
+29 -11
View File
@@ -102,10 +102,8 @@ jobs:
include:
- runner: macos-14
electron_arch: arm64
node_heap_mb: 4096
- runner: macos-15-intel
electron_arch: x64
node_heap_mb: 8192
permissions:
contents: write
packages: read
@@ -163,10 +161,6 @@ jobs:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
PASEO_DESKTOP_SMOKE: "1"
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke
# Metro's jest workers OOM on the generated ws-outbound.aot.js validator,
# the last module in the web bundle. Node's default heap is sized from
# runner RAM, which is not enough on the Intel runner.
NODE_OPTIONS: --max-old-space-size=${{ matrix.node_heap_mb }}
run: |
set -euo pipefail
build_args=(-- --publish never --mac --${{ matrix.electron_arch }})
@@ -191,8 +185,16 @@ jobs:
run: |
set -euo pipefail
release_dir="${DESKTOP_PACKAGE_PATH}/release"
files=()
while IFS= read -r -d '' f; do
files+=("$f")
done < <(find "$release_dir" -maxdepth 1 -type f ! -name '*.yml' -print0 | sort -z)
if (( ${#files[@]} == 0 )); then
echo "::error::No release artifacts found in $release_dir"
exit 1
fi
release_lookup="$(node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG")"
node scripts/upload-release-assets.mjs "$release_lookup" "$release_dir" "${{ github.repository }}"
gh release upload "$release_lookup" "${files[@]}" --clobber --repo "${{ github.repository }}"
- name: Upload desktop artifacts to workflow
if: env.SHOULD_PUBLISH != 'true'
@@ -259,7 +261,7 @@ jobs:
NODE
- name: Install Linux smoke display
run: sudo apt-get update && sudo apt-get install -y xvfb xauth rpm
run: sudo apt-get update && sudo apt-get install -y xvfb xauth
- name: Build desktop release
shell: bash
@@ -276,7 +278,7 @@ jobs:
npm run build:desktop "${build_args[@]}"
- name: Upload packaged smoke diagnostics
if: always()
if: failure()
uses: actions/upload-artifact@v4
with:
name: desktop-smoke-linux-x64
@@ -292,8 +294,16 @@ jobs:
run: |
set -euo pipefail
release_dir="${DESKTOP_PACKAGE_PATH}/release"
files=()
while IFS= read -r -d '' f; do
files+=("$f")
done < <(find "$release_dir" -maxdepth 1 -type f ! -name '*.yml' -print0 | sort -z)
if (( ${#files[@]} == 0 )); then
echo "::error::No release artifacts found in $release_dir"
exit 1
fi
release_lookup="$(node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG")"
node scripts/upload-release-assets.mjs "$release_lookup" "$release_dir" "${{ github.repository }}"
gh release upload "$release_lookup" "${files[@]}" --clobber --repo "${{ github.repository }}"
- name: Upload desktop artifacts to workflow
if: env.SHOULD_PUBLISH != 'true'
@@ -390,8 +400,16 @@ jobs:
run: |
set -euo pipefail
release_dir="${DESKTOP_PACKAGE_PATH}/release"
files=()
while IFS= read -r -d '' f; do
files+=("$f")
done < <(find "$release_dir" -maxdepth 1 -type f ! -name '*.yml' -print0 | sort -z)
if (( ${#files[@]} == 0 )); then
echo "::error::No release artifacts found in $release_dir"
exit 1
fi
release_lookup="$(node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG")"
node scripts/upload-release-assets.mjs "$release_lookup" "$release_dir" "${{ github.repository }}"
gh release upload "$release_lookup" "${files[@]}" --clobber --repo "${{ github.repository }}"
- name: Upload desktop artifacts to workflow
if: env.SHOULD_PUBLISH != 'true'
+22 -1
View File
@@ -1,6 +1,27 @@
name: Docker
on:
pull_request:
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/plugin/**"
- "packages/protocol/**"
- "packages/relay/**"
- "packages/server/**"
push:
branches: [main]
tags:
@@ -30,7 +51,7 @@ on:
concurrency:
group: docker-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
REGISTRY: ghcr.io
+24 -1
View File
@@ -1,8 +1,27 @@
name: Nix
on:
push:
pull_request:
branches: [main]
paths:
- "nix/**"
- "flake.nix"
- "flake.lock"
- "package.json"
- "package-lock.json"
- "packages/app/**"
- "packages/expo-two-way-audio/**"
- "packages/highlight/**"
- "packages/plugin/**"
- "packages/protocol/**"
- "packages/client/**"
- "packages/server/**"
- "packages/relay/**"
- "packages/cli/**"
- "scripts/build-daemon-web-ui.mjs"
- "scripts/update-nix.sh"
- "scripts/fix-lockfile.mjs"
- ".github/workflows/nix.yml"
permissions:
contents: read
@@ -12,6 +31,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- uses: actions/setup-node@v4
with:
@@ -89,6 +110,8 @@ jobs:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- uses: cachix/install-nix-action@v31
with:
-3
View File
@@ -90,6 +90,3 @@ packages/protocol/src/generated/validation/*.aot.ts
packages/desktop/.cache/
packages/desktop/src-tauri/resources/managed-runtime/
app.json
# Heap snapshots dumped by --heapsnapshot-near-heap-limit; keep them for debugging.
*.heapsnapshot
+57 -288
View File
@@ -1,322 +1,91 @@
# Changelog
## 0.10.2 - 2026-09-30
## 0.8.0-beta.1 - 2026-09-08
### Fixed
This release considerably extends what plugins can do: complete providers, custom components in the chat, slash commands, settings screens, and agent lifecycle hooks.
- Fixed OpenCode v2 turns longer than five minutes failing with `UND_ERR_HEADERS_TIMEOUT` ([#5674](https://github.com/getpaseo/paseo/pull/5674))
- Fixed the context meter staying empty during OpenCode v2 turns and disappearing after them ([#5710](https://github.com/getpaseo/paseo/pull/5710) by [@mcowger](https://github.com/mcowger))
- Fixed OpenCode v2 question cards showing only the header and offering no typed answer ([#5679](https://github.com/getpaseo/paseo/pull/5679))
- Fixed OpenCode v2 patch edits from GPT models showing as a raw Patch card instead of an edit diff ([#5696](https://github.com/getpaseo/paseo/pull/5696))
- Fixed completed OpenCode v2 edits showing no diff ([#5609](https://github.com/getpaseo/paseo/pull/5609))
The plugin API has a breaking change. Plugins now use separate client and server entries and runtime-owned SDK entry points, so plugins written for 0.7 must follow the [migration guide](https://paseo.sh/docs/plugins/v0.8/migration) before they load on 0.8.
## 0.10.1 - 2026-09-29
### Added
- Added Claude Sonnet 5.5 for Claude Code 2.1.284 and newer ([#5583](https://github.com/getpaseo/paseo/pull/5583) by [@yangqi](https://github.com/yangqi))
### Fixed
- Fixed OpenCode chats failing with "Variant unavailable" after switching to a model without the selected thinking level ([#5587](https://github.com/getpaseo/paseo/pull/5587))
- Fixed rewinding a Codex chat dropping its custom provider and Paseo tools ([#5345](https://github.com/getpaseo/paseo/pull/5345) by [@zbaibg](https://github.com/zbaibg))
- Fixed streamed replies joining lines of code blocks and Mermaid diagrams until the chat reloads ([#5577](https://github.com/getpaseo/paseo/pull/5577))
- Fixed a subagent opened from a split pane opening in a different pane ([#5451](https://github.com/getpaseo/paseo/pull/5451))
- Fixed archiving a custom Codex provider's agent leaving its session in Import session ([#5572](https://github.com/getpaseo/paseo/pull/5572))
- Fixed the `/` menu of a custom Codex provider with its own `CODEX_HOME` listing the daemon's prompts instead of its own ([#5450](https://github.com/getpaseo/paseo/pull/5450))
- Fixed a background `send_agent_prompt` returning `idle` for a prompt the agent accepted ([#5386](https://github.com/getpaseo/paseo/pull/5386))
- Fixed Windows file-link tooltips showing the full path for files inside the workspace ([#1987](https://github.com/getpaseo/paseo/pull/1987))
- Fixed terminal profiles that run `cursor-agent` showing the generic terminal icon ([#5379](https://github.com/getpaseo/paseo/pull/5379))
## 0.10.0 - 2026-09-28
### Added
- Added OpenCode v2 support, selected automatically from the installed `opencode` version ([#5198](https://github.com/getpaseo/paseo/pull/5198), [#5526](https://github.com/getpaseo/paseo/pull/5526) by [@karrots](https://github.com/karrots), [@dsingal0](https://github.com/dsingal0), [@gszep](https://github.com/gszep))
- Added task lists from rpiv-todo, pi-goal-x, and Pi's example todo extension to Pi chats ([#5309](https://github.com/getpaseo/paseo/pull/5309))
- Added subagent runs from pi-subagents, Tintinweb pi-subagents, and Gotgenes pi-subagents to the Subagents track ([#5309](https://github.com/getpaseo/paseo/pull/5309))
- Added rpiv-ask-user-question dialogs to Pi chats as one question form ([#5309](https://github.com/getpaseo/paseo/pull/5309))
- Added password checks to relay connections, rejecting an incorrect daemon password; a follow-up release will require the password ([#5393](https://github.com/getpaseo/paseo/pull/5393))
- Added a password field to Add host, pairing links, and QR pairing for password-protected daemons ([#5393](https://github.com/getpaseo/paseo/pull/5393))
- Added `relay://` connection strings to `paseo daemon pair` ([#5393](https://github.com/getpaseo/paseo/pull/5393))
- Added OMP Ask option descriptions to question cards on OMP 17.4.2 and newer ([#3628](https://github.com/getpaseo/paseo/pull/3628) by [@joeshull](https://github.com/joeshull))
### Changed
- Changed the CLI and desktop app to connect to a password-protected daemon on the same machine without asking for the password ([#5393](https://github.com/getpaseo/paseo/pull/5393))
- Changed a host rejected for its password to show "Password required" or "Incorrect password" on the host and Connections pages ([#5393](https://github.com/getpaseo/paseo/pull/5393))
- Reorganized Settings into General, Sidebar, Chat, Terminal, Browser, and Open location pages ([#5459](https://github.com/getpaseo/paseo/pull/5459))
### Fixed
- Fixed direct connections failing when the daemon password contains spaces or characters such as `@` or `/` ([#5393](https://github.com/getpaseo/paseo/pull/5393))
- Fixed existing OpenCode agents failing with `ECONNREFUSED` after the OpenCode server restarts ([#5338](https://github.com/getpaseo/paseo/pull/5338))
- Fixed messages OpenCode adds on its own appearing in the chat as if the user typed them ([#5434](https://github.com/getpaseo/paseo/pull/5434))
- Fixed the daemon stopping when `daemon.log` cannot be written, such as on a full disk ([#5445](https://github.com/getpaseo/paseo/pull/5445))
- Fixed the daemon crashing when opening an archived ACP agent whose worktree was removed ([#5439](https://github.com/getpaseo/paseo/pull/5439) by [@qinkangdeid](https://github.com/qinkangdeid))
- Fixed existing workspaces missing from the sidebar after the app starts, including after later restarts ([#5394](https://github.com/getpaseo/paseo/pull/5394))
- Fixed agents on a custom Claude provider with its own `CLAUDE_CONFIG_DIR` opening with an empty chat after a daemon restart ([#5437](https://github.com/getpaseo/paseo/pull/5437))
- Fixed a custom Codex provider's sessions missing from Import session ([#5446](https://github.com/getpaseo/paseo/pull/5446))
- Fixed Update daemon and Restart in host settings failing when a provider's version check takes over 1.5s ([#5372](https://github.com/getpaseo/paseo/pull/5372))
- Fixed `paseo --host <other daemon> run` failing with "Caller agent not found" from inside an agent session ([#5392](https://github.com/getpaseo/paseo/pull/5392))
- Fixed a Pi chat rewind going back further than the picked message after an earlier rewind ([#5383](https://github.com/getpaseo/paseo/pull/5383))
- Fixed a Pi chat rewind being undone when the daemon restarts ([#5432](https://github.com/getpaseo/paseo/pull/5432))
- Fixed Pi agents started without a model ignoring Pi's configured default model ([#5343](https://github.com/getpaseo/paseo/pull/5343))
- Fixed project skills in a new agent's `/` menu staying stale after the checkout switches branches ([#5415](https://github.com/getpaseo/paseo/pull/5415))
- Fixed slash commands missing from a new agent's `/` menu on custom ACP providers ([#5411](https://github.com/getpaseo/paseo/pull/5411))
- Fixed a parent agent receiving a child's finish notification twice after prompting the running child ([#5407](https://github.com/getpaseo/paseo/pull/5407))
- Fixed a blocking `send_agent_prompt` that outlasts its 30s wait never notifying the caller when the child finishes ([#5347](https://github.com/getpaseo/paseo/pull/5347))
- Fixed OSC 8 terminal links showing a navigation prompt and opening a blank Paseo window ([#5388](https://github.com/getpaseo/paseo/pull/5388) by [@liujin0506](https://github.com/liujin0506))
- Fixed the Theme menu not scrolling when plugin themes overflow the window ([#5374](https://github.com/getpaseo/paseo/pull/5374))
- Fixed a message from today's weekday last week showing only the weekday instead of its date ([#5341](https://github.com/getpaseo/paseo/pull/5341))
- Fixed `paseo daemon set-password` exiting silently when stdin is not a terminal ([#5358](https://github.com/getpaseo/paseo/pull/5358))
## 0.9.2 - 2026-09-24
### Added
- Added structured Claude Code launch arguments for session configuration and plugins ([#5206](https://github.com/getpaseo/paseo/pull/5206))
### Improved
- Reduced background Git polling for repositories the watcher cannot observe ([#5170](https://github.com/getpaseo/paseo/pull/5170))
- Reduced Add Project directory search time on large home directories ([#5190](https://github.com/getpaseo/paseo/pull/5190))
### Fixed
- Fixed the daemon becoming unresponsive when ignored directories appear after a workspace opens ([c3e1e08](https://github.com/getpaseo/paseo/commit/c3e1e084a068e5895710c43b034ced8ebef256e8) by [@Marcus172](https://github.com/Marcus172))
- Fixed daemon memory growing after client connections close ([9978988](https://github.com/getpaseo/paseo/commit/9978988e35409a018a52d0d7646f18b51e562346))
- Fixed a project folder ending in a space crashing the app on load ([#5205](https://github.com/getpaseo/paseo/pull/5205) by [@L4XB](https://github.com/L4XB))
- Fixed workspace labels missing when an agent screen opens before the sidebar ([#5079](https://github.com/getpaseo/paseo/pull/5079) by [@morven-ai](https://github.com/morven-ai))
- Fixed workspaces disappearing when their disk or network share is unavailable ([#5227](https://github.com/getpaseo/paseo/pull/5227))
- Fixed a failed session import making an archived worktree impossible to restore ([#5238](https://github.com/getpaseo/paseo/pull/5238))
- Fixed archived agent logs failing after Paseo removes their worktree ([#5229](https://github.com/getpaseo/paseo/pull/5229))
- Fixed a newly created branch pushing to the default branch through an inherited upstream ([#5249](https://github.com/getpaseo/paseo/pull/5249))
- Fixed fork checkout pull requests disappearing from the workspace after refresh ([#5221](https://github.com/getpaseo/paseo/pull/5221))
- Fixed agent-created local workspaces accepting a missing path or file ([#5322](https://github.com/getpaseo/paseo/pull/5322))
- Fixed chat uploads replacing valid characters in original file names with underscores ([#5317](https://github.com/getpaseo/paseo/pull/5317))
- Fixed multi-select questions dropping checked options or a typed Other answer ([#5320](https://github.com/getpaseo/paseo/pull/5320) by [@ThePharmer](https://github.com/ThePharmer))
- Fixed Android Back leaving the screen while a bottom sheet is open ([#5245](https://github.com/getpaseo/paseo/pull/5245))
- Fixed voice mode playing the thinking tone between spoken reply segments ([#5281](https://github.com/getpaseo/paseo/pull/5281))
- Fixed a Claude slash command sent with an attachment reaching Claude as plain text ([#5240](https://github.com/getpaseo/paseo/pull/5240) by [@joecorkerton](https://github.com/joecorkerton))
- Fixed Claude rewind after a turn that received no response ([#5285](https://github.com/getpaseo/paseo/pull/5285))
- Fixed Claude rewind after a turn containing subagent messages ([#5289](https://github.com/getpaseo/paseo/pull/5289))
- Fixed repeated agent history after refresh when a durable timeline store is configured ([#5286](https://github.com/getpaseo/paseo/pull/5286))
- Fixed an OMP custom message ending a turn before the provider finished ([#3258](https://github.com/getpaseo/paseo/pull/3258))
- Fixed a stopped OMP turn appearing as a failed turn ([#5243](https://github.com/getpaseo/paseo/pull/5243))
- Fixed Stop refusing to settle a Pi or OMP agent whose runtime has exited ([#5235](https://github.com/getpaseo/paseo/pull/5235))
- Fixed OpenCode agents ignoring their configured permission rules ([#5296](https://github.com/getpaseo/paseo/pull/5296) by [@gurvancampion](https://github.com/gurvancampion))
- Fixed Codex Default and Read-only modes sending approval requests to Auto-review ([#5239](https://github.com/getpaseo/paseo/pull/5239) by [@HMWCS](https://github.com/HMWCS))
- Fixed Cursor agent creation after switching from a Fast model to one without Fast ([#5274](https://github.com/getpaseo/paseo/pull/5274) by [@gengjiawen](https://github.com/gengjiawen))
- Fixed the Fast control missing for GPT-6 Sol and GPT-6 Luna in Codex ([#5273](https://github.com/getpaseo/paseo/pull/5273) by [@basilk15](https://github.com/basilk15), [@colonelpanic8](https://github.com/colonelpanic8))
- Fixed Claude's configured Fable model missing from the model picker ([#5326](https://github.com/getpaseo/paseo/pull/5326) by [@noahg9](https://github.com/noahg9))
- Fixed ACP terminals started by the daemon using the wrong agent identity ([#5248](https://github.com/getpaseo/paseo/pull/5248))
- Fixed an agent's completed plugin session appearing failed after a daemon restart ([#5253](https://github.com/getpaseo/paseo/pull/5253))
- Fixed plugin reload crashing its subprocess while a provider session closes ([#5231](https://github.com/getpaseo/paseo/pull/5231))
- Fixed a failed plugin provider request crashing the daemon ([#5298](https://github.com/getpaseo/paseo/pull/5298))
- Fixed Hub executions being unable to title workspaces they create ([#5302](https://github.com/getpaseo/paseo/pull/5302))
- Fixed an invalid schedule file preventing the daemon from starting ([#5301](https://github.com/getpaseo/paseo/pull/5301))
- Fixed an empty `paseo.pid` preventing the daemon from starting ([#5306](https://github.com/getpaseo/paseo/pull/5306))
- Fixed a recycled daemon PID preventing startup after a reboot ([#5277](https://github.com/getpaseo/paseo/pull/5277))
- Fixed daemon startup failing on `config.json` saved with a UTF-8 byte order mark ([#5315](https://github.com/getpaseo/paseo/pull/5315))
- Fixed background daemon startup errors missing from the reported log file ([#5332](https://github.com/getpaseo/paseo/pull/5332))
- Fixed CLI errors for invalid `config.json` omitting the file and failing field ([#5337](https://github.com/getpaseo/paseo/pull/5337))
- Fixed the CLI suggesting daemon startup after a password rejection ([#5310](https://github.com/getpaseo/paseo/pull/5310))
- Fixed Open in editor missing for a password-protected desktop daemon ([#5335](https://github.com/getpaseo/paseo/pull/5335))
- Fixed `paseo permit ls --json` shortening request IDs needed by `permit allow` and `permit deny` ([#5305](https://github.com/getpaseo/paseo/pull/5305))
- Fixed a replica cache read spinning when its store keeps rejecting writes ([#5290](https://github.com/getpaseo/paseo/pull/5290))
- Fixed the MiniMax card showing a raw error for an inactive token subscription ([#5258](https://github.com/getpaseo/paseo/pull/5258))
- Fixed modified Backspace being rejected as a custom shortcut ([#5224](https://github.com/getpaseo/paseo/pull/5224))
- Fixed multi-step shortcuts failing when the app updates between steps ([#5255](https://github.com/getpaseo/paseo/pull/5255) by [@colonelpanic8](https://github.com/colonelpanic8))
- Fixed multi-step shortcuts failing when the second step holds a modifier ([#5272](https://github.com/getpaseo/paseo/pull/5272))
- Fixed rebound pane-focus shortcuts failing while typing ([#5287](https://github.com/getpaseo/paseo/pull/5287))
## 0.9.1 - 2026-09-22
### Added
- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer ([#5200](https://github.com/getpaseo/paseo/pull/5200) by [@leonardourci](https://github.com/leonardourci), [@sebgalind0](https://github.com/sebgalind0), [@rp4ri](https://github.com/rp4ri))
### Fixed
- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon ([#5189](https://github.com/getpaseo/paseo/pull/5189) by [@bagutzu](https://github.com/bagutzu))
- Fixed Import session offering only the newest 100 Codex conversations ([#5174](https://github.com/getpaseo/paseo/pull/5174) by [@3ae3ae](https://github.com/3ae3ae))
- Fixed a Claude model whose ID carries a minor version, such as Opus 5.5, reverting to its major version in the composer once a turn finished ([#5200](https://github.com/getpaseo/paseo/pull/5200))
## 0.9.0 - 2026-09-22
### Added
- Added Cmd/Ctrl+F Find to file panes, with replacement in editable files ([#4589](https://github.com/getpaseo/paseo/pull/4589))
- Added Cmd/Ctrl+F Find to terminal scrollback ([#4650](https://github.com/getpaseo/paseo/pull/4650))
- Added Cmd/Ctrl+F Find to chat, including messages outside the loaded history window ([#4765](https://github.com/getpaseo/paseo/pull/4765))
- Added Jump to file to mobile Changes, opening the changed-files tree in a sheet ([#4861](https://github.com/getpaseo/paseo/pull/4861))
- Added automatic Pull request tab opening once per workspace when a PR is detected ([#4956](https://github.com/getpaseo/paseo/pull/4956))
- Added expandable plan cards, with rejected plans collapsed by default ([#4756](https://github.com/getpaseo/paseo/pull/4756))
- Added an attachment placeholder with a spinner while a selected file uploads ([#4958](https://github.com/getpaseo/paseo/pull/4958))
- Added match highlighting to the workspace, agent, project, and branch fields in History search ([#4945](https://github.com/getpaseo/paseo/pull/4945))
It also fixes regressions across desktop updates, mobile, Import session, and agent lifecycle.
### Plugins
- Added plugin installation from npm, including scoped packages, versions, tags, and ranges ([#4975](https://github.com/getpaseo/paseo/pull/4975))
- Added `paseo plugin update` with `--all`, `--check`, and `--yes`, showing the current and proposed revision before approval ([#4975](https://github.com/getpaseo/paseo/pull/4975))
- Added each plugin's description, source, and installed revision to Settings → Plugins ([#4975](https://github.com/getpaseo/paseo/pull/4975))
- Added an optional `serverId` to `navigation.openAgent()` and `navigation.openWorkspace()` ([#4942](https://github.com/getpaseo/paseo/pull/4942))
- Added host discovery through `useHosts()` and host-targeted SDK clients through `getPaseoClient(serverId)` ([#4971](https://github.com/getpaseo/paseo/pull/4971))
- Added `openExternalUrl()` and `<ExternalLink>` for opening a URL outside Paseo ([#4972](https://github.com/getpaseo/paseo/pull/4972))
- Added `navigation.openBrowser()` for opening a URL in a workspace browser on desktop ([#4972](https://github.com/getpaseo/paseo/pull/4972))
- Added a server settings handle returned by `registerSettings()` with `read()` and `subscribe()` ([#4674](https://github.com/getpaseo/paseo/pull/4674) by [@mcowger](https://github.com/mcowger))
- Changed `assistant_message` transformers to receive the whole accumulated message on each update ([#4675](https://github.com/getpaseo/paseo/pull/4675) by [@mcowger](https://github.com/mcowger), [@jegork](https://github.com/jegork))
- Changed `tool_call` transformers to receive every original call before Overview groups them ([#4675](https://github.com/getpaseo/paseo/pull/4675))
- Fixed a plugin session losing its host API permanently after its heartbeat lease expired ([#4912](https://github.com/getpaseo/paseo/pull/4912) by [@gpambrozio](https://github.com/gpambrozio))
- Fixed plugin build commands failing with `spawn npm ENOENT` on Windows ([#4776](https://github.com/getpaseo/paseo/pull/4776) by [@ABorakati](https://github.com/ABorakati))
- Fixed ACP text chunks without a `messageId` splitting one reply into a message per chunk ([#4701](https://github.com/getpaseo/paseo/pull/4701) by [@L4XB](https://github.com/L4XB))
- Fixed nested provider subagents appearing as direct children of the root agent ([#4970](https://github.com/getpaseo/paseo/pull/4970))
### Changed
- Changed History search to keep results chronological in date buckets instead of reordering by relevance ([#4945](https://github.com/getpaseo/paseo/pull/4945))
- Changed workspace Restore to keep the archived agent selected, with its own Unarchive action ([#4736](https://github.com/getpaseo/paseo/pull/4736))
- Changed the workspace error screen's Retry to Reload, which reopens at the project picker ([#4598](https://github.com/getpaseo/paseo/pull/4598))
- Changed closing the last content tab to leave the New launcher instead of an unusable pane ([#4844](https://github.com/getpaseo/paseo/pull/4844))
- Changed `paseo daemon start` to read persistent configuration; removed configuration flags fail with migration instructions ([#4575](https://github.com/getpaseo/paseo/pull/4575))
### Improved
- Bold, italics, strikethrough, inline code, and link labels stay formatted while a reply streams ([#4742](https://github.com/getpaseo/paseo/pull/4742))
- Reduced time to first voice audio from 4.80s to 0.95s on a three-sentence reply ([#4927](https://github.com/getpaseo/paseo/pull/4927))
- Reduced cold diff generation from 11.45s to 2.65s on a 213-file workspace ([#4676](https://github.com/getpaseo/paseo/pull/4676))
- Reduced desktop memory use, from 290.5 MiB to 152.1 MiB RSS in the Electron main process after loading daemon management ([#5007](https://github.com/getpaseo/paseo/pull/5007))
- Kept open chats subscribed across view eviction, app backgrounding, and reconnect ([#4863](https://github.com/getpaseo/paseo/pull/4863))
- Added reconnection and Updating messages status to the chat toast ([#4863](https://github.com/getpaseo/paseo/pull/4863))
- Kept the app responsive during large uploads by yielding between 128 KiB chunks ([#4958](https://github.com/getpaseo/paseo/pull/4958))
- Let resident browser pages idle between screenshots while the desktop window is hidden ([#4646](https://github.com/getpaseo/paseo/pull/4646))
### Fixed
- Fixed the daemon exhausting its heap in long conversations with cumulative tool output ([#4838](https://github.com/getpaseo/paseo/pull/4838))
- Fixed repeated submissions creating several workspaces or agents for one intent ([#4442](https://github.com/getpaseo/paseo/pull/4442))
- Fixed a crash loop after closing the last content tab in a workspace ([#4844](https://github.com/getpaseo/paseo/pull/4844))
- Fixed New Agent crashing when an enabled provider published duplicate model IDs ([#4839](https://github.com/getpaseo/paseo/pull/4839))
- Fixed long drafts growing behind the chat header on Android and iOS ([#4824](https://github.com/getpaseo/paseo/pull/4824))
- Fixed the Android composer growing taller when the keyboard closed ([#4902](https://github.com/getpaseo/paseo/pull/4902))
- Fixed the Android composer keeping its height after hold-to-delete emptied a draft ([#4946](https://github.com/getpaseo/paseo/pull/4946))
- Fixed New workspace setup content not moving up as the composer grows ([#4973](https://github.com/getpaseo/paseo/pull/4973))
- Fixed the Android timeline scrolling away when tapping or selecting text in a chat ([#5013](https://github.com/getpaseo/paseo/pull/5013))
- Fixed Paste image failing before an attachment reached the composer on Android ([#4758](https://github.com/getpaseo/paseo/pull/4758))
- Fixed provider and model pickers not responding on Android tablets ([#4845](https://github.com/getpaseo/paseo/pull/4845) by [@cjcrjc](https://github.com/cjcrjc), [@mkuhl](https://github.com/mkuhl))
- Fixed Linux desktop packages launching without the Chromium sandbox ([#4447](https://github.com/getpaseo/paseo/pull/4447))
- Fixed `paseo daemon stop` shutting down a daemon other than the selected one ([#4575](https://github.com/getpaseo/paseo/pull/4575))
- Fixed restored archived workspaces showing empty Changes and Commits ([#4926](https://github.com/getpaseo/paseo/pull/4926))
- Fixed a rejected plan appearing below the follow-up message that rejected it ([#4756](https://github.com/getpaseo/paseo/pull/4756))
- Fixed Cmd/Ctrl+F not opening chat Find while the composer had focus ([#4991](https://github.com/getpaseo/paseo/pull/4991))
- Fixed the linked pull request going undetected when a branch remote is a repository URL ([#4862](https://github.com/getpaseo/paseo/pull/4862))
- Fixed a plugin's filtered agent list replacing the app's own directory subscription ([#4596](https://github.com/getpaseo/paseo/pull/4596))
- Fixed an updated app rejecting daemons that lack independent subscriptions ([#4737](https://github.com/getpaseo/paseo/pull/4737))
- Fixed workspace and agent creation failing or leaving agent titles at "Loading…" on 0.8.0 and older daemons ([#4895](https://github.com/getpaseo/paseo/pull/4895))
- Fixed Cursor models showing another model's thinking options ([#4180](https://github.com/getpaseo/paseo/pull/4180) by [@fidelix](https://github.com/fidelix))
- Fixed Pi model pickers offering thinking levels the model does not support ([#4413](https://github.com/getpaseo/paseo/pull/4413) by [@mcowger](https://github.com/mcowger), [@therainisme](https://github.com/therainisme))
- Fixed Pi sessions reporting the requested thinking level instead of the one Pi applied ([#4413](https://github.com/getpaseo/paseo/pull/4413))
- Fixed voice-chat user messages showing the internal prompt wrapper instead of the transcript ([#4927](https://github.com/getpaseo/paseo/pull/4927))
- Fixed History and Command Center search matching a query assembled from letters in separate words ([#4945](https://github.com/getpaseo/paseo/pull/4945))
- Fixed workspace titles truncating early on touch layouts behind hidden diff stats ([#4698](https://github.com/getpaseo/paseo/pull/4698))
- Fixed the Explorer showing `+0 -0` and "No changes" while being dragged open ([#4861](https://github.com/getpaseo/paseo/pull/4861))
- Fixed the reconnect toast restarting its entrance animation when opening a saved chat ([#4925](https://github.com/getpaseo/paseo/pull/4925))
- Fixed raised shadows around Android file rows in the changed-files sheet ([#4898](https://github.com/getpaseo/paseo/pull/4898))
## 0.8.0 - 2026-09-10
Paseo 0.8 adds plugin header buttons, custom providers, and richer chat components, alongside fixes for desktop updates and mobile keyboards.
**Before upgrading:** the macOS desktop app now requires macOS 13 or newer; Linux and Windows support is unchanged. Plugins written for 0.7 need the [0.8 migration guide](https://paseo.sh/docs/plugins/v0.8/migration), including separate client/server entries and the revised composer pill API.
- Added provider contributions: a plugin registers a complete provider, directly or through the bundled ACP shim, with its own icon, session settings, permissions, and timeline rendering ([#4314](https://github.com/getpaseo/paseo/pull/4314))
- Added live timeline items: a plugin renders custom components in the chat by transforming timeline items at render time ([e34aea2](https://github.com/getpaseo/paseo/commit/e34aea206e6ec7ac2d5fa9766db28fb221ae990c))
- Added durable timeline rows: a daemon handler appends its own persisted rows to an agent's timeline ([e34aea2](https://github.com/getpaseo/paseo/commit/e34aea206e6ec7ac2d5fa9766db28fb221ae990c))
- Added slash commands: a plugin registers commands in the composer that run on the client without a provider turn ([e34aea2](https://github.com/getpaseo/paseo/commit/e34aea206e6ec7ac2d5fa9766db28fb221ae990c))
- Added settings screens: a plugin contributes screens under Settings → Plugins from shared settings components, with host-scoped persistence, validation, and Command Center navigation ([#4357](https://github.com/getpaseo/paseo/pull/4357))
- Added lifecycle hooks: server callbacks observe agent creation, turns, permissions, and archive events, and can answer permissions or send follow-ups ([#4435](https://github.com/getpaseo/paseo/pull/4435))
- Added agent creation transforms: before hooks rewrite a new agent's provider, model, launch environment, MCP servers, and workspace isolation ([#4435](https://github.com/getpaseo/paseo/pull/4435))
- Added modal bodies: a plugin owns the whole sheet body with custom padding, ScrollView or FlatList scrolling, horizontal tabs, keyboard-aware inputs, and a clipboard copy action ([#4392](https://github.com/getpaseo/paseo/pull/4392), [#4431](https://github.com/getpaseo/paseo/pull/4431))
- Added terminals to the SDK: create, list, write, send keys, capture, and kill terminals by workspace ID ([#4358](https://github.com/getpaseo/paseo/pull/4358))
- Added `paseo.projects.subscribe()` for live project updates ([#3983](https://github.com/getpaseo/paseo/pull/3983) by [@omercnet](https://github.com/omercnet))
- Added `agent.respondToPermission()` to answer pending permissions from a plugin ([#3985](https://github.com/getpaseo/paseo/pull/3985) by [@omercnet](https://github.com/omercnet))
- Added version requirements: `requirements.paseo` in `paseo-plugin.json` declares the supported Paseo versions, incompatible installs, updates, and reloads are rejected before any code runs, and prerelease Paseo versions satisfy any range their stable core satisfies ([#4430](https://github.com/getpaseo/paseo/pull/4430), [#4452](https://github.com/getpaseo/paseo/pull/4452))
- Changed plugins to separate `index.client.ts` and `index.server.ts` entries with `client/`, `server/`, and `shared/` directories; plugins written for 0.7 must follow the [migration guide](https://paseo.sh/docs/plugins/v0.8/migration) ([#4206](https://github.com/getpaseo/paseo/pull/4206))
- Changed the plugin SDK into runtime-owned entry points: `@getpaseo/plugin` for shared code, `@getpaseo/plugin/client` for the app, and `@getpaseo/plugin/server` for the daemon, with crossed imports rejected at compile time ([#4347](https://github.com/getpaseo/paseo/pull/4347) by [@liujin0506](https://github.com/liujin0506), [@panrafal](https://github.com/panrafal))
- Changed legacy plugin load failures to say the plugin was built for an older Paseo and link to the migration guide ([#4265](https://github.com/getpaseo/paseo/pull/4265))
- Changed `paseo plugin ls` to report runtime state, source, installed commit, and load errors without contacting a remote ([#4265](https://github.com/getpaseo/paseo/pull/4265))
- Fixed workspace plugin panels missing from the Explorer tab-rail menu ([#4446](https://github.com/getpaseo/paseo/pull/4446))
- Added versioned plugin documentation for v0.7 and v0.8 with a migration guide ([#4245](https://github.com/getpaseo/paseo/pull/4245), [#4452](https://github.com/getpaseo/paseo/pull/4452))
### Added
- Added answer forms for Codex questions asked while the agent continues working ([#4587](https://github.com/getpaseo/paseo/pull/4587))
- Added Mark as unread to finished workspaces in the sidebar menu ([#3603](https://github.com/getpaseo/paseo/pull/3603) by [@edihasaj](https://github.com/edihasaj))
- Added an in-app What's new sheet in the help menu, Settings → About, and desktop update callout ([#4576](https://github.com/getpaseo/paseo/pull/4576))
- Added Import session to the sidebar footer, History, and Command Center ([#4216](https://github.com/getpaseo/paseo/pull/4216))
- Added search, pagination, and workspace filtering to Import session ([#4216](https://github.com/getpaseo/paseo/pull/4216))
- Added sidebar item reordering and visibility controls in Appearance settings ([#4203](https://github.com/getpaseo/paseo/pull/4203))
- Added provider notifications from Pi extensions and OpenCode to the timeline ([#3411](https://github.com/getpaseo/paseo/pull/3411) by [@trim21](https://github.com/trim21))
- Added a fullscreen Mermaid viewer on web and desktop ([#4107](https://github.com/getpaseo/paseo/pull/4107) by [@gengjiawen](https://github.com/gengjiawen))
- Added Copy, Copy line, and Select all to the web diff context menu ([#4229](https://github.com/getpaseo/paseo/pull/4229))
- Added Import session to the sidebar footer, the History and sidebar empty states, and the Command Center ([#4216](https://github.com/getpaseo/paseo/pull/4216))
- Added search, Load more, and a This workspace / Show all scope to the Import session sheet ([#4216](https://github.com/getpaseo/paseo/pull/4216))
- Added customizable top-level sidebar items: reorder and hide New workspace, History, Search, Schedules, and plugin items from Appearance settings ([#4203](https://github.com/getpaseo/paseo/pull/4203))
- Added provider notifications from Pi extensions and OpenCode as timeline items with info, warning, and error levels ([#3411](https://github.com/getpaseo/paseo/pull/3411) by [@trim21](https://github.com/trim21))
- Added a fullscreen viewer for Mermaid diagrams on web and desktop ([#4107](https://github.com/getpaseo/paseo/pull/4107) by [@gengjiawen](https://github.com/gengjiawen))
- Added Copy, Copy line, and Select all to the web diff context menu, with Cmd/Ctrl+A and Escape ([#4229](https://github.com/getpaseo/paseo/pull/4229))
- Added Gajae Code to the ACP provider catalog ([#3471](https://github.com/getpaseo/paseo/pull/3471) by [@Yeachan-Heo](https://github.com/Yeachan-Heo))
- Added per-provider controls for disabling all or selected Paseo tools ([#4277](https://github.com/getpaseo/paseo/pull/4277) by [@mcowger](https://github.com/mcowger))
- Added explicit approval before running setup, automatic terminals, or scripts in fork pull request workspaces ([#4215](https://github.com/getpaseo/paseo/pull/4215))
- Added Hub follow-ups to existing agents, including archived workspace recovery ([#4354](https://github.com/getpaseo/paseo/pull/4354))
- Added provider catalog discovery to Hub executions ([#4242](https://github.com/getpaseo/paseo/pull/4242))
### Plugins
- Added custom providers with their own icons, settings, permissions, and timeline rendering ([#4314](https://github.com/getpaseo/paseo/pull/4314))
- Added provider session recovery after plugin reload ([#4629](https://github.com/getpaseo/paseo/pull/4629) by [@mcowger](https://github.com/mcowger))
- Added custom timeline components and persisted timeline rows ([e34aea2](https://github.com/getpaseo/paseo/commit/e34aea206e6ec7ac2d5fa9766db28fb221ae990c))
- Added client slash commands that run without a provider turn ([e34aea2](https://github.com/getpaseo/paseo/commit/e34aea206e6ec7ac2d5fa9766db28fb221ae990c))
- Added plugin settings screens under Settings → Plugins ([#4357](https://github.com/getpaseo/paseo/pull/4357))
- Added lifecycle hooks for agent creation, turns, permissions, and archive events ([#4435](https://github.com/getpaseo/paseo/pull/4435))
- Added transforms for new agents' provider, model, environment, MCP servers, and workspace isolation ([#4435](https://github.com/getpaseo/paseo/pull/4435))
- Added header buttons and composer pills with actions, menus, custom popovers, and live updates ([#4577](https://github.com/getpaseo/paseo/pull/4577))
- Added custom modal bodies with scrolling, keyboard-aware inputs, and clipboard actions ([#4392](https://github.com/getpaseo/paseo/pull/4392))
- Added terminal creation, input, capture, and management to the SDK ([#4358](https://github.com/getpaseo/paseo/pull/4358))
- Added live project subscriptions through `paseo.projects.subscribe()` ([#3983](https://github.com/getpaseo/paseo/pull/3983) by [@omercnet](https://github.com/omercnet))
- Added permission responses through `agent.respondToPermission()` ([#3985](https://github.com/getpaseo/paseo/pull/3985) by [@omercnet](https://github.com/omercnet))
- Added provider usage queries through `providers.listUsage()` ([#4062](https://github.com/getpaseo/paseo/pull/4062) by [@ivanbrykov](https://github.com/ivanbrykov))
- Added supported Paseo version requirements to plugin manifests ([#4430](https://github.com/getpaseo/paseo/pull/4430))
- Changed plugins to separate client and server entries with runtime-specific SDK imports ([#4347](https://github.com/getpaseo/paseo/pull/4347) by [@liujin0506](https://github.com/liujin0506), [@panrafal](https://github.com/panrafal))
- Changed composer pill registration to use `button`, `registration.update()`, and `registration.remove()` ([#4577](https://github.com/getpaseo/paseo/pull/4577))
- Changed `paseo plugin ls` to show runtime state, installed commit, and load errors without contacting a remote ([#4265](https://github.com/getpaseo/paseo/pull/4265))
- Fixed workspace plugin panels missing from the Explorer menu ([#4446](https://github.com/getpaseo/paseo/pull/4446))
- Added per-provider Paseo tool policy in daemon config to disable all or selected Paseo tools for a provider ([#4277](https://github.com/getpaseo/paseo/pull/4277) by [@mcowger](https://github.com/mcowger))
- Added explicit setup approval for fork pull request workspaces: setup, automatic terminals, and scripts wait for Run setup or `paseo workspace setup <id>` ([#4215](https://github.com/getpaseo/paseo/pull/4215))
- Added Hub follow-ups to existing agents, archived workspace recovery, and provider catalog discovery through the daemon API ([#4354](https://github.com/getpaseo/paseo/pull/4354), [#4242](https://github.com/getpaseo/paseo/pull/4242))
### Changed
- Changed the desktop app to require macOS 13 or newer; macOS 12 stops receiving desktop updates ([#4322](https://github.com/getpaseo/paseo/pull/4322))
- Changed `--host` to a global CLI option: `paseo --host <target> <command>` ([#4238](https://github.com/getpaseo/paseo/pull/4238))
- Changed the sidebar footer Home button to Import session ([#4216](https://github.com/getpaseo/paseo/pull/4216))
- Changed `paseo hub init` and default `paseo hub deploy` to use organization triggers in `.paseo/triggers/` ([#4517](https://github.com/getpaseo/paseo/pull/4517))
- Changed Codex Fast availability to the supported model list, including GPT-6 Astra ([#4640](https://github.com/getpaseo/paseo/pull/4640))
- Changed CLI installs to resolve patched React 19.1.x releases ([#4100](https://github.com/getpaseo/paseo/pull/4100) by [@liujin0506](https://github.com/liujin0506))
- Changed the sidebar footer Home button to Import session; the Home route and tile remain ([#4216](https://github.com/getpaseo/paseo/pull/4216))
### Improved
- Reconnected every host immediately when returning to the app ([#4160](https://github.com/getpaseo/paseo/pull/4160))
- Smoothed mobile keyboard transitions by moving the timeline and composer together ([#4275](https://github.com/getpaseo/paseo/pull/4275))
- Reduced stalls when opening large diffs ([#4574](https://github.com/getpaseo/paseo/pull/4574))
- Reloaded only providers whose configuration changed ([#4332](https://github.com/getpaseo/paseo/pull/4332))
- Removed unsolicited timeline and catalog traffic from idle SDK connections ([#4470](https://github.com/getpaseo/paseo/pull/4470))
- Added macOS updater diagnostics to App Diagnostics ([#4322](https://github.com/getpaseo/paseo/pull/4322))
- Reconnected every host immediately on app foreground instead of waiting for reconnect backoff ([#4160](https://github.com/getpaseo/paseo/pull/4160))
- Smoothed mobile keyboard transitions by moving the timeline and composer together instead of relaying out every frame ([#4275](https://github.com/getpaseo/paseo/pull/4275))
- Reloaded only the providers whose configuration changed instead of restarting every provider and resetting every catalog ([#4332](https://github.com/getpaseo/paseo/pull/4332))
- Shortened agent tab tooltips to one title line with the agent ID and last activity ([#4214](https://github.com/getpaseo/paseo/pull/4214))
- Added macOS updater and ShipIt state to App Diagnostics ([#4322](https://github.com/getpaseo/paseo/pull/4322))
### Fixed
- Fixed the mobile terminal keyboard closing and reopening when using toolbar keys or Enter ([#4469](https://github.com/getpaseo/paseo/pull/4469))
- Fixed macOS desktop updates closing the app without installing the new version ([#4322](https://github.com/getpaseo/paseo/pull/4322))
- Fixed the desktop updater reporting up to date before release manifests were available ([#4201](https://github.com/getpaseo/paseo/pull/4201))
- Fixed the desktop updater reporting up to date while a new release had no updater manifests yet ([#4201](https://github.com/getpaseo/paseo/pull/4201))
- Fixed `paseo restart` failing from the desktop-installed CLI on Linux `.deb`, RPM, and tarball installs ([#4207](https://github.com/getpaseo/paseo/pull/4207))
- Fixed the packaged macOS Dock icon rendering oversized ([#4389](https://github.com/getpaseo/paseo/pull/4389))
- Fixed the mobile terminal keyboard closing and reopening when using toolbar keys or Enter ([#4469](https://github.com/getpaseo/paseo/pull/4469))
- Fixed cached conversations and workspace lists failing to appear when live updates arrived during loading ([#4436](https://github.com/getpaseo/paseo/pull/4436))
- Fixed cached conversations and the workspace list not painting when live updates arrived during cache reads ([#4436](https://github.com/getpaseo/paseo/pull/4436))
- Fixed queued messages not sending after a turn completed while the app was in the background ([#4160](https://github.com/getpaseo/paseo/pull/4160))
- Fixed Codex reloads and follow-ups after workspace archive failing with an active-writer error ([#4353](https://github.com/getpaseo/paseo/pull/4353))
- Fixed repeated Claude compaction updates leaving extra Compacting rows spinning ([#4391](https://github.com/getpaseo/paseo/pull/4391) by [@tomgrin10](https://github.com/tomgrin10))
- Fixed the New workspace form losing the selected model after creating a workspace ([#4401](https://github.com/getpaseo/paseo/pull/4401))
- Fixed a crash with `Select a model` when the selected provider disappeared during workspace creation ([#4332](https://github.com/getpaseo/paseo/pull/4332))
- Fixed Codex model rows staying on Loading forever in the New workspace model picker ([#4283](https://github.com/getpaseo/paseo/pull/4283) by [@colonelpanic8](https://github.com/colonelpanic8))
- Fixed reloading a Codex agent failing with `already has an active writer` ([#4353](https://github.com/getpaseo/paseo/pull/4353))
- Fixed Pi agents stuck running after an extension-triggered turn ([#3849](https://github.com/getpaseo/paseo/pull/3849) by [@mjakl](https://github.com/mjakl))
- Fixed delayed workspace creation navigating away after you switched elsewhere ([#2986](https://github.com/getpaseo/paseo/pull/2986) by [@cleiter](https://github.com/cleiter))
- Fixed New workspace losing the selected model after creating a workspace ([#4401](https://github.com/getpaseo/paseo/pull/4401))
- Fixed a crash when the selected provider disappeared during workspace creation ([#4332](https://github.com/getpaseo/paseo/pull/4332))
- Fixed Codex model rows staying on Loading in the model picker ([#4283](https://github.com/getpaseo/paseo/pull/4283) by [@colonelpanic8](https://github.com/colonelpanic8))
- Fixed nested subagents appearing under the root agent instead of their actual parent ([#4321](https://github.com/getpaseo/paseo/pull/4321))
- Fixed repeated tool-call IDs across autonomous turns producing duplicate timeline rows ([140b0bb](https://github.com/getpaseo/paseo/commit/140b0bb716205cf0e00c0ff5b6b3c20af6c79413))
- Fixed Import session loading forever when one provider hung ([#4216](https://github.com/getpaseo/paseo/pull/4216))
- Fixed nested provider subagents shown as direct children of the root agent and their notifications appearing in the root timeline ([#4321](https://github.com/getpaseo/paseo/pull/4321))
- Fixed repeated provider tool-call IDs across autonomous turns producing duplicate timeline rows ([140b0bb](https://github.com/getpaseo/paseo/commit/140b0bb716205cf0e00c0ff5b6b3c20af6c79413))
- Fixed the Import session spinner never stopping when one provider hung; the provider now shows an inline error with Retry ([#4216](https://github.com/getpaseo/paseo/pull/4216))
- Fixed Import session ignoring Claude Code `/rename` titles ([#4216](https://github.com/getpaseo/paseo/pull/4216))
- Fixed importing a session creating a duplicate workspace for an already-open directory ([#4216](https://github.com/getpaseo/paseo/pull/4216))
- Fixed importing a session into a directory with an active workspace creating a duplicate workspace ([#4216](https://github.com/getpaseo/paseo/pull/4216))
- Fixed empty ACP sessions appearing in Import session ([#4335](https://github.com/getpaseo/paseo/pull/4335))
- Fixed restored terminal titles briefly showing Terminal before loading ([#4553](https://github.com/getpaseo/paseo/pull/4553))
- Fixed diff file headers lagging behind while scrolling on web and desktop ([#4240](https://github.com/getpaseo/paseo/pull/4240))
- Fixed Changes and its diff going out of sync when switching between Committed and Uncommitted ([#4199](https://github.com/getpaseo/paseo/pull/4199))
- Fixed Mermaid diagrams shrinking during streaming or becoming corrupted on reload ([#4210](https://github.com/getpaseo/paseo/pull/4210))
- Fixed diff file headers lagging behind the document while scrolling on web and desktop ([#4240](https://github.com/getpaseo/paseo/pull/4240))
- Fixed the Changes tree and its working diff going out of sync when switching between Committed and Uncommitted ([#4199](https://github.com/getpaseo/paseo/pull/4199))
- Fixed Mermaid diagrams shrinking on every streaming re-render and the inline diagram box collapsing ([#4107](https://github.com/getpaseo/paseo/pull/4107) by [@gengjiawen](https://github.com/gengjiawen))
- Fixed Mermaid source corrupted on reload when cached history overlapped the live message ([#4210](https://github.com/getpaseo/paseo/pull/4210))
- Fixed Settings navigation retaining closed views in memory on desktop ([#4228](https://github.com/getpaseo/paseo/pull/4228))
- Fixed the file watcher missing nested directory changes on Windows ([#4354](https://github.com/getpaseo/paseo/pull/4354))
- Fixed daemon Git commands allowing a repository's `core.fsmonitor` to execute its own command ([#4208](https://github.com/getpaseo/paseo/pull/4208))
- Fixed terminal activity reporting for OpenCode 2 sessions ([#4300](https://github.com/getpaseo/paseo/pull/4300) by [@mr-karan](https://github.com/mr-karan))
- Fixed the website offering downloads for the wrong platform before loading ([#4222](https://github.com/getpaseo/paseo/pull/4222))
- Fixed the file watcher missing nested directory changes on Windows ([#4354](https://github.com/getpaseo/paseo/pull/4354), [#4358](https://github.com/getpaseo/paseo/pull/4358))
- Fixed daemon Git commands honoring a repository's `core.fsmonitor`, which let a checkout run its own command ([#4208](https://github.com/getpaseo/paseo/pull/4208))
- Fixed terminal activity reporting for OpenCode 2 sessions started from a terminal ([#4300](https://github.com/getpaseo/paseo/pull/4300) by [@mr-karan](https://github.com/mr-karan))
- Changed the plugin SDK's React peer range to `~19.1.0` so CLI installs resolve a patched 19.1.x instead of the version dependency scanners flag ([#4100](https://github.com/getpaseo/paseo/pull/4100) by [@liujin0506](https://github.com/liujin0506))
- Fixed the paseo.sh homepage offering the Mac download to every visitor before hydration and desktop builds to phones ([#4222](https://github.com/getpaseo/paseo/pull/4222))
## 0.7.2 - 2026-09-02
+2 -8
View File
@@ -106,15 +106,10 @@ Repo dev commands use checkout-local state by default. In this checkout, `PASEO_
See [docs/development.md](docs/development.md) for full setup, build sync requirements, and debugging.
## Release branches
When the user says "this goes to next", create or
retarget the PR to `next` and preserve that destination through delivery. Follow
[release branch discipline](docs/release.md#release-branch-discipline) for creating
and updating `next`, integrating it after a release, and releasing a hotfix from a tag.
## Critical rules
- **Before changing the plugin SDK, compiler, host module maps, scaffold, or examples, read [SDK import boundaries](docs/plugins.md#sdk-import-boundaries).** Classify the export by runtime first and preserve the enforced boundaries.
- **NEVER restart the main Paseo daemon on port 6767 without permission** — it manages all running agents. If you're an agent, restarting it kills your own process.
- **NEVER assume a timeout means the service needs restarting** — timeouts can be transient.
- **NEVER add auth checks to tests** — agent providers handle their own auth.
@@ -125,7 +120,6 @@ and updating `next`, integrating it after a release, and releasing a hotfix from
- If you must run a broad suite, pipe output to a file and read it afterward: `npx vitest run <file> --bail=1 > /tmp/test-output.txt 2>&1` then read the file.
- Never re-run a test suite that another agent already ran and reported green — trust the result.
- For full suite verification, push to CI and check GitHub Actions instead.
- Add tests to existing suites and reuse their npm scripts and CI jobs instead of creating feature-specific ones.
- **Always run typecheck and lint after every change.**
- **Build workspace packages before diagnosing cross-package type errors.** This repo consumes generated declarations across workspaces. If typecheck fails in a package that depends on another workspace, rebuild the owning stack first so `dist` declarations are current:
- `npm run build:client` — rebuild protocol and client declarations.
-6
View File
@@ -151,12 +151,6 @@ npm run build:server
npm run typecheck
```
## スポンサー
Paseo は一人で開発しており、使ってくれる人たちの支援で成り立っています。[GitHub Sponsors](https://github.com/sponsors/boudra) で支援できます。企業として毎月[スポンサー](https://paseo.sh/sponsor#spot)になっていただくと、ロゴをここと paseo.sh のホームページに掲載します。
<!-- Sponsor logos go here, in the same order as packages/website/src/data/sponsors.ts -->
## 関連プロジェクト
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — Elixir 製の公式分散リレー
-6
View File
@@ -161,12 +161,6 @@ npm run build:server
npm run typecheck
```
## 스폰서
Paseo는 한 사람이 개발하며, 사용하는 분들의 후원으로 운영됩니다. [GitHub Sponsors](https://github.com/sponsors/boudra)로 후원할 수 있습니다. 회사는 매월 [Paseo를 후원](https://paseo.sh/sponsor#spot)하고 로고를 이곳과 paseo.sh 홈페이지에 게재할 수 있습니다.
<!-- Sponsor logos go here, in the same order as packages/website/src/data/sponsors.ts -->
## 관련 프로젝트
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — Elixir로 작성한 공식 분산형 릴레이
+3 -8
View File
@@ -50,9 +50,10 @@ Run agents in parallel on your own machines. Ship from your phone or your desk.
## Plugins
Add themes, workspace panels, commands, settings screens, and coding-agent providers with trusted
TypeScript plugins. Install from npm, Git, or a local directory with `paseo plugin install <source>`.
TypeScript plugins. Install from a local directory or Git repository with `paseo plugin add <source>`.
Start with the [plugin quickstart](https://paseo.sh/docs/plugins). Plugins run with access to your daemon
See the [plugin docs](https://paseo.sh/docs/plugins) for your Paseo version, or start with the
[0.8 beta quickstart](https://paseo.sh/docs/plugins/v0.8). Plugins run with access to your daemon
machine and inside connected clients; install only code you trust.
## Getting Started
@@ -193,12 +194,6 @@ npm run build:server
npm run typecheck
```
## Sponsors
Paseo is built by one person and funded by the people who use it. Support the work on [GitHub Sponsors](https://github.com/sponsors/boudra). Companies can [sponsor Paseo](https://paseo.sh/sponsor#spot) monthly and have their logo shown here and on the paseo.sh homepage.
<!-- Sponsor logos go here, in the same order as packages/website/src/data/sponsors.ts -->
## Related projects
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — official distributed relay, written in Elixir
-6
View File
@@ -151,12 +151,6 @@ npm run build:server
npm run typecheck
```
## 赞助
Paseo 由一个人开发,靠使用它的人来资助。你可以通过 [GitHub Sponsors](https://github.com/sponsors/boudra) 支持这项工作。公司也可以按月[赞助 Paseo](https://paseo.sh/sponsor#spot),logo 会展示在这里和 paseo.sh 首页上。
<!-- Sponsor logos go here, in the same order as packages/website/src/data/sponsors.ts -->
## 相关项目
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — 官方分布式 relay,使用 Elixir 编写
+4 -6
View File
@@ -42,20 +42,18 @@ The daemon requires a valid cryptographic handshake before processing any comman
The QR code or pairing link is the trust anchor. It contains the daemon's public key, which is required to establish the encrypted connection. Treat it like a password — don't share it publicly.
When a daemon password is configured, new relay clients send it in the encrypted `hello` message. This release still admits relay clients that send no credential so existing mobile builds continue to connect. A wrong password is rejected. The next release will require the password for relay connections after updated mobile builds are available.
## Local daemon trust boundary
By default, the daemon binds to `127.0.0.1`. With no password configured, anything that can reach the daemon socket can control the daemon. Loopback is reachable by other users on the machine and by some forwarding tools.
By default, the daemon binds to `127.0.0.1`. With no password configured, the local control plane is trusted by network reachability — anything that can reach the daemon socket can control the daemon. This is the same security model Docker documents for its daemon: the security boundary is access to the socket or listening address.
The daemon supports an optional shared-secret password (set via `auth.password` in `config.json` or the `PASEO_PASSWORD` env var; stored bcrypt-hashed). WebSocket clients send the password in `hello`; the daemon sends no session data before admission. Direct connections still accept bearer headers and WebSocket bearer subprotocols for older clients. HTTP stays bearer-header based. Health (`GET /api/health`) and CORS preflight (`OPTIONS`) are exempt; `/api/files/download` and `/mcp/agents` use their own capability tokens.
The daemon writes a new `$PASEO_HOME/local-credential` on every run with mode `0600` and removes it on shutdown. The CLI and desktop main process read it only for the daemon whose PID lock `listen` matches their connection target. A same-user process can read this credential, so the password protects against network clients and other OS users, not processes running as the daemon user. Protect `$PASEO_HOME` accordingly. Relay traffic remains end-to-end encrypted independently of password admission.
The daemon also supports an optional shared-secret password (set via `auth.password` in `config.json` or the `PASEO_PASSWORD` env var; stored bcrypt-hashed). When configured, every HTTP request must carry `Authorization: Bearer <password>` and every WebSocket upgrade must include a `Sec-WebSocket-Protocol: paseo.bearer.<password>` subprotocol. Browser WebSocket cannot set custom headers, which is why the token rides in the subprotocol. Health (`GET /api/health`) and CORS preflight (`OPTIONS`) are exempt. The password is intended for direct-TCP exposure (e.g. `tcp://host:port?ssl=true&password=...`); it is **not** a substitute for the relay's E2E encryption when traversing untrusted networks.
Connected clients are trusted operators of the daemon user. File previews follow that authority: a preview request may read any regular file the daemon process can read, while keeping path normalization and symlink checks in the daemon file service. Workspace-relative paths remain a UI convenience, not a security boundary.
When Paseo checks out a change request from a different repository, it does not run that workspace's `paseo.json` setup, automatic terminals, named scripts, or teardown until you explicitly run setup for that workspace. The decision lasts for the workspace and does not re-prompt after new commits. Same-repository changes, ordinary branches, local workspaces, agent launches, terminals, explicit shell commands, and metadata-generation instructions are outside this gate.
An explicit `symlink <path>` entry in a repository's `.worktreeinclude` gives a Paseo-created worktree live access to that source-checkout file or directory. It is useful for local dependencies and caches, but it weakens the usual worktree isolation: agents and lifecycle scripts can modify the source through the link. Paseo validates entries and refuses traversal or destination-link escapes, but the linked source is a deliberate shared-data boundary.
If you expose the daemon beyond loopback, such as by binding to `0.0.0.0`, forwarding it through a tunnel or reverse proxy, or publishing it from a Docker container, you are responsible for restricting and securing that access. Setting a password is strongly recommended in that case.
In Docker, the official image runs the daemon and agents as the non-root
+2 -2
View File
@@ -51,7 +51,7 @@ const client = new DaemonClient({
appVersion: "0.1.70", // see gotcha #1
});
await client.connect();
await client.fetchAgents({ subscribe: {} });
await client.fetchAgents({ subscribe: { subscriptionId: "test" } });
// ... do your testing ...
@@ -81,7 +81,7 @@ const client = new DaemonClient({
appVersion: "0.1.70",
});
await client.connect();
await client.fetchAgents({ subscribe: {} });
await client.fetchAgents({ subscribe: { subscriptionId: "test" } });
// ... test ...
+12 -31
View File
@@ -34,8 +34,8 @@ workflows all live in the CLI process, and the completion notification that woul
agent never arrives. A runtime that dies mid-turn is reported by whatever is draining its stream, but
between turns nothing is watching, so the agent sits at `idle` looking healthy while its background
work is gone. Report that exit as a turn failure so the agent lands in `error` with a timeline entry.
Claude and OMP report exits between turns. OMP relaunches from its session file on the next prompt;
the unfinished turn is lost.
Only the Claude provider does this today; the others still report a death only when a turn happens to
be in flight.
### Cancellation
@@ -109,28 +109,16 @@ archived workspace. History navigation must not infer workspace lifecycle from `
or mutate either lifecycle. The workspace route asks the daemon for authoritative recovery state;
only the route's explicit Unarchive or Restore action changes the archived workspace.
History navigation opens the selected agent without changing either archive state. Workspace
**Restore** recovers only the workspace; the selected archived agent stays open with its callout.
The agent's **Unarchive** runs the provider's native unarchive hook before interactive resume and
history hydration. Other archived agents stay archived.
Opening an agent is a navigation choice, independent of whether its details are cached. The
layout retains that choice across reload while the panel fetches the agent from the daemon.
Once the daemon reports the agent active, its tab follows normal archive propagation again.
An empty active list cannot cancel an explicit History selection. Agent-detail loading does
not own selection or release the explicit open.
Persisted resume, native restore, and both live and stored-only archive enter the same per-agent
lifecycle queue. Resume chooses its history or interactive purpose from the durable record after
entering that queue. Shutdown must finish before the manager releases runtime ownership; a failed
close retains the runtime for cleanup and blocks replacement through that close operation.
Authoritative timeline catch-up can use a runtime-only `history` resume purpose. For Codex, that
purpose initializes a temporary app-server, reads the persisted thread and child histories, and
releases the process before returning. It never loads, resumes, or unarchives a native thread,
including legacy records whose native archive failed. The retained history session contains only
the read results. Interactive resume remains responsible for repairing a provider session archived
outside Paseo while its Paseo agent is active.
History navigation preserves the selected agent as an explicit recovery target. If both that agent
and its workspace are archived, the workspace recovery action restores the workspace and unarchives
the selected agent as one user action. Other archived agents in the restored workspace remain
recoverable from History. Opening one pins its tab and renders the archived-agent callout. Authoritative
timeline catch-up may load provider history with a runtime-only `history` resume purpose, which must
leave both Paseo's `archivedAt` and the provider's native archive state unchanged. **Unarchive** remains
the only transition back to an interactive runtime: it runs the provider's native unarchive hook
(including Codex `thread/unarchive`) before the normal agent resume and timeline hydration flow. A
provider session can be archived outside Paseo while its Paseo agent remains active. Interactive
resume repairs that drift through the provider's native unarchive hook; history resume does not.
Provider session connection owns every process it spawns until the session is registered with
`AgentManager`. If initialization, persisted-session resume, or initial history hydration fails,
@@ -164,13 +152,6 @@ A finished workspace can be marked unread after it has been reviewed. The daemon
`finished` attention on its newest eligible workspace-root agent without sending a new completion
notification. Opening the workspace clears that attention through the normal focus flow.
Attention is set by the agent finishing or failing and cleared by the client's
`workspace.clear_attention`, which fires when the user reads the chat. Loading an agent's runtime is
neither, so resuming carries the stored attention and the stored last-activity time through
untouched. Forging either makes a background resume look like the user read a workspace and like the
agent worked in it just now, which rewrites the sidebar timestamp permanently — persisted
`updatedAt` is what workspace `statusEnteredAt` is re-derived from on the next daemon start.
## The subagents track
The track is a pill at the foot of an agent's pane (`packages/app/src/subagents/track.tsx`): a count you can read at a glance, and a panel behind it — a popover on wide screens, a sheet on compact ones — holding the rows. It floats over the transcript rather than sitting in a band above the composer, so the timeline scrolls underneath it; `packages/app/src/panels/agent-tracks.tsx` owns that placement, and the pill frame is shared with the task list in `packages/app/src/composer/tracks.tsx`.
+1 -19
View File
@@ -12,8 +12,7 @@ provider deltas (every provider streams incrementally)
→ recordTimeline: one canonical row per flushed item
→ agent_stream ws message
→ reducer queue (app, one commit per frame) → session store
→ source-item plugin transforms → native Markdown blocks / tool grouping
→ paced reveal (app, per displayed item) → paint
→ paced reveal (app, per assistant/reasoning item) → markdown blocks → paint
```
Every provider delivers incremental text, so there is no provider that needs special handling: Claude via `includePartialMessages`, Codex via `agent_message_delta`, ACP agents via `agent_message_chunk`, Pi and OMP via `text_delta`.
@@ -29,23 +28,6 @@ So arrival sets a _target_ and the reveal rate is derived from the backlog inste
- **The coalescer is leading + trailing.** The first delta after an idle window flushes synchronously; only the rest of the burst waits for the trailing timer. Reverting to trailing-only adds a full window to the first character of every turn. Same shape and the same reason as `TerminalOutputCoalescer`.
- **The leading flush adds a canonical row, and that is fine.** A burst's first chunk lands as its own timeline row. `mergeAssistantChunks` / `mergeReasoningChunks` in `timeline-projection.ts` join contiguous same-turn rows, and clients read the projected timeline, so history is unaffected. Tests that assert on raw rows have to account for the extra row; tests that assert on what a client sees do not.
- **The store holds the full text; only the rendered slice is paced.** Copy, selection, the chat outline, and scroll geometry all read the same string the user can see. Pacing the store instead would leave the bottom anchor chasing a content height that is ahead of the reveal.
- **Markdown blocks belong to presentation, and every assistant message is a block group.**
`agent-stream/presentation.ts` splits a message into one display row per Markdown block, the same
way whether it arrived streaming or as fetched history. Splitting in the reducer instead would
discard paragraph separators and expose fragments to plugin callbacks, which need the whole source
text. Live-head work stays cheap by parsing only the growing last block during append, retaining
completed blocks by object identity, and caching the split per source item so a tail change does
not re-split history. History was once left whole to keep cross-block Markdown context; that gave
one message two shapes depending on how it arrived, and message-addressed features only worked on
one of them.
- **A row id is never a message id.** Block rows are `${messageId}:block:${n}` with
`blockGroupId = messageId`, including single-block messages, so nothing can come to rely on the two
being equal. `getStreamItemMessageId` in `presentation.ts` is the only way to go from a row to its
message, and web rows carry it as `data-message-id` alongside `data-history-row-id`. Anything
addressing a message — chat find, scroll-to-message, history reveal, the find expansion that lifts
the render cap — uses the message id and must expect several rows to answer to it. Row ids stay for
React keys, virtualizer measurement, scroll anchors, and per-row caches like assistant image
occurrence keys.
- **First sight of a text is revealed whole.** Only growth is paced. This is what makes history hydration, timeline replay, a virtualized row remounting on scroll, and an already-finished message all render complete on first paint without a special case for each.
- **Leaving `phase: "streaming"` snaps the reveal.** A completed turn must never be left holding characters. `layoutStream` sets the phase, so anything outside the live head with an active turn is already complete.
- **The reducer queue commits on a frame, with a timer as the ceiling.** A frame callback never fires in a hidden tab, so a timer races it and wins when nothing is painting — the store has to keep advancing either way.
-11
View File
@@ -120,17 +120,6 @@ REACT_NATIVE_PACKAGER_HOSTNAME=localhost \
This is the Android counterpart of the iOS local-simulator flow in [development.md](development.md): on iOS the simulator shares the Mac's loopback so `localhost:<port>` works directly; on Android you need `10.0.2.2` or `adb reverse`.
## Inverted timeline selection
Android focus and selection visibility requests must not reposition inverted timelines. The
`modules/paseo-scroll` package keeps React Native's scroll manager interface and returns zero for
child-reveal scroll calculations when the vertical scale is inverted. Dragging and explicit scroll
commands still work; non-inverted scroll views keep Android's default behavior.
Register this package before React Native's core package through its Expo config plugin. Normal
Android builds use the prebuilt `react-android` library, so patching Java under `node_modules` does
not change the shipped scroll view. Keep this behavior in the app's compiled native module.
## F-Droid / source-only Android builds
F-Droid builds should set `PASEO_FDROID_BUILD=1` when running Expo prebuild:
+6 -60
View File
@@ -54,15 +54,6 @@ The heart of Paseo. A Node.js process that:
All paths are under `packages/server/src/`.
Desktop and CLI import server capabilities through explicit package subpaths such as
`@getpaseo/server/daemon-control`, `/configuration`, and `/process`. The root export
loads daemon bootstrap eagerly, even when a caller only uses a path or process helper.
Keep process-management and configuration dependencies independent of bootstrap and
WebSocket message schemas. Shared configuration schemas belong in protocol leaf modules;
`messages` imports and re-exports them. This prevents every supervising process from
retaining the daemon's runtime and wire-schema allocations. The server export tests guard
these dependency trees with tree-shaking disabled to match unbundled production imports.
Project identity is daemon-global rather than session-owned. After registry bootstrap, the daemon's
project Git observer keeps one non-recursive watch on each lexically equivalent active project root
and listens only for the root `.git` entry, with a slow rescan as a missed-event fallback. It runs
@@ -154,7 +145,7 @@ traffic. Workspace assignments stay on the workspace directory sequence.
Commander.js CLI with Docker-style commands. Common agent operations are also exposed at the top level (e.g. `paseo ls`, `paseo run`).
- `paseo agent ls/run/import/attach/logs/stop/delete/send/inspect/wait/archive/reload/update/mode`
- `paseo daemon start/run/stop/restart/status/reload/config/pair/set-password`
- `paseo daemon start/stop/restart/status/pair/set-password`
- `paseo terminal ls/create/capture/send-keys/kill`
- `paseo script ls/start/stop`
- `paseo schedule create/ls/inspect/update/pause/resume/run-once/logs/delete`
@@ -248,22 +239,11 @@ Server → Client: status message with payload { status: "server_info",
serverId, hostname, version, capabilities?, features }
```
There is no dedicated welcome message; the server emits one initial `status.server_info` after
accepting hello. Modern clients negotiate `owned_subscriptions` against
`features.ownedSubscriptions`. Connecting or attaching a local listener creates no application-data
demand. A list is a snapshot; adding `subscribe: {}` creates an independent server ID. The client
correlates the initial reply by request ID, then routes updates by subscription ID.
A logical Session can contain several physical sockets. Each socket owns its capabilities,
subscriptions and ongoing operations; the final JSON/binary send boundary requires that source's
request or subscription provenance. Domain modules own filtering, bootstrap and resource teardown.
The shared ownership boundary only tracks source and lifetime. Releasing a subscription acknowledges
its teardown; reconnect creates new server IDs for surviving client handles. See
[protocol compatibility](protocol-compatibility.md) for the legacy boundary.
There is no dedicated welcome message; the server emits a `status` session message after accepting the hello, then begins streaming. The session stores client capabilities from the hello and rehydrates them on reconnect, so the wire boundary can ask one question: `session.supports(...)`.
**Top-level WS envelopes** are `hello`, `recording_state`, `ping`/`pong`, and `session` (which wraps the rich union of session messages).
Client liveness checks use the top-level JSON `ping`/`pong` envelope, not a session RPC or RFC6455 control ping. Current clients ping every 10 seconds, beginning one interval after connecting. The first ping claims an application-ownership lease for that physical socket, all later inbound activity renews it, and the daemon forcibly terminates the socket if the lease expires. A legacy or raw socket that never sends an application ping never enters this lease and is not closed for omitting one. Session RPC timeouts are operation failures, not proof that the socket is dead. A subscription bootstrap timeout closes its source to clear any unknown server-owned registration; the failed handle is released before other surviving handles reconnect.
Client liveness checks use the top-level JSON `ping`/`pong` envelope, not a session RPC or RFC6455 control ping. Current clients ping every 10 seconds, beginning one interval after connecting. The first ping claims an application-ownership lease for that physical socket, all later inbound activity renews it, and the daemon forcibly terminates the socket if the lease expires. A legacy or raw socket that never sends an application ping never enters this lease and is not closed for omitting one. Session RPC timeouts are operation failures and must not be treated as proof that the socket is dead.
Every physical send path enforces an 8 MiB outbound high-water mark, including JSON broadcasts, binary terminal frames, and the encrypted relay adapter's asynchronous queue. This sits above the terminal stream's 4 MiB soft backpressure threshold, leaving room for snapshot catch-up before the hard cutoff. JSON is serialized once per broadcast after sockets already at the limit are removed, then its exact byte length is checked for every remaining socket. A frame that would cross the limit is not sent; that physical socket is forcibly terminated without disturbing other sockets attached to the same logical session. Multiple tabs and simultaneous direct and relay paths may legitimately share a client id.
@@ -317,7 +297,7 @@ messages or transfers.
- WebSocket schemas are append-only. Add fields, do not remove fields, and never make optional fields required.
- New wire enum values must be gated at serialization with `session.supports(CLIENT_CAPS.someCapability)`.
- Capabilities belong to the physical source socket. `session.supports(...)` reads the requesting or observing source at the domain projection boundary; one socket must not borrow a sibling's capabilities.
- `Session` stores client capabilities from the `hello` handshake and rehydrates them on reconnect, so the wire boundary can ask one question: `session.supports(...)`.
Example: adding a new enum value
@@ -328,36 +308,6 @@ Example: adding a new enum value
// 4. Gate the new emitted value: session.supports(CLIENT_CAPS.newThing) ? "new_value" : "old_value"
```
### Creation ownership
Creation is owned by the daemon across socket lifetimes. A workspace request can include
its initial agent and prompt. Workspace readiness is published before provider startup;
the app keeps its existing disk-ready navigation and observes the remaining creation.
Callbacks do not advance the workflow. Resource reservations in acknowledgement are
identities, not ready workspace records.
The creation journal (`server/creation/`) owns identity and execution for both legacy and
modern creation RPCs. Requesting progress never selects a different journal. Existing
agent receipts are imported at this boundary; message delivery receipts remain separate.
It keeps cumulative milestones and permanent IDs under an operation kind and idempotency
key. Reconnect subscribes to that key; retries
join active work or reuse committed stages. A persisted resource alone cannot prove
that a provider accepted its initial prompt. Interrupted side effects with no conclusive
receipt return an unknown outcome instead of being repeated.
`packages/client/src/creation/` owns capability selection and legacy orchestration.
Callers always pass the initial prompt to agent creation. On an older host, the client
adapts keyed creation to the legacy create/send sequence; it cannot continue that
sequence after the client process disappears. Explicitly requested IDs or receipts
that an old host cannot honor produce an unsupported error. Keep these adapters inside
the client package, as an exception to the default no-fallback feature policy.
Creation executes through the existing Session capabilities. Connection-owned delivery
controls observation only: detaching a socket or cleaning up its Session does not cancel
accepted creation. Updates require an explicit subscription and go only to that socket;
reconnect uses the shared subscription owner. Legacy consumers, including Hub, keep their
existing response contract.
## Agent lifecycle
The lifecycle states are defined in `shared/agent-lifecycle.ts`:
@@ -468,16 +418,12 @@ $PASEO_HOME/
├── config.json # Daemon config (mutable)
├── daemon-keypair.json # Daemon identity for relay/E2EE
├── push-tokens.json # Mobile push tokens
├── paseo.pid # Supervisor identity and published bound endpoint
├── paseo.sock / paseo.pid # Local IPC socket and pidfile
└── daemon.log # Daemon trace logs (rotated)
```
The supervisor alone publishes its ready worker's endpoint in `paseo.pid`, clears it before respawn, and fails if any worker exits before first readiness. CLI home selection trusts only that live record; config expresses desired state, never an endpoint fallback. POSIX home stop signals the captured supervisor without TCP. Windows graceful stop and ordinary RPCs trust the published endpoint. This metadata is not cryptographic listener ownership: edited endpoints, copied identities, PID reuse, and address takeover races remain outside that guarantee. A stale heartbeat never permits reclaiming a live lock.
Worker restart retains supervisor arguments/environment and rereads the configuration file. Updating a package and observing its new worker version do not refresh the running supervisor code; the launcher owns full-process replacement. See [CLI lifecycle contracts](../public-docs/cli.md#daemon-management).
## Deployment models
1. **Local daemon** (default): `paseo daemon start` on `127.0.0.1:6767`
2. **Managed desktop**: Electron uses the server package’s local-instance lifecycle capability for sanitized launch, published readiness, and captured-PID stop. Only a matching `{pid, startedAt}` from a spawn in the current Desktop session authorizes automatic stop or binary replacement. Preexisting instances, including legacy `desktopManaged` records, are attach-only. Keep-running survivors attach in the next session. Explicit attached Stop requires a confirmation naming the captured home/PID. Ordinary Restart uses the worker RPC on every app platform.
2. **Managed desktop**: Electron app spawns daemon as subprocess, and stops it again on quit so that "restart the app" is a complete reset. Settings > Host > "Keep daemon running after quit" opts out. Only a daemon the desktop started is stopped — a daemon you started yourself with `paseo daemon start` is left alone (`paseo.pid` records `desktopManaged`).
3. **Remote + relay**: Daemon behind firewall, relay bridges with E2E encryption
+8 -16
View File
@@ -12,7 +12,8 @@ It validates the compositor behavior that unit tests cannot see:
by retrying until the frame appears;
- both viewport `capturePage` and full-page CDP screenshots return real pixels from
the permanent production parking state;
- parked guests remain capturable with Chromium background throttling enabled;
- guest background throttling can be disabled once at attach without per-capture
renderer coordination;
- the real-Electron host-composer sentinel proves guest Enter cannot submit a focused
host composer;
- the automation group loads the compiled production keyboard boundary and guest
@@ -72,17 +73,11 @@ The harness writes PNG evidence and `results.json` to:
packages/desktop/capture-harness/out/
```
A passing run prints `PASS` lines for the production P1 default-throttling parking state,
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.
The existing `npm run test:e2e:browser-tabs --workspace=@getpaseo/desktop` journey
verifies that a hidden window stops guest animation, captures fresh viewport pixels,
and resumes animation after restoring the window. Its artifacts include the screenshot
and animation measurements. Full-page content correctness remains separately tracked in
[the full-page repetition bug](https://github.com/getpaseo/paseo/issues/3196).
## Mechanism
Electron captures copy from the guest web contents' compositor surface. A resident
@@ -101,11 +96,8 @@ plane stays below the overlay plane regardless of body insertion order; menus ke
layering inside `overlay-root`. Activating a presented browser also focuses its registered guest
`WebContents` in main so macOS assigns keyboard first-responder ownership to the page.
There is no renderer prep/restore handshake or lifetime background-throttling override.
Screenshot capture temporarily enables frame production inside the shared serialized queue,
restores the previous throttling policy on success or failure, and retains a 5-second capture budget.
The browser tool takes one viewport frame through Electron's frame subscription and releases the
subscription on completion or cancellation. A resized resident guest can produce fresh pixels
while `capturePage()` leaves its surface-copy request pending; waiting for animation frames or
retrying the copy does not repair that state. Full-page screenshots retain the CDP path with layout
metrics and screenshot clip.
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.
+1 -1
View File
@@ -92,7 +92,7 @@ For testing rules, see [testing.md](testing.md).
- Subscribe narrowly: select primitives from stores, pass `status` not `agent`, use `useShallow` / deep-equal when returning derived arrays/objects.
- Collection rows do not independently subscribe to a high-frequency global store. The collection owner selects structurally shared indexes once, derives a keyed row model with `useMemo`, and passes entries to rows. This keeps retained hidden collections current without running one selector per row on every store update.
- Equality functions prevent React renders; they do not prevent selector callbacks from running. A selector attached to a hot store must be O(1) when its relevant source references have not changed.
- Retained native panels use `RetainedPanel`. If an existing gesture/layout wrapper must own visibility, wrap its contents in `RetainedPanelActivity` instead. Keep keyed panel roots in a stable sibling order, include the newly active panel in the same render, centralize subscriptions, and gate genuine effects through `useRetainedPanelActive`. Follow the [native ownership constraints and chat suspension boundary](mobile-panels.md) when stopping hidden rendering.
- Retained native panels use `RetainedPanel`. If an existing gesture/layout wrapper must own visibility, wrap its contents in `RetainedPanelActivity` instead. Keep keyed panel roots in a stable sibling order, include the newly active panel in the same render, centralize subscriptions, and gate genuine effects through `useRetainedPanelActive`. Do not use `Suspense` or render freezing for this on native: those techniques change native tree ownership instead of merely stopping work.
- Infinite animations are subscriptions. Start them only while their retained panel is active, and cancel a shared clock when its final active consumer leaves. Synchronized animations use one clock per animation family and feed active instances through local shared values; retained hidden instances stay mounted but unsubscribed. Match state updates to the actual visual cadence; do not run every style worklet at 60 fps when the rendered value changes only a few times per second.
- Stable references for props that cross `memo` boundaries or feed dependency arrays. Static literals at module scope `as const`; derived with `useMemo`; handlers with `useCallback` only when there's a memoized beneficiary.
- Use stable ids for `key`, never array index for reorderable/filterable lists.
+1 -1
View File
@@ -714,7 +714,7 @@ Each entry in the `models` array:
### Claude settings.json model discovery
The built-in `claude` provider appends concrete model IDs from `~/.claude/settings.json` to its first-party Claude model list. Paseo reads the top-level `model` field and these `env` keys: `ANTHROPIC_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, `ANTHROPIC_DEFAULT_FABLE_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, and `ANTHROPIC_DEFAULT_HAIKU_MODEL`.
The built-in `claude` provider appends concrete model IDs from `~/.claude/settings.json` to its first-party Claude model list. Paseo reads the top-level `model` field and these `env` keys: `ANTHROPIC_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, and `ANTHROPIC_DEFAULT_HAIKU_MODEL`.
This lets users who already configured Claude Code for Bedrock, OpenRouter, ollama, Z.AI, or another Anthropic-compatible gateway select the exact model ID in Paseo. Explicit model IDs are passed unchanged to Claude Code, even when the same string is a compatibility alias for a built-in model. When `agents.providers.claude.models` is set it **replaces** both the hardcoded first-party Claude list and any settings.json-discovered entries; use `agents.providers.claude.additionalModels` to keep the first-party list and append curated entries on top.
+8 -12
View File
@@ -48,7 +48,6 @@ $PASEO_HOME/
├── server-id # Stable daemon identifier (plain text, "srv_<base64url>")
├── daemon-keypair.json # E2EE keypair for relay (mode 0600)
├── paseo.pid # Daemon PID lock file
├── local-credential # Per-run local client credential (mode 0600)
├── daemon.log # Default log file (path configurable)
├── agents/
│ └── {sanitized-cwd}/
@@ -65,8 +64,8 @@ $PASEO_HOME/
│ └── managed-processes/
│ └── {recordId}.json # Helper processes owned by Paseo; reconciled on daemon bootstrap
├── plugins/
│ ├── sources.json # Managed kind and Git acquisition remote
│ └── {pluginId}/{uuid}/ # Git checkout or npm package/lockfile/dependency tree
│ ├── sources.json # Git origin, ref, commit, and managed checkout ownership
│ └── {pluginId}/{version}/checkout/ # Source checkout for one installed Git commit
└── push-tokens.json # Expo push notification tokens
```
@@ -255,13 +254,11 @@ snapshot so a mixed edit can apply its live subset and still name the paths that
All fields are optional with sensible defaults.
Managed plugins appear as directory sources in `config.json`; it owns the active path and enabled
state. `plugins/sources.json` is written atomically and stores only managed kind and the Git acquisition
remote. Installed revision, package/subdirectory identity and ownership root come from retained
artifacts and the fixed managed layout; [managed source ownership](plugins.md#managed-source-ownership)
explains their authority. An update prepares a new directory before replacing the active path and
removing the previous version. Old record fields are accepted at the store boundary and ignored;
there is no startup migration or persistent update policy.
Git-managed plugins still appear as directory sources in `config.json`. This keeps the plugin
runtime and protocol config compatible with directory-only clients. `plugins/sources.json` owns the
Git-specific origin, tracking ref, installed commit, repository subdirectory, and checkout root.
Paseo writes it atomically. An update creates and validates a new version directory before changing
the configured directory path; successful activation removes the old version.
### Profile lists
@@ -491,7 +488,7 @@ Array of workspace records. A workspace is a specific working directory within a
| `title` | `string \| null` | User-set name override layered over `displayName`. Null means "use `displayName`". |
| `branch` | `string \| null` | The current Git branch for git-backed workspaces. Separate from `displayName`/`title`; a background branch refresh never rewrites the name. |
| `worktreeRoot` | `string \| null` | Backing checkout/worktree root. May differ from `cwd` for exact subprojects and remains persisted after the worktree is deleted so restore can reproduce the placement. |
| `baseBranch` | `string \| null` | Comparison base retained across archive and restore. Branch-off creation stores the resolved ref; legacy and PR-checkout records hold a bare name. Null means no recorded base. |
| `baseBranch` | `string \| null` | Normalized branch the Paseo worktree was created from; null for directories, local checkouts, and checkout-branch worktrees |
| `isPaseoOwnedWorktree` | `boolean` | Whether Paseo owns and may remove/recreate the backing `worktreeRoot` |
| `mainRepoRoot` | `string \| null` | Main repository root for worktree checkouts, independent of both exact `cwd` and backing `worktreeRoot` |
| `createdAt` | `string` (ISO 8601) | |
@@ -558,7 +555,6 @@ These small files are not validated as full Zod schemas but are persisted under
| `server-id` | Plain text, e.g. `srv_<base64url>` | Stable per-`$PASEO_HOME` daemon ID. Overridable via `PASEO_SERVER_ID` env. |
| `daemon-keypair.json` | `{ v: 2, publicKeyB64, secretKeyB64 }` (libsodium box keypair) | E2EE relay identity. Written with mode `0600`. Regenerated if file is unreadable. |
| `paseo.pid` | JSON `{ pid, startedAt, ... }` | PID lock; prevents two daemons sharing one `$PASEO_HOME`. |
| `local-credential` | 32 random bytes encoded as base64url text | Rotated before each listen and deleted on shutdown; mode `0600`. |
| `daemon.log` | Pino log output | Default location; path/rotation configurable via `log.file` in `config.json`. |
---
+2 -2
View File
@@ -20,7 +20,7 @@ A semantic element used in three or more places is a primitive. One of a kind is
Primitives live in `packages/app/src/components/ui/` and `packages/app/src/components/headers/`. Card and row layout live in `packages/app/src/styles/settings.ts`. Section structure lives in `packages/app/src/screens/settings/settings-section.tsx`.
A pressable styled to look like a button is wrong; the button is `<Button>` (`packages/app/src/components/ui/button.tsx`). A bare `<Text>` styled to look like a section header is wrong; the section header is `<SettingsSection>` (`packages/app/src/screens/settings/settings-section.tsx`). A custom `Modal` for a confirmation is wrong; the confirmation is `confirmDialog` (`packages/app/src/utils/confirm-dialog.ts`). A hand-rolled overflow menu is wrong; the menu is `<DropdownMenu>` (`packages/app/src/components/ui/dropdown-menu.tsx`). A hand-rolled status pill is wrong; the pill is `<StatusBadge>` (`packages/app/src/components/ui/status-badge.tsx`). A circle pinned over a scrolling surface is wrong; the floating action is `<FloatingActionButton>` (`packages/app/src/components/ui/floating-action-button.tsx`), one per surface, and the surface reserves `FLOATING_ACTION_BUTTON_CLEARANCE` below its content so the button never traps the last row.
A pressable styled to look like a button is wrong; the button is `<Button>` (`packages/app/src/components/ui/button.tsx`). A bare `<Text>` styled to look like a section header is wrong; the section header is `<SettingsSection>` (`packages/app/src/screens/settings/settings-section.tsx`). A custom `Modal` for a confirmation is wrong; the confirmation is `confirmDialog` (`packages/app/src/utils/confirm-dialog.ts`). A hand-rolled overflow menu is wrong; the menu is `<DropdownMenu>` (`packages/app/src/components/ui/dropdown-menu.tsx`). A hand-rolled status pill is wrong; the pill is `<StatusBadge>` (`packages/app/src/components/ui/status-badge.tsx`).
Before adding a new component, read `components/ui/`. The primitive usually exists.
@@ -193,7 +193,7 @@ Terminology:
Loading is inline by default. `<LoadingSpinner size={14} color={foregroundMuted} />` sits next to the thing it relates to (`packages/app/src/screens/settings/providers-section.tsx:227-231`). Page-level loading is a centered `<LoadingSpinner size="large">` (`packages/app/src/screens/sessions-screen.tsx:69-72`). Card-level loading is a single short line, not a spinner. In-row dropdown items use `<DropdownMenuItem status="pending" pendingLabel="Removing...">`; the menu item handles its own pending state.
Screen-level empty states use a centered, muted noun phrase and at most one ghost button (`packages/app/src/screens/sessions-screen.tsx`). Sidebar list empty states use a quiet bordered card with a left-aligned `sm` title, a muted `sm` description, and `xs` `<Button>` actions. Use `secondary` for the primary action and `outline` for a secondary action (`packages/app/src/components/sidebar/empty-states.tsx`). Keep both forms short and free of illustrations.
Empty states are short noun phrases. Centered, muted, one or two lines. Sessions screen pairs the empty noun with a single ghost button to navigate back (`packages/app/src/screens/sessions-screen.tsx:74-81`); that pairing is the maximum elaboration. Illustrations and CTAs disguised as empty states are wrong.
Inline errors are a single sentence in `palette.red[300]` `xs`, sitting under the field or inside the card it relates to (`packages/app/src/screens/settings/providers-section.tsx:115-119`).
+5 -5
View File
@@ -50,6 +50,7 @@ than downloading a published desktop release.
- **Repo dev scripts** default to `$ROOT/.dev/paseo-home`, where `$ROOT` is the current checkout or worktree root. This keeps all dev state scoped to the checkout instead of the packaged desktop app.
- **`npm run cli -- ...`** runs through the same dev-home wrapper as the dev scripts, so the in-repo CLI automatically targets the current checkout's `.dev/paseo-home` and configured dev daemon endpoint.
- **Paseo-created worktrees** seed `$PASEO_WORKTREE_PATH/.dev/paseo-home` from `$PASEO_SOURCE_CHECKOUT_PATH/.dev/paseo-home` by copying durable JSON metadata. Runtime files like pid files, sockets, and logs are not copied.
- **Paseo-created worktrees** read `.worktreeinclude` from the live source checkout before creation. Bare paths and `copy <path>` copy a snapshot into the new worktree; `symlink <path>` creates a live source link. Missing paths, malformed entries, unsafe paths, incompatible include overlaps, destination conflicts, unavailable platform links, and ordinary read/write failures are skipped individually and reported in the daemon log, so the rest of the plan still runs. A source symlink is allowed only when its resolved target remains inside the active source checkout. If that checkout is itself Paseo-managed, its own paths remain eligible while other managed worktree paths stay protected; `copy` snapshots that resolved target, while `symlink` links directly to it. Hard links are ordinary files. Each include is staged before it is committed; Paseo aborts creation only if it cannot safely clean up partial materialization state (or Git/worktree setup itself fails). Materialization finishes before `worktree.setup` runs.
- **This repo's worktree setup** also best-effort seeds `packages/app/ios` and the newest `.dev/ios-build` entry from the source checkout so iOS simulator services can reuse native project and Xcode cache state when it is safe enough to do so.
Override knobs:
@@ -444,17 +445,16 @@ so the port may already be bound. `portScript` takes precedence when both values
The daemon can optionally serve the browser web client from the same HTTP server. This is disabled by default.
Enable it in persistent configuration:
Enable it for a running daemon with:
```bash
paseo daemon config set features.webUi.enabled true
paseo daemon start
paseo daemon start --web-ui
```
Or set the environment variable:
```bash
PASEO_WEB_UI_ENABLED=true paseo daemon run
PASEO_WEB_UI_ENABLED=true paseo daemon start
```
Or persist it in `config.json`:
@@ -565,7 +565,7 @@ npm run cli -- --host ssh://user@host ls -a
```
Set `PASEO_HOST` to use the same target across invocations. An explicit
selector overrides both environment selectors. With both `PASEO_HOME` and `PASEO_HOST` set, pass an explicit selector. See [CLI target selection](../public-docs/cli.md#select-one-daemon).
`--host` overrides the environment variable.
In an SSH URI, the URL port is the SSH server port. The remote daemon defaults to `127.0.0.1:6767`; use `?daemonPort=7777` to override it. The transport runs non-interactively through the local OpenSSH client and never installs, starts, or configures the remote daemon. User-facing setup and troubleshooting live in [public-docs/connectivity.md](../public-docs/connectivity.md#ssh).
-2
View File
@@ -22,8 +22,6 @@ the web UI. The served app receives a same-origin connection hint and connects
back to that daemon. Static UI files load without daemon auth; API and
WebSocket requests still require `PASEO_PASSWORD` when one is configured.
Host-side CLI commands select the container explicitly, for example `paseo project ls --host 127.0.0.1:6767`. Without an endpoint selector the CLI looks for a local home’s supervisor. Container environment settings are deployment overrides; worker restart preserves them. Your container manager owns full supervisor replacement.
## Quick Start
```bash
+4 -10
View File
@@ -30,10 +30,7 @@ never read or modify that width.
`packages/app/src/workspace-tabs/open-supporting-view.ts` owns semantic Changes and pull-request
opens. Compact and wide native layouts select the matching Explorer tab. Desktop Changes opens
follow the shared diff preference. Desktop pull requests use their Main panel, On the side, or
Explorer sidebar setting. Automatic PR discovery follows that preference once per workspace without
interrupting the user's work. Closing the tab opts that workspace out of future automatic opens,
even for a different PR; moving or reordering it remains the user's choice.
Callers request the content and never choose the shell.
Explorer sidebar setting. Callers request the content and never choose the shell.
The composer Changes pill is a two-stage desktop action: it first reveals Explorer on Changes, then
routes later presses to the working diff through the shared diff preference.
@@ -48,7 +45,7 @@ can be reordered, but the dock cannot be split. Selecting an Explorer tab does n
focus.
Cmd+E shows or hides Explorer without changing its selected view. Compact layouts use the combined
full-screen Explorer overlay for Changes, Files, and pull requests, and close it after a file opens. Compact Changes has no tree rail; its overview is the Jump to file action (`packages/app/src/git/jump-to-file/`), a sheet over the same changed-files tree the desktop rail renders.
full-screen Explorer overlay for Changes, Files, and pull requests, and close it after a file opens.
Wide native layouts without pane splits use the same combined content in a resizable inline dock;
opening a file leaves that dock visible. Both presentations keep their selection in the panel store
and reuse the layout store's per-workspace Explorer width. They do not create a second Explorer
@@ -60,11 +57,8 @@ lifecycle.
layout store remembers one ordinary pane per workspace. The first side open creates a full-height
right split around the workspace root; later side opens reuse it.
Removing a pane clears its remembered id; a later side open creates a new pane. The last visible
ordinary pane stays when its final tab closes and shows the New launcher. An empty workspace does
not automatically create an agent draft tab; choosing Agent opens one. Explorer cannot replace the
workspace canvas, even when visible. Restoring a saved layout enforces the same rule while preserving Explorer and saved
tab content. There is no hidden side-pane lifecycle.
Closing the pane or moving away its final tab removes it normally and clears the remembered id. A
later side open creates a new pane. There is no hidden side-pane lifecycle.
Placement intent still controls existing tabs:
-9
View File
@@ -47,15 +47,6 @@ state.
This split is deliberate. The host layout must mount first so native local
dynamic params exist before any nested workspace leaf is selected.
## Error Recovery
The root error boundary lives above `ExpoRoot` in `src/root-app.tsx`. Reload
remounts the router at `/open-project`, preserving saved workspace layouts and
the remembered selection. Do not move the boundary inside the root layout:
catching a render error there unmounts the navigator, so the recovery button
cannot reliably dispatch a navigation reset. Starting recovery at `/` would
restore the same workspace and could repeat the crash.
## App-Wide Route Hops
When app-wide routes such as `/new`, `/settings`, or `/sessions` navigate back
+3 -3
View File
@@ -10,12 +10,12 @@ Closing an observation must remain safe after its root is renamed or removed. Wo
Linux topology repair is trailing-debounced and scoped to the renamed path. A file rename needs one classification stat; a new or moved directory scans only its subtree. Full-tree scans are reserved for startup and ignore-set replacement. The initial Linux scan conservatively emits existing files discovered before their directory watcher was installed; WorkspaceGit batches these into one refresh. Benchmark this startup cost with pre-populated trees.
Windows and macOS use one native recursive watcher plus an ignored-pruned, directory-indexed inventory. Native events deliver immediately. Named events trigger serialized shallow scans of their parent directories; known directory events also reconcile that subtree. This recovers concrete create and delete paths omitted by coalescing without walking the whole root for each edit. Classification is admission-controlled to 32 concurrent `fs.stat` calls, since `fs.stat` runs on the libuv threadpool the whole daemon shares. Startup and exclusion changes use an awaited full inventory barrier. Activity arms a full safety audit 30 seconds after it settles, with a 30-second minimum interval and a five-minute starvation bound; pathless native events also trigger an immediate shallow root scan. Observation fails into polling rather than retaining more than 250,000 file and directory entries per root. A repository that large is also expensive to poll, so degraded polling doubles its gap from 5 seconds to a 60-second ceiling while the workspace snapshot comes back unchanged, and resets to 5 seconds on the first change. Explicit reads still force a refresh; the ceiling only bounds how long an edit made outside Paseo stays undiscovered.
Windows and macOS use one native recursive watcher plus an ignored-pruned, directory-indexed inventory. Native events deliver immediately. Named events trigger serialized shallow scans of their parent directories; known directory events also reconcile that subtree. This recovers concrete create and delete paths omitted by coalescing without walking the whole root for each edit. Startup and exclusion changes use an awaited full inventory barrier. Activity arms a full safety audit 30 seconds after it settles, with a 30-second minimum interval and a five-minute starvation bound; pathless native events also trigger an immediate shallow root scan. Observation fails into polling rather than retaining more than 250,000 file and directory entries per root.
Read aggregate health from the owning observer service. Runtime metrics include active observations, native handles, tracked native files, pending events and immediate reconciliation work, scoped and full reconciliation activity and latency, and failure counts. `nativeTrackedFileCount` and other per-file counters are native-backend diagnostics only — the Linux backend keeps no per-file inventory and reports zero — so a cross-platform assertion belongs on delivered events, not those counters. Delayed safety audits do not count as pending work. Closing the service releases every subscription and clears its diagnostics. Do not add path lists, watcher handles, or platform-specific controls to this interface.
Read aggregate health from the owning observer service. Runtime metrics include active observations, native handles, tracked native files, pending events and immediate reconciliation work, scoped and full reconciliation activity and latency, and failure counts. Delayed safety audits do not count as pending work. Closing the service releases every subscription and clears its diagnostics. Do not add path lists, watcher handles, or platform-specific controls to this interface.
Git owns Git-ignore evaluation. The observer accepts absolute excluded roots and applies updates without replacing the observation or exposing its watcher topology. This keeps tracked files inside otherwise ignored directories observable and keeps Git policy out of the filesystem module.
Workspace Git verifies each repository metadata subscription with a one-shot canary inside the Git directory. If the event does not round-trip through the subscription callback, treat the watcher as unavailable and enter degraded polling. Refresh working-tree Git-ignore exclusions from ignore-file events, newly observed directories, and watcher recovery, never from a healthy-watcher timer. `git ls-files` reports an ignored directory only once it exists, so new directory topology is a trigger in its own right; the refresh is edge-triggered per directory and debounced so a dependency install collapses into a couple of Git runs.
Workspace Git verifies each repository metadata subscription with a one-shot canary inside the Git directory. If the event does not round-trip through the subscription callback, treat the watcher as unavailable and enter degraded polling. Refresh working-tree Git-ignore exclusions from ignore-file events and watcher recovery, never from a healthy-watcher timer.
The real-filesystem contracts and daemon auto-archive lifecycle run in the normal server test suite. Use the scripts only for manual performance and soak work: `npm run measure:file-observer --workspace=@getpaseo/server` measures burst and sustained-create behavior, and `npm run repro:file-observer-teardown --workspace=@getpaseo/server` runs the teardown soak.
+5 -59
View File
@@ -58,10 +58,7 @@ Android touches sailed straight through to the chat scroll view behind it.
Two escape hatches in the codebase:
- **`Modal`** (combobox, dropdown menu and tooltip on native) — opens a new Android window, so
hit-testing starts fresh in that window. If its children use gesture-handler,
the Modal owner must wrap its content in a full-height `GestureHandlerRootView`:
the app's root does not span Android windows. React context alone does not
establish a native gesture root. Side effect: a Modal opening on
hit-testing starts fresh in that window. Side effect: a Modal opening on
Android can detach the IME from an underlying TextInput. Fine for combobox
(it has its own input) and tooltip (no input). **Not** fine for autocomplete
(the composer's input must stay focused so the user keeps typing).
@@ -137,38 +134,13 @@ The fix for transforms is Gotcha 3. The fix for context is Gotcha 7.
## Gotcha 3 — Keyboard layout and portal anchors
`composer/dock` owns the stationary viewport, bottom-anchored surface, composer
capacity, and keyboard motion for chat, workspace draft tabs, and New workspace.
Render `<ComposerDock>{contentAbove}{composer}</ComposerDock>` below the header.
The optional third child is an overlay that moves with the surface. New workspace
uses `centered` for its existing desktop/tablet form. Hosts never reserve keyboard
space or translate the composer themselves. Put the dismiss surface behind content
as a sibling, with `pointerEvents="box-none"` on its content wrapper. A Pressable
ancestor takes the JS responder on idle Android Fabric and intercepts the stream's
MOVE events before its native scroll view reaches touch slop. Streaming can hide
this failure by delaying the responder grant. Native scroll views retain their own
tap-to-dismiss behavior; unclaimed empty-space taps reach the dismiss sibling.
On iOS, decorative form containers also need `box-none` (and inert titles/spacers
need `none`); otherwise their empty padding blocks hit testing of that sibling.
The shared screen header uses the same dismiss surface. Keep the
header escape available when a capped draft clips all content above it, since
iPhone has no system keyboard-dismiss button. Child controls keep their touches,
and the stream keeps its existing flick-to-dismiss gesture.
The dock's private viewport and capacity model are extracted from chat. The
app-wide `@/keyboard/shift` module owns shared motion for the dock, portals, streams,
terminal, explorer, and dialogs. Import only its public entry point: the provider,
shift hooks, shift style, translate view, settled stream inset, and explorer padding
policy. Its contexts and reconciliation stay private. Keep `KeyboardShiftProvider`
at its root mount point outside the portal host when changing dock hosts.
`KeyboardTranslateView` owns visual keyboard motion inside `@/keyboard/shift`. Android uses the
`KeyboardTranslateView` owns visual keyboard motion. Android uses the
controller's Reanimated signal. iOS uses its native-driver `Animated.event`
signal because the stock iOS Reanimated value changes at move start, while
writing a replacement Reanimated value every frame interrupts UIKit's hide
animation. Keep this platform choice inside the keyboard module's private platform-specific translate files.
animation. Keep this platform choice inside `KeyboardTranslateView`.
`ComposerDock` always keeps the surface at full height and wraps it in
`KeyboardDock` always keeps the chat surface at full height and wraps it in
`KeyboardTranslateView`. The panel root clips it at the header edge, so rows
slide under the header on open and out from under it on close. Do not swap the
dock to keyboard padding at rest. Changing its height creates either a blank
@@ -182,33 +154,7 @@ layout. Preserve the translated dock's history scroll range with a far-end
content inset on the inverted stream list. Update that inset only when keyboard
motion settles; never drive it per frame.
The translated dock's height is not the composer's available height. Bound the
composer against the stationary space below the header inside
`composer/dock`, including its controls and attachments. Reserve the
keyboard destination at move start on the UI thread. Waiting for a settled JS
update lets a long draft disappear behind the header during opening. This is a
boundary-time layout change, not an animated layout prop. Keep the input's
native intrinsic sizing and internal scrolling. Keep the editing input's
`defaultValue` current on every edit: Fabric re-measures the Android input from
the JS text whenever its frame changed, so a stale prop inflates or collapses
the input (see `packages/app/src/components/ui/text-input/text-input.native.tsx`).
Closing the keyboard moves the composer; it does not enlarge its capacity. The
viewport keeps the last keyboard reservation until the next opening replaces
it, so a capped draft keeps one height through every keyboard transition
instead of expanding on close and shrinking again on open.
Keep setup fields out of the composer's shrink chain. New workspace supplies its
setup fields as the first dock child so they move up under the header as the draft
grows. Putting the fields and composer in one bounded, shrinkable column made
Yoga split the shortage between them: rows vanished behind the composer while
the input lost lines at the same time. Keep that separation inside the dock,
shared by every phone host. The centered tablet form caps the complete block
for header clearance, but reserves the composer's own capped height before
shrinking the setup scroll view. The form must not take editing space from the
composer.
Move the stream and composer together through `ComposerDock`. Do not translate
Move the stream and composer together through `KeyboardDock`. Do not translate
them independently.
`KeyboardShiftProvider` owns the normalized shift used for settled insets and
+1 -5
View File
@@ -19,7 +19,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **MR** — GitLab merge request. UI label for GitLab change requests only; do not use MR for GitHub/Gitea/Forgejo.
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. User-facing creation treats it as the `worktree` workspace isolation choice. Code and `paseo.json` retain worktree terminology for git lifecycle implementation. Forbidden: "Checkout" as a product synonym.
- **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. No UI label.
- **Sidebar items** — The configurable entries of the app sidebar, in two sections. Header: the rows at the head of the sidebar — New workspace, History, Search, Schedules, and plugin header items. Footer: the full-width rows between Add project and the footer icon row — Usage and plugin footer items. Add project and the icon row (Hosts, Import session, Help and support, Settings) are fixed and are not sidebar items. A plugin item may render several rows, such as one per bot; it is still one item that moves and hides as a block. Each section's order and visibility is one per-device preference (`AppSettings.sidebarNavItems`, `AppSettings.sidebarFooterItems`); every item may be off. UI: the Header and Footer cards on the Sidebar settings page. Code: `packages/app/src/sidebar-nav/model.ts` resolves a section, and the sidebar (`components/sidebar/sidebar-nav-rows.tsx`, the footer in `components/left-sidebar.tsx`) and the settings page (`screens/settings/sidebar/sidebar-nav-section.tsx`) read it through `useSidebarNavItems(section)`. Never hardcode one of these entries or add a second place that decides which ones show. Don't confuse with **Explorer sidebar**.
- **Sidebar items** — The top-level rows at the head of the app sidebar: New workspace, History, Search, Schedules, and every plugin sidebar contribution. Order and visibility are one per-device preference (`AppSettings.sidebarNavItems`); all of them may be off. UI: the "Sidebar" section on the Appearance settings page. Code: `packages/app/src/sidebar-nav/model.ts` resolves the list, and both the sidebar (`components/sidebar/sidebar-nav-rows.tsx`) and the settings section (`screens/settings/appearance/sidebar-nav-section.tsx`) read it through `useSidebarNavItems`. Never hardcode one of these rows or add a second place that decides which ones show. Don't confuse with **Explorer sidebar**.
- **Explorer sidebar** — The dedicated navigation dock containing the Files and Changes trees. UI: "Explorer sidebar". Code: `packages/app/src/workspace-tabs/explorer-sidebar.ts`; persisted pane identifiers retain the `explorer` spelling. See [explorer-sidebar.md](explorer-sidebar.md).
- **Side pane** — An ordinary right-side workspace pane used for content opened beside the user's work. It closes and participates in workspace splits like every other main-host pane. See [explorer-sidebar.md](explorer-sidebar.md#side-pane). Forbidden: "Side panel".
- **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, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
@@ -31,10 +31,6 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Agent profile** — A named launch bundle: provider, model, mode, thinking option, feature values, and free-text notes. Applying one writes those values into the **Agent controls** and is then forgotten — there is no selected profile, no default profile, and no drift tracking. The notes are written for orchestrating agents and are what the `list_profiles` MCP tool surfaces. Stored host-wide in daemon config as `agentProfiles`. UI: "Agent profiles". Code: `AgentProfile` (`packages/protocol/src/messages.ts:161`). Carries no system prompt, because a per-agent system prompt can only be set at creation. Forbidden: "Favourite", which this replaced.
- **Terminal profile** — A named shell command a terminal can launch. Stored host-wide in daemon config as `terminalProfiles`. UI: "Terminal profiles". Code: `TerminalProfile` (`packages/protocol/src/messages.ts:135`), `resolveTerminalProfiles` (`packages/protocol/src/terminal-profiles.ts`). Also selectable as a New Workspace launch target, where the alternative is the chat agent.
- **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi, Oh My Pi). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`).
- **Usage source** — Plugin registration that discovers accounts and fetches plan usage. Code: `UsageSourceRegistration` (`packages/plugin/src/server/usage.ts`).
- **Report ID** — Stable daemon-composed identity for one source account: `<sourceId>:<accountKey>`. The source owns the account key; clients treat the whole ID as opaque. Code: `UsageReportEntry.id` (`packages/protocol/src/messages.ts`).
- **Usage report** — A source's normalized usage for one account, including status and optional windows, balances, and details. Its entry carries the Report ID, account label, and fetch time. Code: `UsageReport` (`packages/plugin/src/server/usage.ts`).
- **Usage (sidebar item)** — The sidebar footer item showing each pinned window's source icon and percent, or a plain "Usage" row while no pinned window has data; pressing it opens the Usage screen (a bottom sheet on phones). A pin is `{ sourceId, windowId }` and shows once per account of that source. Pins and the used/remaining choice are one per-device preference (`AppSettings.usage`) that every usage surface follows. It reads the active workspace's host, else the first connected host with usage sources. Code: `packages/app/src/usage/sidebar-item.tsx`, `usage/pinned.ts`, `usage/preferences.ts`.
- **Model** — A specific LLM offered by a provider. 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`).
+2 -9
View File
@@ -28,15 +28,12 @@ manage their own relationship or permissions.
## Session grants and agent operations
Hub uses the same authenticated, resumable Session protocol as other clients. Its persisted
`hub.execute` permission authorizes ordinary agent creation, workspace titling, messaging,
cancellation, archival, agent/workspace observation, timeline subscriptions, and workspace recovery. This authority is
`hub.execute` permission authorizes ordinary agent creation, messaging, cancellation, archival,
agent/workspace observation, timeline subscriptions, and workspace recovery. This authority is
daemon-wide; it is not limited to agents created by that Hub. Daemon configuration, terminals,
browser control, and permission management still require their own permissions. See
[permissions.md](permissions.md).
Creating an agent may create a directory workspace. The same `hub.execute` session can title that
workspace through `workspace.title.set.request` without `workspace.manage`.
Clients using this contract check `server_info.features.hubAgentRpc` and
`server_info.features.agentRequestReceipts` once. An older host must be upgraded; do not silently
fall back to creating a fresh agent when continuation was requested.
@@ -52,10 +49,6 @@ Provider controls remain provider-native; see [providers.md](providers.md).
`send_agent_message_request` with a stable `messageId`. Request IDs correlate individual attempts;
creation keys and message IDs identify the operation across attempts. A creation key is daemon-wide;
a message ID is scoped to its agent. Reusing either with different arguments is a conflict.
`workspace.create.request.idempotencyKey` provides the same creation guarantee for directory and
worktree workspaces; check `server_info.features.workspaceRequestReceipts` before using it.
Workspace creation keys and agent creation keys have separate namespaces. The app keeps these keys
for the lifetime of a draft and reuses the first message ID after failures.
The daemon journals the assigned agent ID before creation. Concurrent retries share one operation,
and a retry after a lost acknowledgement or restart returns the durable agent without creating a
-8
View File
@@ -179,10 +179,6 @@ its own.
- **Released height.** Reanimated's web entering animation leaves an inline height snapshot on
the surface. `AnchoredSurface` clears it, and a `revision` prop re-clears it when content
identity changes — a pushed page taller than the one it replaced is clipped without that.
- **Animate only once placed.** The same snapshot carries top/left, and Reanimated writes it back
750ms after mount. `AnchoredSurface` remounts the surface when its position resolves so the
entering animation never ends at the off-screen measuring position; on a slow machine that
write moved the open menu back off-screen.
- **Sheets size to content.** `enableDynamicSizing`, not fixed snap points. A pushed page is
rarely the height of the page before it.
- **The sheet's content is teleported out of the menu's subtree**, so `MenuSheetSurface` rebuilds
@@ -191,10 +187,6 @@ its own.
[floating-panels.md](floating-panels.md).
- **One overlay per menu.** Submenus render inside their parent's layer and paint no second
backdrop, so there is exactly one `Modal` on native no matter how deep the menu goes.
- **Retained panels own visibility.** The shared menu surface unmounts when its panel becomes
inactive. An async action can navigate before its menu closes; a hidden panel must not leave a
portal backdrop blocking the destination. On web the chat suspends one commit after it goes
inactive, and that inactive commit is where the surface unmounts.
- Anchoring, flipping, and edge clamping live in `menu-anchor.ts` and are unit-tested. Fix
positioning bugs there, not at a call site.
- Everything else about floating surfaces on Android — Portal/Modal escape, lifecycle gates,
-2
View File
@@ -116,8 +116,6 @@ definition, no longer eligible to begin.
- Do not suspend retained native subtrees with `Suspense`/`react-freeze`. Suspension changes native
ownership and can detach descendants. Keep the tree mounted, stabilize its subscriptions/selectors,
and use the retained-panel active signal to stop timers, polling, and other genuine background work.
Hidden chat content is suspended on web only, through `retained-chat-content.web.tsx`, where DOM
nodes carry no Fabric ownership; the native file keeps the chat live.
## Tests
-27
View File
@@ -47,33 +47,6 @@ focused input identity and IME hide/show events. It saves screenshots and logs u
`.dev/agent-device-artifacts/terminal-keyboard-android`. Set `PASEO_TERMINAL_KEYBOARD_APP_ID=sh.paseo`
to test an installed production build. It never submits a chat message.
`npm run test:e2e:composer-keyboard:android` preserves the chat control and
keyboard regression flow, then runs the same growth, retained-height, bounds, and
hold-to-delete and background-tap dismissal scenario on chat, a workspace draft
tab, and New workspace. It checks empty-content taps and header taps at the cap
without changing the input height. Each
host also opens command/file autocomplete, the model selector, the attachment
menu, and the forge picker from both keyboard states. It then submits a long
fixture from each host and checks native stream displacement twice: after the
response settles, and after returning to the bottom and letting the scrollbar
fade. The image comparison excludes the scrollbar; keyboard dismissal alone
does not prove that native scrolling receives touches.
Artifacts are grouped by host under
`.dev/agent-device-artifacts/composer-keyboard-android`. Configure its daemon,
Metro, and device through the `PASEO_COMPOSER_KEYBOARD_*` variables in
`packages/app/e2e/mobile/composer-keyboard/android.sh`. Use a software keyboard;
the headless input helper cannot verify the
[visible composer constraints](floating-panels.md#gotcha-3--keyboard-layout-and-portal-anchors).
On a Play-image emulator, disable Gmail and Calendar first. Their welcome
screens launch on their own, take input focus, and the run fails with
`IME did not become visible` while the app still looks focused:
```bash
adb shell pm disable-user --user 0 com.google.android.gm
adb shell pm disable-user --user 0 com.google.android.calendar
```
When replay diverges, read its ranked selector suggestions. Edit the script deliberately and rerun it from the beginning. `--update` is retained for compatibility but no longer rewrites scripts.
## Maestro compatibility
+1 -1
View File
@@ -27,7 +27,7 @@ A pairing invitation is neither. It is an expiring, single-use exchange that cre
| `workspace.write` | Prompts, agent control, files, terminals, git operations, and scripts |
| `workspace.manage` | Create, rename, archive, and remove projects and workspaces |
| `automation.manage` | Schedules, heartbeats, and loops |
| `hub.execute` | Agent lifecycle, workspace titling, observation, and recovery |
| `hub.execute` | Agent lifecycle, agent/workspace observation, and workspace recovery |
Agents and terminals use workspace authority. Both can execute code and mutate the workspace, so separate write permissions would claim an isolation boundary the daemon cannot enforce.
+31 -193
View File
@@ -4,7 +4,7 @@ Local plugins contribute daemon RPCs, native app surfaces, workspace panels, Com
client slash commands, timeline items, header buttons, composer pills, app themes, composer attachment sources, and settings screens.
Paseo executes `index.server.ts` in a subprocess and `index.client.tsx` in every connected app.
> **Trust every plugin you add.** `paseo plugin add` and `paseo plugin install` mean “I trust this codebase.” Plugins are unsandboxed: server code and preparation commands run with the daemon user's access on the daemon host, and client contributions run inside Paseo. The repository's dependencies and future updates are part of that trust decision. With `--host`, preparation runs on that remote daemon host.
> **Trust every plugin you add.** `paseo plugin add` and `paseo plugin install` mean “I trust this codebase.” Plugins are unsandboxed: server code and Git preparation commands run with the daemon user's access on the daemon host, and client contributions run inside Paseo. The repository's dependencies and future updates are part of that trust decision. With `--host`, preparation runs on that remote daemon host.
## Install a directory source
@@ -36,8 +36,7 @@ The daemon stores directory sources under the root `plugins` object:
}
```
Installed plugins are disabled unless `pluginsEnabled` is `true`. Built-in plugins remain active.
Changing that root field is
The plugin system is disabled unless `pluginsEnabled` is `true`. Changing that root field is
runtime-safe: run `paseo reload` after editing `config.json`. Enabling starts every configured,
enabled plugin; disabling tears them all down without restarting the daemon. Plugin source entries
remain lifecycle-owned and do not reload from manual config edits.
@@ -69,7 +68,7 @@ runtime modules, so consumers do not install these packages when adding the plug
```
Declare the supported Paseo range and keep it current when adopting newer APIs. See the
[requirements contract](../public-docs/plugins/reference.md#requirements), including legacy
[requirements contract](../public-docs/plugins/v0.8/reference.md#requirements), including legacy
manifests and prerelease matching.
The config key is the runtime plugin ID. The manifest ID is the default selected during install;
@@ -86,15 +85,6 @@ plugin before compiling and starting from disk. A failed reload stays failed; Pa
the old code. Use `enable`, `disable`, and `remove` to manage one plugin. Removing a directory source
never deletes it. The global `pluginsEnabled` switch remains available.
## Built-in plugins
Built-in plugins live in `plugins/<id>/` and ship with the daemon. Add a directory and one ID to
`builtinPlugins` in `packages/server/src/server/plugins/builtin/index.ts`. The workspace, build
copy, and CI checks cover every listed directory; unlisted directories do not load. Built-ins run
in process, ignore `pluginsEnabled`, and do not appear in `config.json` or the installed plugin
list. Their client bundles appear in the plugin catalog. Editing one in development requires a
daemon restart. Directory, Git, and npm installs cannot use a built-in ID.
## Install a Git source
GitHub repositories use an `owner/repository` shorthand. Other hosts use a Git URL. An existing
@@ -113,36 +103,10 @@ paseo plugin update --all
Append `:relative/path` to the source when the plugin lives below the repository root.
`--ref` chooses the initial branch, tag, or commit once. Ordinary updates resolve the remote's
current default HEAD and ask for approval. `ls` reports the installed commit without contacting the remote.
Omitting `--ref` tracks the remote's default branch. A branch passed with `--ref` also tracks;
tags and commits stay pinned. `ls` reports the installed commit without contacting the remote.
Removing a Git source deletes Paseo's managed checkout.
## Managed source ownership
The [public source reference](../public-docs/plugins/reference.md#plugin-sources) owns identifier
syntax and npm prerequisites; the [publishing guide](../public-docs/plugins/publishing.md) owns distribution. Both clients send the source unchanged
through `installPluginSource`; only the daemon resolves host paths and acquires sources.
`ManagedPluginSources` owns acquisition and offline source description. Config stores the active
directory; sources.json stores managed kind and the Git acquisition remote. The remote is needed
because authenticated clones may have a rewritten placeholder origin. Fixed owned directory layout
provides the selected subdirectory, npm package name and cleanup root. Git HEAD and the installed
npm package.json checked against package-lock.json provide the current revision. Keep the complete
npm dependency tree and lockfile after staging activation. No install selector governs updates.
The loader sees only the configured directory and never invokes npm. npm lifecycle scripts are
disabled during acquisition; manifest preparation owns required commands before validation.
List responses retain the closed `source: directory | git` enum. The optional `installation`
projection carries identity and current revision. Old npm metadata remains accepted but is no longer
emitted. Installation uses `features.pluginSourceInstallation`; reviewed preview/apply uses
`features.pluginSourceUpdates`, gated by the SDK and CLI entry. The old immediate-update request
returns update-client guidance. Basic management remains independently available.
Preview returns exact targets and expected installed identity/root/revision. Apply validates those
values, acquires the displayed artifact or commit, then rechecks state before activation. No review
session is stored in the daemon. PluginService owns activation/recovery and independent bulk results.
### Declare Git preparation
Most plugins should omit `build`. Use it only when the staged checkout must install a dependency
@@ -198,10 +162,10 @@ Shared files import contract helpers and types from `@getpaseo/plugin`. Server h
set; an unknown name renders nothing so it cannot break the plugin surface.
Its controlled modal keeps presentation metadata on `<Modal title="…" icon={…}>` and body UI in
`<Modal.Content>`. Body layout, sheet-aware scrolling, and clipboard actions follow the
[host UI contract](../public-docs/plugins/reference.md#host-ui).
[host UI contract](../public-docs/plugins/v0.8/reference.md#host-ui).
Plugin UI runs on desktop and mobile across multiple themes: color every `Text` from
`theme.colors.foreground` or `theme.colors.foregroundMuted`, and size layout from `layout.compact`.
See `public-docs/plugins/reference.md`.
See `public-docs/plugins/v0.8/reference.md`.
### SDK import boundaries
@@ -242,7 +206,7 @@ The scaffold omits `"DOM"` from `tsconfig.json` and does not use `/// <reference
browser globals are not available across the plugin. Put sanctioned web-only APIs in
`client/web.ts`, declare only the globals that module uses, gate each export with
`Platform.OS === "web"`, and provide a native implementation or no-op. See the
[public plugin reference](../public-docs/plugins/reference.md#works-on-mobile) for the complete
[public plugin reference](../public-docs/plugins/v0.8/reference.md#works-on-mobile) for the complete
pattern.
```ts
@@ -259,23 +223,12 @@ export default function contribute(server: PluginServerContext) {
```tsx
// index.client.tsx
import type { PluginClientContext, PluginSidebarItemProps } from "@getpaseo/plugin/client";
import { SidebarRow } from "@getpaseo/plugin/client/ui";
import type { PluginClientContext } from "@getpaseo/plugin/client";
import { Greeting } from "./client/greeting";
function GreetingItem({ currentScreen, openScreen }: PluginSidebarItemProps) {
return (
<SidebarRow
icon="MessageCircle"
active={currentScreen?.screenId === "main"}
onPress={() => openScreen({ screenId: "main" })}
/>
);
}
export default function contribute(client: PluginClientContext) {
client.addScreen({ id: "main", title: "Greeting", Component: Greeting });
client.addSidebarHeaderItem({ id: "main", title: "Greeting", Component: GreetingItem });
client.addSurface("main", Greeting);
client.addSidebarItem({ id: "main", title: "Greeting", icon: "MessageCircle", surface: "main" });
return () => {};
}
```
@@ -288,41 +241,18 @@ errors are logged and do not interrupt host teardown.
Paseo owns the route, screen header, Lucide icon validation, close action, theme DTO, layout facts,
and render error boundary. The contributed component owns the complete body below the header.
The plugin owns the header's text through the screen's `title` (`resolvePluginScreenTitle` in
`plugins/surface-contribution.ts`). A legacy `addSidebarItem` route (`/sidebar/<id>`) shows the
item's title and icon instead, as before screens had titles; the `addSurface` alias titles its
screen with the id. A `/sidebar/<id>` route with no legacy item of that id opens the screen with
that id, so links saved before a plugin migrated keep working.
Screen params ride in the screen route's query string (`plugins/routes.ts`), not in app state, so
reload, history, links, and the host switcher keep them without a store. Expo Router merges the
route's segments (`serverId`, `pluginId`, ...) into the same search params as the query, so param
keys are stored under a `param.` prefix; plugins never see it and may use any key.
`openScreen` validates its input once (`parsePluginOpenScreenInput`); code past it trusts the
params.
RPC contracts validate inputs and outputs in both the app and plugin subprocess. `useRpc` returns a
typed async function. Use the host-provided `@tanstack/react-query` for request state and caching;
Paseo gives each plugin installation its own query client.
`usePaseo()` and the handler's `{ paseo }` context expose the same `PaseoApi`: projects,
workspaces, agents, terminals, providers, and daemon config. They do not expose connection lifecycle.
`usePaseo()` is the plugin's one client: `InstalledPlugin.paseo`, created with the installation in
`packages/app/src/plugins/registry.ts` over the host's existing connection and passed to setup as
`client.paseo`. Authors release their subscriptions in their own cleanup, and teardown (disable,
reload, removal, host removal) disposes the client, which ends the rest; nothing is created or
disposed per mounted surface. See the
[public example](../public-docs/plugins/reference.md#use-the-paseo-sdk). Switching the screen's host
changes both `usePaseo()` and `useRpc()` to that host's installation. An offline selected host fails there and never falls through to another
workspaces, agents, terminals, providers, and daemon config. They do not expose connection lifecycle. A surface borrows the
selected host's existing connection; switching the screen's host changes both `usePaseo()` and
`useRpc()` to that host. An offline selected host fails there and never falls through to another
installation. A server handler owns an IPC-backed daemon session for the life of its subprocess.
Use plugin RPC for plugin-specific backend behavior that is not a normal Paseo operation.
Host-targeted clients and discovery are owned by `packages/app/src/plugins/hosts`, with per-installation
bindings supplied by the bundle loader. Bind the imperative getter to that installation; do not
resolve ownership through a mutable current-plugin global. Keep observation ownership in this module
and transport ownership in the app host runtime. See the [public host API contract](../public-docs/plugins/reference.md#discover-hosts-and-target-another-host).
Each subprocess gets an exclusively owned `plugin:<id>` session. That identity is reserved from
normal clients, never resumes another session, and is cleaned immediately on exit without reconnect
grace. During daemon startup, plugin sessions may connect while application WebSockets remain
@@ -331,7 +261,7 @@ catalog is complete.
When the same plugin contribution exists on multiple hosts, Paseo shows it once in the sidebar and
adds a host picker to the screen header. The selected host supplies the bundle, RPC transport, and
query cache. Explicit SDK targets follow the [host API contract](../public-docs/plugins/reference.md#discover-hosts-and-target-another-host).
query cache. Plugin code cannot address another host.
Workspace panels, Command Center items, and client slash commands are client contributions. The
daemon transports their compiled bundle without interpreting placement or callbacks. Panel props
@@ -348,16 +278,15 @@ when moved between hosts. Explorer configuration can create workspace-context pa
existing agent-context instances, but it cannot create an agent panel without an agent-aware command.
Command Center callbacks use the selected host's existing `PaseoApi` for normal Paseo operations.
They use typed plugin RPC only for plugin-specific backend work. Surface and panel navigation
belongs to the app; plugins do not receive Expo Router or workspace-layout store access.
See the public [navigation fields](../public-docs/plugins/reference.md#screens-and-sidebar-items)
and [external links and workspace browsers](../public-docs/plugins/reference.md#external-links-and-workspace-browsers)
for the author-facing contract.
They use typed plugin RPC only for plugin-specific backend work. Surface and panel props expose
optional client-owned agent and workspace navigation; its absence is the compatibility gate for
older clients. Other navigation remains limited to registered global surfaces and workspace panels.
Plugins do not receive Expo Router or workspace-layout store access.
## Lifecycle hooks
Server entries register lifecycle observers with `server.on()` and request transforms with
`server.before()`. The [public reference](../public-docs/plugins/reference.md#lifecycle-hooks)
`server.before()`. The [public reference](../public-docs/plugins/v0.8/reference.md#lifecycle-hooks)
owns callback shapes, ordering, and failure behavior. `plugin-examples/lifecycle-logger` registers all
eleven hooks; `plugin-examples/lifecycle-actions` demonstrates common automation callbacks.
@@ -381,38 +310,9 @@ export default function contribute(server: PluginServerContext) {
}
```
Declare `command: ["agent", "serve"]` when your provider launches an executable. The daemon applies
`agents.providers.<id>.command` and `env`, resolves the executable against the effective PATH, and
removes parent-session and daemon-control environment variables. `connect({ launch })` receives
`{ command, args, env }`: an executable path, effective arguments, and the complete sanitized
environment. Spawn with those values; do not merge the plugin process's environment back in.
The launch travels as data through the same provider protocol for built-in and subprocess plugins.
Without a declared command, existing providers keep their connection behavior and receive no launch.
Implement optional `status({ launch })` to check credentials, versions, or other prerequisites using
that effective launch. Return `{ available: true }` or
`{ available: false, diagnostic: "Run agent login" }`. The daemon checks executable availability
before calling status; it exposes your diagnostic through the normal provider diagnostic command.
A command-backed provider without status is available when its executable resolves. Providers with
neither command nor status retain connection-based availability.
A same-ID config entry without `extends` overrides the plugin's `enabled`, `command`, `env`, label,
description, and model configuration through the normal provider registry. `enabled: false` disables
selection and discovery. An entry with `extends` defines the user's own provider, shadows the plugin,
and logs a warning. Overrides validate before plugins load; an override for an absent plugin stays
inactive and logs a warning naming the unmatched ID after built-in and configured plugin startup
settles (including disabled plugins), then on each installed registry generation.
Use IDs matching `/^[a-z][a-z0-9-]*$/` for configurable providers. Dots and underscores
remain valid for plugin registration but cannot be used as config provider IDs.
Provider config changes rebuild the affected registry entry and invalidate its catalogue; the next
connection receives the new launch without restarting the daemon. Running sessions keep their
current launch until refreshed, as native providers do.
Implement optional `ProviderRegistration.getCatalogCacheKey(options)` to share equivalent catalogue
probes. The callback runs in the plugin process before discovery and receives the actual global or
workspace target plus the resolved `launch` when a command is declared. Return a key covering
effective configuration and execution environment, or
workspace target. Return a key covering effective configuration and execution environment, or
`undefined` for target-specific caching. Ignore `force` when choosing identity. Existing providers
need no change. See [catalogue ownership](providers.md#provider-snapshot-refresh-contract).
@@ -423,21 +323,18 @@ Provider settings are toggle/select data that Paseo renders in the composer. Kee
the opaque `providerOptions` config object.
Agent refresh closes the current provider session and opens it again with current configuration and
persistence. Re-read credentials and provider-owned configuration on `session.open`; consume the
daemon launch from `connect` and the per-session env and MCP servers from `session.open`. There is
no provider reload input.
persistence. Providers re-read credentials, environment, global configuration, and MCP servers on
`session.open`; there is no provider reload input.
For an ACP command, register `runAcpProvider({ id, label, command })` from
`@getpaseo/plugin/server/acp`. Its transformer hooks cover narrow vendor differences; do not translate the
whole provider event stream. The shim uses the resolved launch for every probe and session, overlaying
only the supplied per-session env for sessions. The direct and ACP examples live in
`plugin-examples/provider-direct`
whole provider event stream. The direct and ACP examples live in `plugin-examples/provider-direct`
and `plugin-examples/provider-acp-transformer`.
Provider-emitted plugin timeline items use the same renderer registration as transformed and
daemon-appended plugin items. The direct example includes both sides. The renderer-only
`plugin-examples/inline-thinking` example shows that timeline presentation remains independent of a
provider implementation. The public [provider plugin guide](../public-docs/plugins/providers.md)
provider implementation. The public [provider plugin guide](../public-docs/plugins/v0.8/providers.md)
owns author workflow, lifecycle, testing, and distribution guidance.
`ProviderRegistration.icon` is a file path relative to the plugin directory, such as `icon.svg`.
@@ -447,51 +344,10 @@ external `href` or `xlink:href` references are rejected. Fragment references suc
allowed. Paseo reads and sanitizes the file when the plugin starts; the string is never an inline
SVG or URL.
## Usage sources
Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. `input` is a Zod schema checked inside the plugin process before `fetch(input)` runs. `discover()` returns the inputs for accounts found on the host; an empty list omits the source from host usage. `fetch()` returns a report with a stable `account.key` so the daemon can deduplicate accounts and cache by source and input. A failed input or fetch becomes an error report for that source. `icon` uses the same sanitized SVG file rules as provider icons.
The daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients. See the [public usage source reference](../public-docs/plugins/reference.md#usage-sources) for the author contract and minimum version.
## Contribute sidebar items
Sidebar header and footer items are plugin components, not descriptors. The
[public reference](../public-docs/plugins/reference.md#sidebar-items) owns the author API. The host
lives in `packages/app/src/plugins/sidebar-items/`; `packages/app/src/sidebar-nav/model.ts` resolves
one section's order from built-ins, plugin groups, and the section's preference
(`sidebarNavItems` or `sidebarFooterItems`).
- The item's `Component` renders directly in the section, with no wrapper, so a fragment or array
of rows lays out like separate items while Settings keeps one entry for the block. Footer items
are rows between Add project and the footer's icon row. The icon row (Hosts, Import session,
Help and support, Settings) is fixed app code, not a contribution slot, so the kit has no icon
button.
- `openPopover` opens through the same `PluginPopoverSurface` as header buttons
(`plugins/popover.tsx`). Any press inside a `SidebarRow`, including its `trailing` content, moves
the menu anchor to that row: a capture-phase `onStartShouldSetResponderCapture` runs before the
pressed child claims the touch on native and web, and returns false. Keyboard activation skips
the responder system, so the row's own press anchors too. Until a press, the first mounted row
holds the anchor. An item that renders no kit component
anchors to its section container.
- `SidebarSeparator` reuses the app's separator and cancels the footer's horizontal padding, so
the line runs edge to edge in both sections.
- `SidebarRow.trailing` sits in a second `Pressable` beside the row's button, since web cannot nest
buttons. Its `onPress` is the row's, so a press on non-interactive trailing content presses the
row; a button inside it wins its own press because only the innermost pressable responds.
- A new-API item renders from the current route's host, else the host remembered under
`pluginScreensHostKey(pluginId)`, which the screen's host switcher and the item's `openScreen`
set. One key per plugin: switching host on any of its screens moves all its items.
- `addSurface`, `openSurface`, and `addSidebarItem` are undocumented aliases, tagged
`COMPAT(pluginSidebarAliases)`. `addSidebarItem` is recorded only in `legacySidebarItems` and
groups as a `legacy` sidebar group, which the app draws as its own row: its registered icon, in
the sidebar and in Settings > Sidebar, opening `/plugin/<id>/sidebar/<item>` on the host it
remembers under its own key. It shares header ids and the `plugin:<pluginId>:<id>` settings key
with new-API items.
## Contribute buttons
Header buttons and composer pills share the client-only descriptor and registration lifecycle in
`packages/app/src/plugins/buttons/`. The [public button reference](../public-docs/plugins/reference.md#header-buttons)
`packages/app/src/plugins/buttons/`. The [public button reference](../public-docs/plugins/v0.8/reference.md#header-buttons)
owns the author API and placement rules. Keep presentation policy in this module so another
placement can reuse behavior without copying registration or action state.
@@ -499,26 +355,12 @@ Native sheets teleport their children. Button surfaces rebuild the installation'
query, and toast providers inside the surface content, including overflow pages from different
plugins. Providers only around the trigger do not reach those bodies.
Request observation with `client.paseo.agents.list({ subscribe: {} })` and consume the returned
`subscription` handle. Plain `list()` and agent/workspace directory `.subscribe(handler)` listeners create no daemon
demand. Provider and project `subscribe()` calls establish their own demand. On capable daemons, each
list-and-subscribe call has its own server ID, even for the same query. Older daemons retain
[shared delivery behavior](protocol-compatibility.md#owned-observations). Handle snapshots
also run after reconnect; replace your view before applying its subsequent updates. The installation
owns every observation created through its API and releases them on unload, including setup failure.
Mounted surfaces and command invocations have shorter API lifetimes.
Keep the client entry synchronous: return its cleanup function immediately and start asynchronous
work inside it. See the maintained [composer pill example](../plugin-examples/local-plugin/client/main.tsx).
## Contribute timeline items
Timeline transformers and renderers are client contributions. The daemon's canonical rows and
built-in projection stay unchanged. The app transforms each source item while building the render
model, for both fetched history and live events, before native Markdown splitting and Overview
tool grouping. Assistant callbacks receive the accumulated source text, never display fragments.
Live assistant messages use `phase: "streaming"`; committing to history makes them `"complete"`.
Paseo memoizes by source-item reference and phase and derives replacement IDs from source identity, so
model, for both fetched history and live events. The input includes `phase: "streaming" | "complete"`.
Paseo memoizes by source-item reference and derives every replacement ID from the source identity, so
streaming updates preserve mounted component identity.
`query.itemType` selects one public `AgentTimelineItem.type`. The callback owns any detailed
@@ -557,7 +399,7 @@ be rendered intact. The daemon advertises this RPC through
`addSlashCommand` registers an agent- or workspace-context command in the composer. The
callback runs in the app, receives the trimmed text after the command name as `args`, and receives
the same `paseo`, `rpc`, `openScreen`, workspace, agent, and `openPanel` capabilities as the matching
the same `paseo`, `rpc`, `openSurface`, workspace, agent, and `openPanel` capabilities as the matching
Command Center callback.
```ts
@@ -615,7 +457,7 @@ drops the optional presentation fields.
Register ordinary components with `client.addSettingsScreen` and open them with `openSettings`.
The host settings shell owns navigation and layout; plugin content must not add another page
scroll view or header. See the [author contract](../public-docs/plugins/reference.md#settings-screens)
scroll view or header. See the [author contract](../public-docs/plugins/v0.8/reference.md#settings-screens)
and `plugin-examples/settings` for the named UI components and persistence API.
Settings storage is scoped to the runtime installation ID, never the source path or manifest ID.
@@ -623,10 +465,6 @@ Its writer lives with the plugin subprocess, while its directory lives outside m
so updates and reloads retain values. Settings-change notifications must not enter the catalog
reload path: that path disposes the plugin and would destroy open drafts after every save.
`server.registerSettings(definition)` returns a server-side handle. Use `read()` for the current
`ready` or `invalid` state and `subscribe()` for successful saves, resets, and migrations. The
subscription cleanup belongs in the plugin's contribution cleanup when it outlives the entry.
## Contribute a theme
`addTheme` takes a small light or dark palette and a display name. Paseo expands it through the
@@ -650,7 +488,7 @@ stable rather than arrival-ordered. The app resolves that id
against the installed catalog on every change; an id nothing contributes falls back to the default
preference instead of painting the reserved slot's placeholder colors.
Existing plugin authors should follow the standalone [v0.8 runtime-entry migration guide](../public-docs/plugins/migration.md).
Existing plugin authors should follow the standalone [v0.8 runtime-entry migration guide](../public-docs/plugins/v0.8/migration.md).
See `plugin-examples/local-plugin` for a native surface, `plugin-examples/linear` for a complete
attachment-source example, `plugin-examples/timeline-items` for timeline projection, and
+1 -25
View File
@@ -10,8 +10,6 @@ Two contracts follow from it.
A schema change must not break parsing in either direction. An old app still parses messages from a new daemon. A new daemon still parses messages from an old app.
`hello` carries an optional credential. The daemon accepts a client protocol version from its minimum through newer client versions, selects the lower of client and daemon maximum, and reports it as optional `server_info.protocolVersion`. A `hello.rejected` frame precedes an auth close only when the client sent `hello.auth` or advertised `hello_rejection`; older clients receive the existing WebSocket close code and reason. This avoids sending them a new top-level message their validator does not recognize.
- New fields are `.optional()` with a sensible default.
- Never flip optional to required, remove a field, or narrow a type. `string` to `enum` and nullable to non-null are both narrowing.
- A field you stop sending stays accepted. You stop writing it, you don't stop reading it.
@@ -48,32 +46,10 @@ The app, CLI, and plugins inherit those defaults; they supply only host resource
automation, or explicit overrides. A schema accepting a message does not establish support for
its delivery semantics.
On capable daemons, connecting creates no timeline or event demand. Client subscriptions own their network membership,
Connecting creates no timeline or event demand. Client subscriptions own their network membership,
release it on unsubscribe, and restore it after reconnect. Raw message observers inspect traffic
without requesting streams. Application caches and which agents are visible remain caller-owned.
## Owned observations
`owned_subscriptions` and `server_info.features.ownedSubscriptions` negotiate the source-owned
contract described in [architecture](architecture.md#websocket-protocol). The client selects delivery
behavior once at its connection boundary. App workflows use the same observation interface on both.
With an older daemon, the client uses the existing connection and legacy RPCs. Directory subscriptions
remain shared and last-query-wins. Local handle IDs identify listeners; they do not promise independent
server filters. Timeline and event membership retain their existing shared behavior. Releasing a handle
detaches its listener and uses the old unsubscribe operation where one exists. Broadcast-only hosts keep
broadcasting; readiness there means local attachment, not a daemon acknowledgement. No extra sockets
or multiplexing emulation are introduced.
Preserve established workflows when changing delivery internals. Independent filters and quiet
connections require a capable daemon; opening the app, reading history and using terminals do not.
Pre-registry workspace grouping and legacy event normalization belong inside the client boundary.
Old clients keep their existing wire shapes and slot behavior at the daemon's source boundary.
The adapter keys legacy slots by physical socket, so an old connection cannot replace a modern
sibling's observation even when both use the same logical client ID. Optional wire IDs stay accepted
for parsing compatibility; modern requests cannot select their subscription ID.
## Every shim is tagged and dated
A shim that exists for old-app or old-daemon support carries a comment naming it, the version it arrived in, and when it can go:
+26 -57
View File
@@ -1,22 +1,6 @@
# Adding a New Provider to Paseo
Add new providers through the plugin SDK. The core adapter patterns below describe the existing
server integrations.
## Plugin providers
Keep a bundled provider in `plugins/<id>/` and register it through
`@getpaseo/plugin/server/provider`. Antigravity follows this pattern. Built-in loading and SDK
import rules belong to [plugins.md](plugins.md#built-in-plugins); the
[public provider guide](../public-docs/plugins/providers.md) covers the provider contract.
The plugin owns the CLI transport, session state, catalog, and capabilities. The daemon owns
executable resolution and applies `agents.providers.<provider-id>.command` and `env` before
connecting. Register the provider's icon with the plugin rather than adding it to the app's
provider icon map. You do not need a core manifest entry or provider factory.
For Antigravity installation, modes, and limitations, see
[Supported providers](../public-docs/supported-providers.md#antigravity).
This guide walks through adding a new agent provider end-to-end. There are two integration patterns, and this doc covers both.
## Provider-native session options
@@ -24,8 +8,7 @@ For Antigravity installation, modes, and limitations, see
names and nesting are the provider's native contract; options are not portable between providers.
Paseo validates the object with the selected provider's strict schema before constructing a session.
Unknown keys fail with their `providerOptions.*` path. Paseo-owned controls such as cwd, model,
prompt, environment, session identity, MCP transport, callbacks, and hooks are not accepted as
top-level provider options.
prompt, environment, session identity, MCP transport, callbacks, and hooks cannot be passed here.
This Paseo version accepts these keys:
@@ -36,18 +19,10 @@ This Paseo version accepts these keys:
`allow_local_binding`, `allow_upstream_proxy`, `dangerously_allow_all_unix_sockets`,
`dangerously_allow_non_loopback_proxy`, `domains`, and `unix_sockets`. See the
[Codex configuration reference](https://developers.openai.com/codex/config-reference).
- **Claude:** `allowedTools`, `disallowedTools`, `additionalDirectories`, `extraArgs`, `sandbox`, and
`settings`. `providerOptions.extraArgs` passes the SDK's documented
[`Options.extraArgs`](https://platform.claude.com/docs/en/agent-sdk/typescript#options) map
unchanged: keys omit the leading `--`, string values supply an argument value, and `null`
supplies a boolean flag. For example, `providerOptions: { extraArgs: { chrome: null } }`
passes `--chrome`, and `providerOptions: { extraArgs: { model: "x" } }` passes `--model x`.
Set it in session configuration or a plugin's `server.before("agent.create", ...)` hook; see
[plugin configuration hooks](../public-docs/plugins/reference.md#change-configuration-and-inject-an-mcp-server). Values are literal;
shell expressions such as `$(command)` are not evaluated. The accepted sandbox
fields cover enablement, fail-if-unavailable behavior, excluded and unsandboxed commands,
filesystem read/write rules, network domain/socket/local-binding rules, weaker nested
sandboxing, ignored violations, and the ripgrep command. `settings` accepts native
- **Claude:** `allowedTools`, `disallowedTools`, `additionalDirectories`, `sandbox`, and `settings`.
The accepted sandbox fields cover enablement, fail-if-unavailable behavior, excluded and
unsandboxed commands, filesystem read/write rules, network domain/socket/local-binding rules,
weaker nested sandboxing, ignored violations, and the ripgrep command. `settings` accepts native
`permissions.{allow,ask,deny}` and sandbox settings. See the
[Claude Agent SDK TypeScript reference](https://platform.claude.com/docs/en/agent-sdk/typescript)
and [Claude settings reference](https://code.claude.com/docs/en/settings).
@@ -62,7 +37,7 @@ Each provider definition owns its option schema and exact MCP preapproval mappin
must fail closed for Hub unattended execution until it can approve one exact injected MCP server
and tool identity without approving native tools.
## Core adapter patterns
## Two Integration Patterns
### ACP (Agent Client Protocol) -- recommended
@@ -74,18 +49,11 @@ Copilot custom agents are exposed through ACP session config, not the slash-comm
ACP permission options are rendered as ordered actions and Paseo returns the selected option's exact `optionId`. Agents can therefore encode a single-choice question as multiple options of the same allow kind. Auto-accept does not resolve those chooser requests; they always wait for the user.
ACP shims can own model discovery through `catalogModelResolver`; the shared client owns the probe
process and refresh deadline. Keep vendor RPCs in the shim. Cursor uses
`cursor/list_available_models` because switching models during discovery writes its saved CLI
preferences and selection history. Cursor versions without that extension must be updated. Kimi
still probes model selections in its own shim. The initial session supplies modes and the current
model; it does not override the model list returned by a resolver.
### Direct
Implement the `AgentClient` and `AgentSession` interfaces from `agent-sdk-types.ts` yourself. This gives full control but requires you to handle process management, streaming, permissions, and session persistence from scratch.
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode/runtime-client.ts`), `pi` (`providers/pi/agent.ts`), and `omp` (`providers/omp/agent.ts`). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
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` (`providers/omp/agent.ts`). 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.
@@ -95,8 +63,6 @@ A provider that can register runtime tools directly should set `supportsNativePa
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.
Pi extension adapters live under `packages/server/src/server/agent/providers/pi/extensions/<extension>/`. Each adapter turns Pi RPC facts into Paseo tool, timeline, subagent, or question mappings through the [extension contract](../packages/server/src/server/agent/providers/pi/extensions/contract.ts). To add one, create its directory, add one entry to `extensions/registry.ts`, and test it with fixtures captured from the real extension in Pi that record package, version, source commit, Pi version, and capture date. `agent.ts`, `history-mapper.ts`, and `tool-call-mapper.ts` never name an extension.
Paseo's per-agent and daemon-wide system prompts are appended by its generated Pi integration extension. Paseo deliberately does not pass `--append-system-prompt`, because that flag replaces Pi's automatic `APPEND_SYSTEM.md` discovery instead of composing with it.
Pi model records expose input capabilities through `model.input`. Only send raw RPC `images` when the current model explicitly includes `"image"` in that list. Text-only Pi/OMP models reject image content and persist the rejected image in JSONL history, so image prompts for those models must be materialized to a local file and passed as a text path hint instead.
@@ -113,15 +79,9 @@ OMP supports native Paseo host tools. The adapter registers the full caller-scop
Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them.
OpenCode adapters target v1.14.46 and v2.0.10. V2 rejects binaries older than the tested 2.0.10 SDK at runtime selection. Runtime selection uses the configured command and environment. A recognized version is cached until provider configuration reload; a failed, timed-out, or unrecognized probe retains the v1 path and retries detection on the next operation. Load v2 code and materialize its plugin only after positive v2 selection; v1 sessions remain undecorated. Keep upstream SDK types inside the version-specific adapter. OpenCode owns storage migration; a missing native session must fail resume rather than create a replacement. V2 has no native archive/unarchive operation: archiving affects Paseo only. V1 retains native archiving.
OpenCode 1 keeps MCP and process environment outside the session boundary. Paseo shares one OpenCode server for ordinary agents and installs a daemon-owned plugin through `OPENCODE_CONFIG_CONTENT`. The plugin reads the exact agent environment and caller-scoped Paseo tool catalog from the daemon's private loopback bridge for each OpenCode session. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifact contains no session data or secrets.
Use OpenCode v2 execution events to trigger turn completion, with active-state and durable-log reconciliation after admission, reconnect, and while a turn remains active. Do not use `session.wait`: a healthy turn exceeding Node's HTTP headers deadline produces a transport error while OpenCode keeps working. The live event feed has no replay, and shutdown interruption preserves the previous idle outcome, so the session snapshot alone cannot recover missed execution events. Quiet streams are healthy: v2 heartbeats are SSE comments, not application events.
V2.0.4 also removed the activation endpoint that gated a cold location, and a cold location registers its config-derived commands, skills, and providers asynchronously. Wait until `plugin.list` returns a populated inventory before reading the catalog or commands; an empty inventory means the location is still warming. Fail when the readiness deadline expires, including when an inventory request stalls.
Paseo installs its OpenCode tool bridge through `OPENCODE_CONFIG_CONTENT`. V1 accepts a plugin file; v2 silently skips configured files and requires a package directory with a server entry point. Both versions use the daemon's private loopback bridge for caller-scoped tools. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifacts contain no session data or secrets. V2 also needs this plugin when native Paseo tools are disabled: its prompt API has no structured-output format, so the plugin supplies a schema-validated final-answer tool.
An agent with custom environment variables or user-configured MCP servers gets a dedicated OpenCode server. V2 supports session environment, but its MCP configuration remains location-scoped, so separate servers still prevent one agent from changing another agent's MCP setup. Configure custom MCP with `mcp.add`; do not follow it with `mcp.connect`, which only toggles config-backed servers.
An agent with custom environment variables or user-configured MCP servers gets a dedicated OpenCode server. Keep that isolation until OpenCode exposes those values as session-owned configuration. Configure custom MCP with `mcp.add`; do not follow it with `mcp.connect`, which only toggles config-backed servers.
OpenCode owns user message IDs. Do not pass Paseo-generated IDs to OpenCode prompt APIs; let OpenCode create `msg*` IDs and record the user timeline item from the `message.updated` event.
@@ -213,17 +173,26 @@ promise for completion: equal results, including equal discovery timestamps, emi
---
## Usage sources
## Provider Usage Fetchers
Usage is fetched on demand from plugin usage sources. Each source registers through `server.registerUsageSource()` with an input schema, `fetch(input)`, and required `discover()`. The daemon discovers configured accounts, validates inputs in the plugin runtime, caches each source/input result for five minutes, and returns `usage.list_reports.response`. A source report has an account key, availability status, plan label, windows, balances, and details.
Provider plan usage is fetch-on-demand, not a daemon push subscription. The app calls `provider.usage.list.request` through React Query when the usage tooltip or Host Usage settings screen is shown, and the daemon returns the normalized `ProviderUsage` list directly.
Create a built-in source under `plugins/<name>-usage-source/` with the same manifest, entry, `server/`, `shared/`, and `icon.svg` layout as an external plugin. Add its ID to `builtinPlugins` in `packages/server/src/server/plugins/builtin/index.ts`. Keep credential discovery, API parsing, and normalization inside the source; use helpers from `@getpaseo/plugin/server/usage`. The wire shape remains source agnostic. See [plugin usage sources](plugins.md#usage-sources).
To add plan usage for a provider, add `packages/server/src/services/quota-fetcher/providers/<provider>.ts` and register it in `packages/server/src/services/quota-fetcher/manifest.ts`. The provider file exports only its fetcher class; provider auth, endpoint constants, API schemas, and normalization helpers stay private in that file. A fetcher owns provider auth/API parsing and returns the generic shape:
`provider.usage.list` remains a compatibility RPC for older apps. It maps discovered reports to `ProviderUsage`. New clients use `usage.list_reports` after checking `server_info.features.usageSources`.
- `providerId`, `displayName`, `status`, and optional `planLabel`
- any number of `windows` such as Session, Weekly, or Biweekly
- optional `balances` for credits, USD, requests, or tokens
- optional `details` for provider-specific rows
### Credentials are read only
Keep the protocol shape provider-agnostic. Do not add provider-specific renderers for new limit windows; labels and generic bars should carry the UI. API responses should be parsed and normalized with Zod inside the fetcher, while the protocol boundary stays strict so old/new client compatibility is explicit.
A source reads provider credentials without writing them. On 401 or 403 it returns `unavailable` and leaves refresh to the provider CLI. Redeeming a refresh token here would invalidate the CLI's copy; rewriting a parsed credential file could drop fields the source does not model.
Kimi Code usage follows the CLI-managed credential file at `KIMI_CODE_HOME` or `~/.kimi-code/credentials/kimi-code.json`; do not probe the legacy `~/.kimi` path as the primary source for current Kimi Code installs.
Cursor usage reads the desktop `state.vscdb` token first, then `cursor-agent`'s `~/.config/cursor/auth.json`. Headless hosts only have the CLI file.
### Usage fetchers are read-only on credentials
A fetcher reads the provider's credential file and never writes it. On a 401 or 403 it returns `unavailable` and leaves refresh to the provider's own CLI: redeeming a refresh token in the fetcher invalidates the CLI's copy (refresh tokens are single-use), and rewriting the file through the fetcher's Zod schema drops any field the schema does not model, corrupting the file for the CLI.
---
@@ -311,7 +280,7 @@ export class CopilotACPAgentClient extends ACPAgentClient {
### 2. Add to the provider manifest
In `packages/protocol/src/provider-manifest.ts`, add mode definitions with UI metadata (icons, color tiers) and a provider definition entry.
In `packages/server/src/server/agent/provider-manifest.ts`, add mode definitions with UI metadata (icons, color tiers) and a provider definition entry.
First, define the modes with visual metadata:
Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

+1 -32
View File
@@ -47,36 +47,6 @@ commit the prepared inputs locally and run the release command. Its branch and
tag push is the one remote release batch and starts CI for the complete release
commit.
## Release branch discipline
While you finalize a release on `main`, use a temporary `next` branch for work intended for the following
release. This applies to both beta and stable releases.
- Create each new `next` from freshly fetched `origin/main`. Reuse it while active.
- "This goes to next" means create the PR against `next` or retarget an existing
PR, and keep that destination through delivery.
- Keep `next` current by merging `origin/main` into it as release fixes land.
Avoid rebasing this shared branch because agents and open PRs depend on its history.
- After the release ships, bring `next` up to date and open a `next` → `main` PR.
Pass CI and merge without squashing away the individual PR commits needed for
the changelog. Retarget remaining PRs based on `next` to `main` and delete the integrated
`next`. Create it fresh when needed again.
**Setup still needed:** CI, Docker, and Nix PR checks currently target only `main`,
and GitHub permits only squash merges. Enable checks and required-check protection
for `next`, CI on its pushes, and merge commits for the integration PR. Handle PR
base changes (`edited` events) so retargeting runs checks against the new base;
GitHub's default PR events do not cover this. Deployment triggers stay unchanged.
### Hotfix from a release tag
If `main` contains changes you do not want to release, branch from the affected
release tag and cherry-pick only the required fixes. Run CI on that branch, then
use the normal release flow with it as the explicit source, choosing a new patch
or beta version. Ensure the fixes and changelog also reach `main` and any active
`next`, preserving newer development and version changes there. This is a
short-lived hotfix branch, not another maintained release track.
## ACP catalog updates
ACP catalog work enters a release through an explicit user request:
@@ -94,7 +64,7 @@ release push as the changelog and version commit.
There are two supported release paths:
1. **Direct stable release**: you are ready to ship the resolved release source to everyone immediately (default `origin/main`).
1. **Direct stable release**: you are ready to ship the current `main` commit to everyone immediately.
2. **Beta flow**: release candidates on the `beta` channel. Each beta carries its own changelog entry, publishes npm only on the explicit `beta` dist-tag, and stays behind the Stable/Beta switch on `/download`.
Paseo has one linear release track even though npm dist-tags are independent
@@ -192,7 +162,6 @@ npm run release:promote # Promote X.Y.Z-beta.N to stable X.Y.Z
- Betas publish desktop assets and APKs for testing. They also build iOS, upload it to TestFlight, add it to the `Paseo Beta` external group, and submit it for Beta App Review. They do not submit mobile builds to the production stores.
- `release:promote` creates a fresh stable tag like `v0.1.41`; the final release never reuses the beta tag
- Desktop assets now come from the Electron package at `packages/desktop`
- Require the Linux artifact CI checks with both restricted and usable user namespaces to pass before publication; see [packaged desktop smoke](testing.md#packaged-desktop-smoke). Keep the installed-package and AppImage checks together.
- Beta releases use Electron's `beta` update channel. Users on the stable channel only receive stable releases; users on the beta channel receive beta releases and the final stable release when it is published.
- **Each beta carries its own changelog entry.** `Release Notes Sync` mirrors the matching `## X.Y.Z-beta.N` entry into that prerelease body. Promotion collapses every beta entry for the version into one final stable entry. See the Changelog policy section.
-1
View File
@@ -26,7 +26,6 @@ Terminal frames share the daemon main event loop with all agent traffic. The `ev
- **Snapshot catch-up is backpressure-gated.** A stream falls back to a full snapshot only when `outputBytesSinceSnapshot > MAX_TERMINAL_OUTPUT_FRAME_BYTES` (256KB) **and** the client transport reports `bufferedAmount > MAX_CLIENT_BUFFERED_BYTES` (4MB). A client that keeps draining streams continuously, no matter how much output is produced. Before this gate existed, every 256KB of build output dropped a frame and forced a full JSON cell-grid snapshot (~200k objects across IPC) — the historical source of spiky lag and GC hitches.
- **Plugin daemon sessions report IPC queue bytes.** Their virtual socket increments `bufferedAmount` before `process.send` and decrements it only in the send callback. Text and binary frames share that ordered queue, so the normal snapshot catch-up and physical high-water gates remain valid for server-side plugin SDK traffic.
- **Client output writes are not serialized per frame.** The emulator runtime drains contiguous plain writes straight into xterm (which buffers internally). Only barrier ops (`clear`, `snapshot`, `suppressInput` writes) wait — behind a zero-length sentinel write — so resets can't interleave with in-flight output.
- **Find preserves the inspected xterm viewport, including at the live bottom.** Keep its output anchor inside the existing batched write path. Serializing output to preserve a search result would add terminal latency.
- **Retained terminal tabs in the focused workspace keep their streams.** Hidden mounted terminals continue applying output, so switching tabs does not resubscribe or request a fresh snapshot. The retained-panel LRU bounds the number of live streams; terminals in an unfocused workspace detach.
- **Terminal size has one daemon-owned claimant.** Focus and direct interaction send a `claim`; later geometry changes from that connection send `update`. A claim transfers ownership even when the dimensions are unchanged, while an update from any other connection is ignored. This lets an owning pane follow splits and keyboard insets without allowing an idle phone or browser to steal the PTY size.
+4 -8
View File
@@ -107,18 +107,14 @@ When a test is labeled end-to-end, it calls the real service. No environment var
The packaged desktop smoke is an external observer of the production launch path. It must not add a smoke-only branch to Electron main or start the daemon itself.
The harness launches the packaged app with isolated user data and daemon state, connects to the real renderer over Chromium's debugging protocol, and requires all of these outcomes:
The harness launches the unpacked packaged app with isolated user data and daemon state, connects to the real renderer over Chromium's debugging protocol, and requires all of these outcomes:
- the `paseo://app/` renderer mounts into `#root`;
- the sandboxed preload exposes the desktop bridge;
- the renderer starts a fresh desktop-managed daemon through the normal startup bootstrap;
- the bundled CLI can query that daemon and run a terminal command.
The Desktop Packages workflow runs the Linux x64 smoke under Xvfb on main pushes and on pull requests that change `packages/desktop/**`, `nix/**`, or the workflow itself. The Linux job is pinned to Ubuntu 24.04 and runs twice: with AppArmor user namespace restrictions enabled, then with user namespaces available. It installs the real `.deb`, launches the real AppImage via `--appimage-extract-and-run`, and launches the extracted tar archive. It also replaces the Debian installation with the generated RPM through `rpm --install --nodeps` and checks its sandboxed launch under restrictions. Ubuntu supplies the runtime libraries under Debian package names, so this verifies the RPM payload and postinstall rather than Fedora dependency resolution. Each launch verifies the reported sandbox decision; enabled renderers must also have `NoNewPrivs: 1` and `Seccomp: 2` in `/proc`. The desktop release matrix retains its host-native smokes. Linux release builds stay on Ubuntu 22.04 to preserve their native-library baseline; the restricted-host regression runs on Ubuntu 24.04 after merge.
Never repair `chrome-sandbox` in the smoke harness. The old unpacked smoke set its mode to 4755 and concealed a broken package installer. Run installer tests as root and launch tests as an ordinary user: a root-run namespace probe does not reproduce Ubuntu's AppArmor policy for desktop users. Preserve both restricted and unrestricted cases; either one alone permits another sandbox regression.
Smoke jobs upload renderer screenshots and desktop/daemon diagnostics, including successful Linux sandbox evidence.
Pull-request CI runs the Linux x64 smoke under Xvfb when the cumulative PR diff changes `packages/desktop/**`. The desktop release matrix runs the harness against each host-native packaged app before publishing. All smoke jobs upload renderer, desktop, and daemon diagnostics on failure.
To exercise the smoke locally on Linux:
@@ -132,7 +128,7 @@ npm run build:desktop -- --publish never --linux --x64 --dir
electron-builder packs `node_modules` by walking declared production `dependencies`. A package that imports something it only lists as a `peerDependency` resolves fine in this hoisted workspace, passes every test, and then throws `ERR_MODULE_NOT_FOUND` inside `app.asar` — killing the desktop daemon at startup. That shipped twice from `@replit/codemirror-lang-*` grammars, which are interactive editor extensions published as if they were bare parsers.
The packaged smoke catches it after merge. PRs need the fast dependency-closure test below because packaging runs on main.
The packaged smoke catches it but only runs when a PR touches the `desktop` filter in `.github/ci-paths.yml`. Both offenders landed under `packages/highlight/**`, which maps to `sdk`.
`packages/highlight/src/__tests__/dependency-closure.test.ts` replicates the packer's traversal statically and runs with the normal unit tests. It is scoped to `@getpaseo/highlight` on purpose: that tree is small and pure, so the check is exact. Running the same walk over `@getpaseo/server` produces dozens of false positives from optional dependencies loaded behind `try`/`catch`.
@@ -204,7 +200,7 @@ PR checks are routed by the behavior each suite proves, using `.github/ci-paths.
Required matrix legs are declared as statically named jobs. Their shared steps use YAML anchors, while job-level `if` conditions let GitHub report an unaffected leg as genuinely skipped without allocating a runner or losing the exact required-check name.
The smallest meaningful contract wins over package ownership. Tiny structural invariants such as daemon launch supervision run unconditionally in the always-running routing job instead of maintaining a transitive file list; this check reads source entrypoints and builds no product. Routed integration contracts use stable domain directories. Browser changes select the required Playwright shards; desktop changes select the existing required desktop jobs, with renderer and real-Electron coverage together in the Ubuntu leg. Packaging runs on main in the Desktop Packages, Docker, and Nix workflows. CLI-side Hub changes select one focused test inside the existing required server jobs. The CLI source suite runs once in the third required CLI job; only the separate local E2E runner is sharded, and that runner owns its dependency build. Repository scripts and the shared Vitest configuration run every PR contract because they are cross-cutting toolchain inputs.
The smallest meaningful contract wins over package ownership. Tiny structural invariants such as daemon launch supervision run unconditionally in the always-running routing job instead of maintaining a transitive file list; this check reads source entrypoints and builds no product. Routed integration contracts use stable domain directories. Browser changes select the required Playwright shards; desktop changes select the existing required desktop jobs, with renderer, real-Electron, and packaged-app coverage together in the Ubuntu leg. CLI-side Hub changes select one focused test inside the existing required server jobs. Repository scripts and the shared Vitest configuration run every PR contract because they are cross-cutting toolchain inputs.
## Agent authentication in tests
+14 -64
View File
@@ -5,10 +5,8 @@ Agent chat delivery has two paths:
1. **Live stream** — `agent_stream` WebSocket messages for immediacy. These may be delta-shaped lifecycle updates.
2. **Authoritative history** — `fetch_agent_timeline_request` for correctness. This always returns full projected timeline items, never lifecycle deltas.
The daemon retains projected items in memory. Each source event advances the stream sequence,
then replaces the previous tool state or merges into the current text item. Intermediate payloads
are never retained for history or catch-up. Provider history is the durable transcript authority
and rebuilds the projection when an agent resumes.
The daemon keeps canonical rows only for its runtime. Provider history is the durable transcript
authority and repopulates those rows when an agent resumes.
The invariants are:
@@ -69,34 +67,15 @@ Provider message IDs are not guaranteed for every displayed item. Paseo-generate
Actions that address a point in chat history, such as Fork, use the daemon timeline `epoch` plus the projected item's `seqEnd`. The app carries that position on the rendered assistant item for both live and fetched history. When adjacent projected chunks merge, the merged item retains the newer chunk's position.
The daemon validates the epoch and locates the projected item at the selected position. A fork
includes projected items through that checkpoint. If an item spans the checkpoint and changed
afterward, the daemon refuses that fork with an actionable error: discarded historical payloads
cannot be reconstructed from sequence metadata. Forking the current context remains available.
The daemon validates that the epoch is current and the exact source sequence still exists before slicing rows. It slices before projection so later lifecycle updates cannot leak into the selected context.
## Resume behavior
Opening, reconnecting, and returning from app background establish the current timeline through
bounded catch-up. Switching between continuously subscribed open chats needs no fetch.
Focus alone does not mutate timeline state; the response is compared with the local
Opening, reconnecting, or revisiting after a selective-delivery coverage gap fetches the latest tail
page.
Focus alone does not mutate timeline state; the tail response is compared with the local
authoritative range first.
Cached history remains readable during recovery. The chat shows Reconnecting to host while the host is
offline, then Updating messages until authoritative catch-up completes. Socket connectivity alone cannot
certify that the displayed conversation is current. The timeline owner publishes freshness; the
view renders it without a toast timer or a separate resume workflow.
The draft-create handoff has the same lifetime: the viewed-timeline owner releases it when the sync
stops owing that chat a catch-up, not when the first authoritative page lands. Releasing it at the
first page leaves a chat this client just created looking hydrated but still catching up, which
shows Updating messages for a conversation that cannot be out of date. Closing the tab ends the
obligation too, so a reopened chat starts from authoritative state instead of a stale optimistic
one; disconnect and backgrounding keep it.
Foregrounding probes a nominally connected session immediately. A healthy response preserves the
socket; a failed three-second probe starts reconnecting without waiting for the background heartbeat
or retry backoff. This cannot keep a mobile socket alive after the operating system suspends it.
- The same epoch and `window.maxSeq` is an exact display no-op. The app advances synchronization
bookkeeping without replacing timeline arrays, preserving an upward-scrolled viewport.
- When the page overlaps or is adjacent to the local end cursor, only projected items newer than
@@ -109,25 +88,6 @@ The installed tail carries `hasOlder`, so history skipped by a replacement remai
ordinary backward pagination. A backward page is accepted only when it is adjacent to the current
history start; a response requested from a pre-replacement range is stale and is discarded.
A plan approval keeps the original proposal's tool-call identity through resolution and provider
history replay. The pending approval UI can hide that tool from presentation, but the client model
must retain its position. Creating a new history card on rejection places it after the prompt that
rejected it; changing steer-event ordering would also put new assistant output before that prompt.
## Provider child history
Child transcripts use the same projected-page reconciliation as the main conversation. The client
retains rendered items and sequence cursors, never a second cache of source events. Pagination
uses the projected display anchor; live updates advance the source cursor without moving tools.
Clients advertising `projected_subagent_timeline` receive child streams and projected fetches.
Updated clients require `features.projectedSubagentTimeline` for child history; older hosts show
an update-host notice for that pane.
Older clients retain child names/status and receive an upgrade message when opening a child
conversation. This gate affects only the child transcript; it does not gate the connection,
main conversation, or current-context forks. A legacy `canonical` root request still receives
projected items.
## Client replica lifetime
The session projection remains host-scoped for as long as the host is registered. The viewed-timeline
@@ -135,11 +95,6 @@ owner wraps cached preparation, network catch-up, accepted timeline application,
behind one interface. React supplies transport and projection operations without selecting a cache
path or issuing a separate persistence notification.
Active-agent list reconciliation does not own transcript lifetime. An archived agent's fresh
history response can arrive before the active-list response that omits it. Clearing history there
would discard accepted rows while the viewed owner still considers them synchronized. Entity
deletion clears the transcript; list membership changes do not.
Removing the host from the registry is the destructive boundary: it stops the runtime and clears the
session and host-scoped setup state together.
@@ -173,19 +128,14 @@ replica cache.
The app chooses one delivery policy from `server_info.features.selectiveAgentTimeline`:
- Selective daemons receive the chats this session has opened, plus any visible agent pane. A chat
enters that set the first time the user opens it and leaves when its tab closes or the session
ends, independently of mounted or retained React views: switching workspaces, evicting a retained
view, app backgrounding, and reconnect all preserve the demand. There is no recent-agent limit.
Restored workspace layout is a release signal, never a source. It carries a tab for every chat the
user has ever opened on the host, and a catch-up fetch resumes its agent on the daemon, so
subscribing from layout spawns a provider session per historical tab at every launch and stamps
every one of those workspaces as just used (see
[agent lifecycle](agent-lifecycle.md#workspace-activity)). Visible chats get the first catch-up
attempt; the rest follow when those attempts settle, including failures, so a failed visible chat
does not starve background recovery. Split panes catch up together. Hidden chats update the
replica; on web their retained presentation stays suspended until revealed, on native it keeps
rendering. Revealing a chat reads the current store and preserves its local UI state.
- Selective daemons receive every agent visible in any pane plus the most recently viewed hidden
agents, up to five subscribed agents. Visible agents always win: if more than five are visible,
they all remain subscribed and no hidden agent does. Switching and app backgrounding preserve
this connection-scoped hot set, so returning to an agent still covered by it needs no catch-up.
Losing window keyboard focus does not make a selected pane invisible. Disconnecting clears hidden
hot agents; reconnect restores the currently visible set before authoritative catch-up. Revisiting
an evicted retained timeline displays its cached state immediately while authoritative catch-up
advances it to the current tail.
- Legacy daemons keep globally streaming agent timelines. Visibility still triggers the existing
authoritative catch-up, but the app does not issue selective-subscription RPCs.
@@ -1,7 +0,0 @@
Added
- Added OpenCode v2 support, selected automatically from the installed opencode version
- Added task lists from rpiv-todo, pi-goal-x, and Pi's example todo extension to Pi chats
- Added subagent runs from pi-subagents, Tintinweb pi-subagents, and Gotgenes pi-subagents to the Subagents track
- Added rpiv-ask-user-question dialogs to Pi chats as one question form
Full notes: https://paseo.sh/changelog
@@ -1,7 +0,0 @@
Added
- Added OpenCode v2 support, selected automatically from the installed opencode version
- Added task lists from rpiv-todo, pi-goal-x, and Pi's example todo extension to Pi chats
- Added subagent runs from pi-subagents, Tintinweb pi-subagents, and Gotgenes pi-subagents to the Subagents track
- Added rpiv-ask-user-question dialogs to Pi chats as one question form
Full notes: https://paseo.sh/changelog
@@ -1,7 +0,0 @@
Added
- Added OpenCode v2 support, selected automatically from the installed opencode version
- Added task lists from rpiv-todo, pi-goal-x, and Pi's example todo extension to Pi chats
- Added subagent runs from pi-subagents, Tintinweb pi-subagents, and Gotgenes pi-subagents to the Subagents track
- Added rpiv-ask-user-question dialogs to Pi chats as one question form
Full notes: https://paseo.sh/changelog
@@ -1,7 +0,0 @@
Added
- Added OpenCode v2 support, selected automatically from the installed opencode version
- Added task lists from rpiv-todo, pi-goal-x, and Pi's example todo extension to Pi chats
- Added subagent runs from pi-subagents, Tintinweb pi-subagents, and Gotgenes pi-subagents to the Subagents track
- Added rpiv-ask-user-question dialogs to Pi chats as one question form
Full notes: https://paseo.sh/changelog
@@ -1,5 +0,0 @@
Paseo 0.8 adds plugin header buttons, custom providers, and richer chat components, alongside fixes for desktop updates and mobile keyboards.
Before upgrading: the macOS desktop app now requires macOS 13 or newer; Linux and Windows support is unchanged. Plugins written for 0.7 need the 0.8 migration guide, including separate client/server entries and the revised composer pill API.
Full notes: https://paseo.sh/changelog
@@ -1,5 +0,0 @@
Paseo 0.8 adds plugin header buttons, custom providers, and richer chat components, alongside fixes for desktop updates and mobile keyboards.
Before upgrading: the macOS desktop app now requires macOS 13 or newer; Linux and Windows support is unchanged. Plugins written for 0.7 need the 0.8 migration guide, including separate client/server entries and the revised composer pill API.
Full notes: https://paseo.sh/changelog
@@ -1,5 +0,0 @@
Paseo 0.8 adds plugin header buttons, custom providers, and richer chat components, alongside fixes for desktop updates and mobile keyboards.
Before upgrading: the macOS desktop app now requires macOS 13 or newer; Linux and Windows support is unchanged. Plugins written for 0.7 need the 0.8 migration guide, including separate client/server entries and the revised composer pill API.
Full notes: https://paseo.sh/changelog
@@ -1,5 +0,0 @@
Paseo 0.8 adds plugin header buttons, custom providers, and richer chat components, alongside fixes for desktop updates and mobile keyboards.
Before upgrading: the macOS desktop app now requires macOS 13 or newer; Linux and Windows support is unchanged. Plugins written for 0.7 need the 0.8 migration guide, including separate client/server entries and the revised composer pill API.
Full notes: https://paseo.sh/changelog
@@ -1,9 +0,0 @@
Added
- Added Cmd/Ctrl+F Find to file panes, with replacement in editable files
- Added Cmd/Ctrl+F Find to terminal scrollback
- Added Cmd/Ctrl+F Find to chat, including messages outside the loaded history window
- Added Jump to file to mobile Changes, opening the changed-files tree in a sheet
- Added automatic Pull request tab opening once per workspace when a PR is detected
- Added expandable plan cards, with rejected plans collapsed by default
Full notes: https://paseo.sh/changelog
@@ -1,9 +0,0 @@
Added
- Added Cmd/Ctrl+F Find to file panes, with replacement in editable files
- Added Cmd/Ctrl+F Find to terminal scrollback
- Added Cmd/Ctrl+F Find to chat, including messages outside the loaded history window
- Added Jump to file to mobile Changes, opening the changed-files tree in a sheet
- Added automatic Pull request tab opening once per workspace when a PR is detected
- Added expandable plan cards, with rejected plans collapsed by default
Full notes: https://paseo.sh/changelog
@@ -1,9 +0,0 @@
Added
- Added Cmd/Ctrl+F Find to file panes, with replacement in editable files
- Added Cmd/Ctrl+F Find to terminal scrollback
- Added Cmd/Ctrl+F Find to chat, including messages outside the loaded history window
- Added Jump to file to mobile Changes, opening the changed-files tree in a sheet
- Added automatic Pull request tab opening once per workspace when a PR is detected
- Added expandable plan cards, with rejected plans collapsed by default
Full notes: https://paseo.sh/changelog
@@ -1,9 +0,0 @@
Added
- Added Cmd/Ctrl+F Find to file panes, with replacement in editable files
- Added Cmd/Ctrl+F Find to terminal scrollback
- Added Cmd/Ctrl+F Find to chat, including messages outside the loaded history window
- Added Jump to file to mobile Changes, opening the changed-files tree in a sheet
- Added automatic Pull request tab opening once per workspace when a PR is detected
- Added expandable plan cards, with rejected plans collapsed by default
Full notes: https://paseo.sh/changelog
@@ -1,8 +0,0 @@
Added
- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer
Fixed
- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon
- Fixed Import session offering only the newest 100 Codex conversations
Full notes: https://paseo.sh/changelog
@@ -1,8 +0,0 @@
Added
- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer
Fixed
- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon
- Fixed Import session offering only the newest 100 Codex conversations
Full notes: https://paseo.sh/changelog
@@ -1,8 +0,0 @@
Added
- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer
Fixed
- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon
- Fixed Import session offering only the newest 100 Codex conversations
Full notes: https://paseo.sh/changelog
@@ -1,8 +0,0 @@
Added
- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer
Fixed
- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon
- Fixed Import session offering only the newest 100 Codex conversations
Full notes: https://paseo.sh/changelog
@@ -1,12 +0,0 @@
Added
- Added structured Claude Code launch arguments for session configuration and plugins
Improved
- Reduced background Git polling for repositories the watcher cannot observe
- Reduced Add Project directory search time on large home directories
Fixed
- Fixed the daemon becoming unresponsive when ignored directories appear after a workspace opens
- Fixed daemon memory growing after client connections close (9978988)
Full notes: https://paseo.sh/changelog
@@ -1,12 +0,0 @@
Added
- Added structured Claude Code launch arguments for session configuration and plugins
Improved
- Reduced background Git polling for repositories the watcher cannot observe
- Reduced Add Project directory search time on large home directories
Fixed
- Fixed the daemon becoming unresponsive when ignored directories appear after a workspace opens
- Fixed daemon memory growing after client connections close (9978988)
Full notes: https://paseo.sh/changelog
@@ -1,12 +0,0 @@
Added
- Added structured Claude Code launch arguments for session configuration and plugins
Improved
- Reduced background Git polling for repositories the watcher cannot observe
- Reduced Add Project directory search time on large home directories
Fixed
- Fixed the daemon becoming unresponsive when ignored directories appear after a workspace opens
- Fixed daemon memory growing after client connections close (9978988)
Full notes: https://paseo.sh/changelog
@@ -1,12 +0,0 @@
Added
- Added structured Claude Code launch arguments for session configuration and plugins
Improved
- Reduced background Git polling for repositories the watcher cannot observe
- Reduced Add Project directory search time on large home directories
Fixed
- Fixed the daemon becoming unresponsive when ignored directories appear after a workspace opens
- Fixed daemon memory growing after client connections close (9978988)
Full notes: https://paseo.sh/changelog
-4
View File
@@ -1,10 +1,6 @@
{
"$schema": "./node_modules/knip/schema.json",
"workspaces": {
"plugins": {
"entry": ["**/index.{server,client}.{ts,tsx}", "**/*.test.ts"],
"project": ["**/*.{ts,tsx}"]
},
".": {
"entry": ["scripts/**/*.{js,mjs,cjs,ts}"],
"project": ["scripts/**/*.{js,mjs,cjs,ts}"]
+1 -1
View File
@@ -1 +1 @@
sha256-pVwIsufl/4VVcTiqJYbqy4hNnL2O5EBpl5DN5As+20c=
sha256-8P7kk9B2A81e13IIyyfo8fIoNGlrutfV/WGKAFHscUc=
+27 -314
View File
@@ -1,16 +1,15 @@
{
"name": "paseo",
"version": "0.10.0",
"version": "0.8.0-beta.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.10.0",
"version": "0.8.0-beta.1",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
"plugins",
"packages/expo-two-way-audio",
"packages/highlight",
"packages/plugin",
@@ -6334,10 +6333,6 @@
"resolved": "packages/app",
"link": true
},
"node_modules/@getpaseo/builtin-plugins": {
"resolved": "plugins",
"link": true
},
"node_modules/@getpaseo/cli": {
"resolved": "packages/cli",
"link": true
@@ -8276,84 +8271,6 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz",
"integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz",
"integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz",
"integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz",
"integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz",
"integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz",
"integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz",
@@ -8727,48 +8644,6 @@
"node": ">=20.0"
}
},
"node_modules/@opencode/client": {
"version": "2.0.10",
"resolved": "https://registry.npmjs.org/@opencode/client/-/client-2.0.10.tgz",
"integrity": "sha512-Pz4FD6jVtfZYX97AghVTM+cedi3HfI+U6gg/we5Luf08L85TewKsSXR4jHOZTCvggl0vACTquz1/hOjsJWE0Sg==",
"license": "MIT",
"dependencies": {
"@opencode/protocol": "2.0.10",
"@opencode/schema": "2.0.10"
},
"peerDependencies": {
"effect": "4.0.0-rc.112",
"solid-js": ">=1.9.0"
},
"peerDependenciesMeta": {
"effect": {
"optional": true
},
"solid-js": {
"optional": true
}
}
},
"node_modules/@opencode/protocol": {
"version": "2.0.10",
"resolved": "https://registry.npmjs.org/@opencode/protocol/-/protocol-2.0.10.tgz",
"integrity": "sha512-cHr0aSK39Q9c3JwPDvG45FFlE6rU/ntTFDSaqm4LRl74tki88G4WpzKk6Q56G06K6hKIONrezcPdXtDKtnZzyw==",
"license": "MIT",
"dependencies": {
"@opencode/schema": "2.0.10",
"effect": "4.0.0-rc.112"
}
},
"node_modules/@opencode/schema": {
"version": "2.0.10",
"resolved": "https://registry.npmjs.org/@opencode/schema/-/schema-2.0.10.tgz",
"integrity": "sha512-RpRhRZFH1YFnM+OcrxOZc2ObIW3RQ63z/LkcHrZKa47kWF0OiaZVkbAOITeAoAoofcnS3O5ekZBvgT/7kBwRUA==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "1.1.0",
"effect": "4.0.0-rc.112"
}
},
"node_modules/@opentelemetry/api": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
@@ -18747,16 +18622,6 @@
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/effect": {
"version": "4.0.0-rc.112",
"resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-rc.112.tgz",
"integrity": "sha512-wXxwuh1Ywnv4cPRM3Wfa0vDwuOHnZ1TsTgHJkG9XgzND6inhBH9n1vBxhg3iIXOia/OrpmvVmd3lrD4vq6bF3A==",
"license": "MIT",
"dependencies": {
"fast-check": "^4.9.0",
"msgpackr": "^2.0.5"
}
},
"node_modules/ejs": {
"version": "3.1.10",
"resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
@@ -21273,16 +21138,6 @@
"node": ">=10"
}
},
"node_modules/expo-screen-orientation": {
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/expo-screen-orientation/-/expo-screen-orientation-9.0.9.tgz",
"integrity": "sha512-UTU745XoqBvQJkZ820GTfMuOxlkppYqaeQ+x0wdu44cyrZiZugqe+egFF2+zC+SaxgAltU5EuO8GIj9xSF+YMw==",
"license": "MIT",
"peerDependencies": {
"expo": "*",
"react-native": "*"
}
},
"node_modules/expo-server": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.5.tgz",
@@ -22011,28 +21866,6 @@
"node": ">=18"
}
},
"node_modules/fast-check": {
"version": "4.10.0",
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.10.0.tgz",
"integrity": "sha512-hhqQL+IJllZi3aM4TKvmCj3bywLEcycNTTLZeLhA9ttMxBrCqM07q7Di4kl+j9EWSTXvJH1+EpIgsDbF/+8H5Q==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/dubzzz"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fast-check"
}
],
"license": "MIT",
"dependencies": {
"pure-rand": "^8.0.0"
},
"engines": {
"node": ">=12.17.0"
}
},
"node_modules/fast-copy": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.2.tgz",
@@ -28308,37 +28141,6 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/msgpackr": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.1.0.tgz",
"integrity": "sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ==",
"license": "MIT",
"optionalDependencies": {
"msgpackr-extract": "^3.0.4"
}
},
"node_modules/msgpackr-extract": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz",
"integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"node-gyp-build-optional-packages": "5.2.2"
},
"bin": {
"download-msgpackr-prebuilds": "bin/download-prebuilds.js"
},
"optionalDependencies": {
"@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4",
"@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4",
"@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4",
"@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4",
"@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4",
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4"
}
},
"node_modules/multipasta": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.7.tgz",
@@ -28664,21 +28466,6 @@
"node-gyp-build-test": "build-test.js"
}
},
"node_modules/node-gyp-build-optional-packages": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
"integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
"license": "MIT",
"optional": true,
"dependencies": {
"detect-libc": "^2.0.1"
},
"bin": {
"node-gyp-build-optional-packages": "bin.js",
"node-gyp-build-optional-packages-optional": "optional.js",
"node-gyp-build-optional-packages-test": "build-test.js"
}
},
"node_modules/node-gyp/node_modules/chownr": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
@@ -30354,22 +30141,6 @@
"node": ">=6"
}
},
"node_modules/pure-rand": {
"version": "8.4.2",
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz",
"integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/dubzzz"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fast-check"
}
],
"license": "MIT"
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
@@ -36425,7 +36196,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.10.0",
"version": "0.8.0-beta.1",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -36483,7 +36254,6 @@
"expo-localization": "~17.0.9",
"expo-notifications": "^0.32.16",
"expo-router": "~6.0.13",
"expo-screen-orientation": "~9.0.9",
"expo-sharing": "^14.0.8",
"expo-splash-screen": "~31.0.10",
"expo-sqlite": "~16.0.10",
@@ -36498,7 +36268,6 @@
"qrcode": "^1.5.4",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-freeze": "1.0.4",
"react-i18next": "^17.0.8",
"react-native": "0.81.5",
"react-native-draggable-flatlist": "^4.0.3",
@@ -37492,12 +37261,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.10.0",
"version": "0.8.0-beta.1",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.10.0",
"@getpaseo/protocol": "0.10.0",
"@getpaseo/server": "0.10.0",
"@getpaseo/client": "0.8.0-beta.1",
"@getpaseo/protocol": "0.8.0-beta.1",
"@getpaseo/server": "0.8.0-beta.1",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -37744,10 +37513,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.10.0",
"version": "0.8.0-beta.1",
"dependencies": {
"@getpaseo/protocol": "0.10.0",
"@getpaseo/relay": "0.10.0",
"@getpaseo/protocol": "0.8.0-beta.1",
"@getpaseo/relay": "0.8.0-beta.1",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -37758,7 +37527,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.10.0",
"version": "0.8.0-beta.1",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -37769,7 +37538,6 @@
"zod": "^4.4.3"
},
"devDependencies": {
"@electron/asar": "3.4.1",
"@types/node": "24.6.0",
"@types/ws": "^8.5.14",
"electron": "44.2.0",
@@ -38002,7 +37770,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.10.0",
"version": "0.8.0-beta.1",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -38898,7 +38666,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.10.0",
"version": "0.8.0-beta.1",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -38927,14 +38695,14 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.10.0",
"version": "0.8.0-beta.1",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.10.0",
"@getpaseo/protocol": "0.10.0",
"@getpaseo/client": "0.8.0-beta.1",
"@getpaseo/protocol": "0.8.0-beta.1",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.1.0",
@@ -38944,8 +38712,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.10.0",
"@getpaseo/protocol": "0.10.0",
"@getpaseo/client": "0.8.0-beta.1",
"@getpaseo/protocol": "0.8.0-beta.1",
"react": "~19.1.0",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
@@ -38967,7 +38735,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.10.0",
"version": "0.8.0-beta.1",
"dependencies": {
"semver": "^7.8.5",
"zod": "^4.4.3"
@@ -38994,7 +38762,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.10.0",
"version": "0.8.0-beta.1",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -39212,20 +38980,19 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.10.0",
"version": "0.8.0-beta.1",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.10.0",
"@getpaseo/highlight": "0.10.0",
"@getpaseo/plugin": "0.10.0",
"@getpaseo/protocol": "0.10.0",
"@getpaseo/relay": "0.10.0",
"@getpaseo/client": "0.8.0-beta.1",
"@getpaseo/highlight": "0.8.0-beta.1",
"@getpaseo/plugin": "0.8.0-beta.1",
"@getpaseo/protocol": "0.8.0-beta.1",
"@getpaseo/relay": "0.8.0-beta.1",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
"@opencode/client": "2.0.10",
"@xterm/headless": "^6.0.0",
"ai": "5.0.78",
"ajv": "^8.20.0",
@@ -39235,7 +39002,6 @@
"express": "^4.18.2",
"fast-deep-equal": "^3.1.3",
"lru-cache": "^11.5.1",
"markdown-it": "^10.0.0",
"mnemonic-id": "^3.2.7",
"node-pty": "1.2.0-beta.15",
"openai": "^6.44.0",
@@ -39246,7 +39012,6 @@
"pino-pretty": "^13.1.3",
"qrcode": "^1.5.4",
"rotating-file-stream": "^3.2.9",
"semver": "^7.8.5",
"sherpa-onnx-node": "1.12.28",
"strip-ansi": "^7.1.2",
"tree-kill": "^1.2.2",
@@ -39254,13 +39019,11 @@
"uuid": "^9.0.1",
"which": "^5.0.0",
"ws": "^8.14.2",
"yaml": "2.9.1",
"zod": "^4.4.3"
},
"devDependencies": {
"@playwright/test": "^1.56.1",
"@types/express": "^4.17.20",
"@types/markdown-it": "^14.2.0",
"@types/node": "^20.9.0",
"@types/qrcode": "^1.5.6",
"@types/uuid": "^9.0.7",
@@ -39705,17 +39468,6 @@
"cross-spawn": "7.0.6"
}
},
"packages/server/node_modules/@types/markdown-it": {
"version": "14.2.0",
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.2.0.tgz",
"integrity": "sha512-NoQ2yGlLWj4wpxMs+TYmRKk3thDrQ97agr7sFqfLsAlvoS8SNQuTrlObhFqG9iugdTtgOE9jpJ6FNM4ZGsa5xQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/linkify-it": "^5",
"@types/mdurl": "^2"
}
},
"packages/server/node_modules/@vitest/expect": {
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.6.tgz",
@@ -39954,18 +39706,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"packages/server/node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"packages/server/node_modules/strip-ansi": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
@@ -40113,21 +39853,6 @@
"node": "^18.17.0 || >=20.5.0"
}
},
"packages/server/node_modules/yaml": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz",
"integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"packages/server/node_modules/yocto-queue": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz",
@@ -40142,7 +39867,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.10.0",
"version": "0.8.0-beta.1",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
@@ -41744,18 +41469,6 @@
"optional": true
}
}
},
"plugins": {
"name": "@getpaseo/builtin-plugins",
"version": "0.0.0",
"dependencies": {
"@getpaseo/plugin": "*",
"zod": "^4.4.3"
},
"devDependencies": {
"@types/node": "^20.9.0",
"vitest": "^4.1.6"
}
}
}
}
+2 -3
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.10.0",
"version": "0.8.0-beta.1",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -21,7 +21,6 @@
"url": "https://github.com/getpaseo/paseo.git"
},
"workspaces": [
"plugins",
"packages/expo-two-way-audio",
"packages/highlight",
"packages/plugin",
@@ -67,7 +66,7 @@
"watch:protocol": "npm run watch --workspace=@getpaseo/protocol",
"watch:client": "tsc -p packages/client/tsconfig.json --watch --preserveWatchOutput",
"typecheck": "npm run typecheck --workspaces --if-present",
"typecheck:server": "npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/protocol && npm run typecheck --workspace=@getpaseo/client && npm run typecheck --workspace=@getpaseo/builtin-plugins && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli",
"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",
"test": "npm run test --workspaces --if-present",
"format": "oxfmt .",
"format:files": "oxfmt",
+1 -12
View File
@@ -3,10 +3,8 @@ const path = require("node:path");
const pkg = require("./package.json");
const withAndroidAsyncStorageSize = require("./plugins/with-android-async-storage-size");
const withAndroidProfileable = require("./plugins/with-android-profileable");
const withAndroidFoldConfiguration = require("./plugins/with-android-fold-configuration");
const withFdroidAutolinking = require("./plugins/with-fdroid-autolinking");
const withPasteInput = require("./plugins/with-paste-input");
const withAndroidScroll = require("./modules/paseo-scroll/app.plugin");
const { getNativeReleaseVersion } = require("./native-release-version");
const appVariant = process.env.APP_VARIANT ?? "production";
const isFdroidBuild = process.env.PASEO_FDROID_BUILD === "1";
@@ -101,8 +99,7 @@ export default {
name: variant.name,
slug: "voice-mobile",
version: nativeReleaseVersion.appVersion,
// Android chooses its full window before the runtime phone/fold/tablet policy applies.
orientation: "default",
orientation: "portrait",
icon: "./assets/images/icon.png",
scheme: "paseo",
userInterfaceStyle: "automatic",
@@ -110,12 +107,6 @@ export default {
ios: {
supportsTablet: true,
infoPlist: {
// Keep the existing iPhone restriction while the iPad orientation key
// generated by Expo continues to permit rotation.
UISupportedInterfaceOrientations: [
"UIInterfaceOrientationPortrait",
"UIInterfaceOrientationPortraitUpsideDown",
],
NSMicrophoneUsageDescription: "This app needs access to the microphone for voice commands.",
ITSAppUsesNonExemptEncryption: false,
},
@@ -150,8 +141,6 @@ export default {
plugins: [
"expo-router",
withPasteInput,
withAndroidFoldConfiguration,
withAndroidScroll,
[withAndroidAsyncStorageSize, 64],
...buildProfile.cameraPlugins,
[
@@ -36,18 +36,14 @@ import { delayBrowserAgentCreatedStatus } from "../support/helpers/new-workspace
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import { gotoAppShell, openSettings, selectModel } from "../support/helpers/app";
import { observeTimelineSubscriptions } from "../support/helpers/timeline-delivery";
import { rememberTimelineRequestCounts } from "../support/helpers/timeline-resume";
import {
recordPanelToasts,
switchWorkspaceViaSidebar,
expectResumeOverflowFallsBackToOneTail,
rememberTimelineRequestCounts,
} from "../support/helpers/timeline-resume";
import {
waitForWorkspaceInSidebar,
workspaceDeckEntryLocator,
} from "../support/helpers/workspace-ui";
import {
openSessions,
clickSessionRow,
expectWorkspaceTabVisible,
} from "../support/helpers/archive-tab";
import { expectInFlightForkAvailable } from "../support/helpers/assistant-fork";
import {
scrollTimelineToNewestLoadedEdge,
@@ -432,18 +428,6 @@ async function expectInterruptedTurnOrderAfterReconnect(
}
}
async function visitEvictionWorkspaces(
page: Page,
agents: readonly { agentId: string }[],
): Promise<void> {
for (const [index, agent] of agents.entries()) {
await openSessions(page);
await clickSessionRow(page, `Workspace eviction ${index + 1}.`);
await expectWorkspaceTabVisible(page, agent.agentId);
await expectComposerVisible(page);
}
}
async function expectHiddenStreamingSubmissionOrderAfterWorkspaceEviction(
page: Page,
testInfo: { workerIndex: number },
@@ -459,76 +443,50 @@ async function expectHiddenStreamingSubmissionOrderAfterWorkspaceEviction(
Array.from({ length: WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES }, (_unused, index) =>
seedMockAgentWorkspace({
repoPrefix: `submission-workspace-eviction-${testInfo.workerIndex}-${index}-`,
title: `Workspace eviction ${index + 1}.`,
title: `Workspace eviction ${index + 1}`,
}),
),
);
const prompt = "Keep this hidden image prompt before its streaming output.";
const targetDeckEntry = workspaceDeckEntryLocator(page, getServerId(), target.workspaceId);
const openAgentIds = [target.agentId, ...evictionAgents.map((agent) => agent.agentId)];
try {
await openAgentRoute(page, target);
await expectComposerVisible(page);
// Open every chat before holding output. Adding chats replaces the timeline
// subscription and retires the IDs stamped on already-buffered frames.
await visitEvictionWorkspaces(page, evictionAgents);
await subscriptions.waitForSubscribedAgents(openAgentIds);
await switchWorkspaceViaSidebar({
page,
serverId: getServerId(),
workspaceId: target.workspaceId,
});
await expectWorkspaceTabVisible(page, target.agentId);
await expectComposerVisible(page);
const subscriptionRequests = gate.getClientRequestCount(
"agent.timeline.set_subscription.request",
);
await subscriptions.waitForSubscribedAgents([target.agentId]);
const userMessageCount = gate.getAgentStreamItemCount("user_message");
gate.setAgentStreamItemSuppressed("user_message", true);
gate.holdNextAgentStreamEvent("turn_started");
gate.setAgentStreamSuppressed(true);
const promptRow = await submitMessageWithImage(page, prompt);
await gate.waitForAgentStreamItem("user_message", userMessageCount + 1);
await gate.waitForHeldAgentStreamEvent("turn_started");
// Finish production before navigation so passing cannot depend on late
// chunks arriving after the final workspace switch.
await target.client.waitForFinish(target.agentId, 30_000);
// Navigate inside the app: a document reload discards the retained deck and
// adds startup history fetches, so it cannot prove eviction/resume behavior.
await visitEvictionWorkspaces(page, evictionAgents);
for (const evictionAgent of evictionAgents) {
await openAgentRoute(page, evictionAgent);
await expectComposerVisible(page);
}
await expect(targetDeckEntry).toHaveCount(0);
await subscriptions.waitForSubscribedAgents(openAgentIds);
expect(gate.getClientRequestCount("agent.timeline.set_subscription.request")).toBe(
subscriptionRequests,
);
gate.releaseHeldAgentStreamEvent("turn_started");
const requestsBeforeReturn = rememberTimelineRequestCounts(gate, target.agentId);
await subscriptions.waitForSubscribedAgents([
evictionAgents[WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES - 1]!.agentId,
]);
gate.setAgentStreamSuppressed(false);
await target.client.waitForFinish(target.agentId, 30_000);
const requestsBeforeReturn = rememberTimelineRequestCounts(gate);
await waitForWorkspaceInSidebar(page, {
serverId: getServerId(),
workspaceId: target.workspaceId,
});
await switchWorkspaceViaSidebar({
page,
serverId: getServerId(),
workspaceId: target.workspaceId,
});
await openAgentRoute(page, target);
await expectComposerVisible(page);
await subscriptions.waitForSubscribedAgents(openAgentIds);
await subscriptions.waitForSubscribedAgents([target.agentId]);
const responseStart = page.getByText("Cycle 1", { exact: true });
const response = page.getByText("(end of synthetic stream)", { exact: true }).last();
await expect(promptRow).toBeVisible();
await expect(responseStart).toBeVisible();
await expect(response).toBeVisible();
await expectRenderedBefore(promptRow, responseStart);
await expectRenderedBefore(promptRow, response);
// Open chats stay subscribed when their workspace view is evicted. Returning
// uses that live timeline without another resume check or startup tail fetch.
expect(rememberTimelineRequestCounts(gate, target.agentId)).toEqual(requestsBeforeReturn);
expectResumeOverflowFallsBackToOneTail(gate, requestsBeforeReturn);
} finally {
gate.setAgentStreamItemSuppressed("user_message", false);
gate.setAgentStreamSuppressed(false);
gate.restore();
await Promise.all([...evictionAgents.map((agent) => agent.cleanup()), target.cleanup()]);
}
@@ -910,7 +868,7 @@ test.describe("Agent message submission", () => {
}
});
test("keeps canonical prompts through early echoes, reload, and later turns", async ({
test("keeps one canonical prompt when the provider echoes before accepting", async ({
page,
}, testInfo) => {
const workspace = await seedWorkspace({
@@ -931,20 +889,6 @@ test.describe("Agent message submission", () => {
await expect(submittedPrompt).toHaveCount(1);
await expect(submittedPrompt).toHaveAttribute("aria-busy", "false");
await expectVisibleAgentSurfacesIdle(page);
for (const [index, nextPrompt] of [
"emit 1 coalesced agent stream updates for the second turn.",
"emit 1 coalesced agent stream updates for the third turn.",
].entries()) {
await submitMessage(page, nextPrompt);
await expect(page.getByText(nextPrompt, { exact: true })).toBeVisible();
await expectVisibleAgentSurfacesIdle(page);
await expectComposerEditable(page);
await expect(page.getByTestId("user-message")).toHaveCount(index + 2);
}
await fillComposerDraft(page, "Keep this unsent draft.");
await composerLocator(page).blur();
await expect(page.getByTestId("user-message")).toHaveCount(3);
await expectComposerEditable(page);
} finally {
await workspace.cleanup();
}
@@ -964,12 +908,16 @@ test.describe("Agent message submission", () => {
await submitMessage(page, prompt);
const submittedPrompt = page.getByTestId("user-message").filter({ hasText: prompt });
await expect(submittedPrompt).toHaveAttribute("aria-busy", "false", { timeout: 30_000 });
const marker = `late-provider-identity-${Date.now()}`;
await submittedPrompt.evaluate((element, value) => {
element.dataset.lateProviderIdentity = value;
}, marker);
await submittedPrompt.hover();
await expect(submittedPrompt.getByTestId("rewind-menu-trigger")).toBeVisible({
timeout: 5_000,
});
await expect(submittedPrompt).toHaveCount(1);
await expect(page.locator(`[data-late-provider-identity="${marker}"]`)).toBeVisible();
await page.getByRole("button", { name: "Stop agent", exact: true }).click();
await expectVisibleAgentSurfacesIdle(page);
} finally {
@@ -1166,11 +1114,8 @@ test.describe("Agent message submission", () => {
draftCreateScenario,
}) => {
test.setTimeout(120_000);
const toasts = await recordPanelToasts(page);
const pending = await beginDraftCreateSubmission(page, draftCreateScenario);
await completeDraftCreateSubmission(page, draftCreateScenario, pending);
// A chat this client just created is current by construction; it is never out of date.
await toasts.expectNeverShown("agent-updating-toast");
});
test("restores a rejected submission and accepts its retry", async ({
@@ -9,7 +9,7 @@ import {
seedModelProvider,
} from "../support/helpers/agent-profiles";
import { gotoAppShell } from "../support/helpers/app";
import { captureWorkspaceAgentRequest } from "../support/helpers/creation";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import {
openGlobalNewWorkspaceComposer,
selectNewWorkspaceProject,
@@ -22,6 +22,53 @@ const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences";
const MODELESS_PROVIDER = "modeless-profile-e2e";
const MODELESS_MODEL = "pi-profile-model";
type WebSocketMessage = string | Buffer;
interface CreateAgentRequestMessage {
type: "create_agent_request";
config?: {
provider?: unknown;
modeId?: unknown;
featureValues?: unknown;
};
}
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
try {
const envelope = JSON.parse(rawMessage) as { type?: unknown; message?: unknown };
return envelope.type === "session" && envelope.message && typeof envelope.message === "object"
? (envelope.message as Record<string, unknown>)
: null;
} catch {
return null;
}
}
async function recordAndBlockCreateAgentRequest(page: Page): Promise<{
waitForRequest(): Promise<CreateAgentRequestMessage>;
}> {
let resolveRequest: ((message: CreateAgentRequestMessage) => void) | null = null;
const request = new Promise<CreateAgentRequestMessage>((resolve) => {
resolveRequest = resolve;
});
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
if (sessionMessage?.type === "create_agent_request") {
resolveRequest?.(sessionMessage as unknown as CreateAgentRequestMessage);
return;
}
server.send(message);
});
server.onMessage((message) => ws.send(message));
});
return { waitForRequest: () => request };
}
async function seedPoisonedModelessPreference(page: Page): Promise<void> {
await page.addInitScript(
({ preferencesKey, provider, model }) => {
@@ -96,7 +143,7 @@ test.describe("Agent profiles repair modeless provider preferences", () => {
],
});
await seedPoisonedModelessPreference(page);
const createAgentRecorder = await captureWorkspaceAgentRequest(page, { block: true });
const createAgentRecorder = await recordAndBlockCreateAgentRequest(page);
try {
await gotoAppShell(page);
@@ -159,7 +206,7 @@ test.describe("Agent profiles repair modeless provider preferences", () => {
},
],
});
const createAgentRecorder = await captureWorkspaceAgentRequest(page, { block: true });
const createAgentRecorder = await recordAndBlockCreateAgentRequest(page);
try {
await gotoAppShell(page);
@@ -6,16 +6,13 @@ import {
expectAgentProfilesEmptyPrompt,
expectProfileEditTooltip,
expectComposerMode,
expectCreateProfileFromModelRow,
expectComposerModel,
expectModelRowProfileActionBesideRow,
expectModelRowSelected,
expectProfileEditIsPencilOnly,
expectProfileVisibleForProvider,
openModelPicker,
openAgentProfilesFromEmptyPrompt,
seedAgentProfiles,
selectModelRow,
} from "../support/helpers/agent-profiles";
import { expectWorkspaceAgentConfiguration } from "../support/helpers/command-center-agent-controls";
import { expectComposerVisible } from "../support/helpers/composer";
@@ -118,34 +115,4 @@ test.describe("Agent profiles in the model picker", () => {
await seed.restore();
}
});
test("a model row and its create-profile action are separate buttons", async ({ page }) => {
const seed = await seedAgentProfiles([]);
const workspace = await seedMockAgentWorkspace({
repoPrefix: "agent-profiles-row-action-",
title: "Agent profiles row action",
model: "ten-second-stream",
modeId: "load-test",
});
try {
const oneMinute = {
provider: "mock",
modelId: "one-minute-stream",
modelLabel: "One minute stream",
};
await openAgentRoute(page, workspace);
await expectComposerVisible(page);
await openModelPicker(page);
await expectModelRowProfileActionBesideRow(page, oneMinute);
await expectCreateProfileFromModelRow(page, oneMinute);
await expectComposerModel(page, "Ten second stream");
await openModelPicker(page);
await selectModelRow(page, oneMinute);
await expectComposerModel(page, oneMinute.modelLabel);
} finally {
await workspace.cleanup();
await seed.restore();
}
});
});
@@ -1,51 +0,0 @@
import { expect, test } from "../support/fixtures";
import { openCommandCenter } from "../support/helpers/command-center";
import { getServerId } from "../support/helpers/server-id";
import { seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { openAgentRoute } from "../support/helpers/mock-agent";
import { resetSeededPageState, openSessions } from "../support/helpers/archive-tab";
import { closeSidebarDisplayPreferences, openSidebarDisplayPage } from "../support/helpers/sidebar";
test("agent list age advances and command center says just now for a fresh agent", async ({
page,
}) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "agent-relative-time-",
title: "Relative time agent",
});
try {
await session.client.waitForAgentUpsert(session.agentId, (agent) => agent.status === "idle");
await resetSeededPageState(page);
await page.clock.install({ time: Date.now() });
await openSessions(page);
const row = page.getByTestId(`agent-row-${getServerId()}-${session.agentId}`);
await expect(row).toContainText("just now", { timeout: 30_000 });
const commandCenter = await openCommandCenter(page);
await commandCenter.getByTestId("command-center-input").fill("Relative time agent");
const agentResult = commandCenter.getByTestId(
`command-center-agent-${getServerId()}:${session.agentId}`,
);
await expect(agentResult.getByTestId("command-center-agent-subtitle")).toContainText(
"just now",
);
await page.keyboard.press("Escape");
await page.clock.fastForward("03:00");
await expect(row).toContainText("3m ago");
await openAgentRoute(page, session);
await page.getByTestId(`workspace-tab-agent_${session.agentId}`).first().hover();
await expect(page.getByTestId(`workspace-tab-tooltip-agent_${session.agentId}`)).toContainText(
"3m ago",
);
await openSidebarDisplayPage(page, "sidebar-display-show");
await page.getByRole("menuitem", { name: "Last activity", exact: true }).click();
await closeSidebarDisplayPreferences(page);
await expect(page.getByTestId("sidebar-workspace-timestamp").first()).toHaveText("3m");
} finally {
await session.cleanup();
}
});
@@ -88,12 +88,18 @@ test.describe("Agent stream UI", () => {
}
await page.evaluate((block) => {
const evidence = { addedNodes: 0, characterDataMutations: 0, removedNodes: 0 };
const evidence = {
addedNodes: 0,
characterDataMutations: 0,
removedNodes: 0,
};
const observer = new MutationObserver((records) => {
for (const record of records) {
evidence.addedNodes += record.addedNodes.length;
evidence.removedNodes += record.removedNodes.length;
if (record.type === "characterData") evidence.characterDataMutations += 1;
if (record.type === "characterData") {
evidence.characterDataMutations += 1;
}
}
});
observer.observe(block, { characterData: true, childList: true, subtree: true });
@@ -1,12 +1,5 @@
import { randomUUID } from "node:crypto";
import { submitMessage, expectComposerEditable } from "../support/helpers/composer";
import {
focusTerminalSurface,
typeInTerminal,
getTerminalBufferText,
} from "../support/helpers/terminal-perf";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
import { metroTest as test, expect } from "../support/fixtures";
import { metroTest as test } from "../support/fixtures";
import { buildCreateAgentPreferences, buildSeededHost } from "../support/helpers/daemon-registry";
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
import type { MockAgentWorkspace } from "../support/helpers/mock-agent";
@@ -24,19 +17,6 @@ test("does not repeat an assistant block when the current app paginates a publis
test.setTimeout(120_000);
const serverId = `srv_old_pagination_${randomUUID().replaceAll("-", "").slice(0, 12)}`;
const daemon = await startIsolatedHostDaemon(serverId, { publishedVersion: "0.2.5" });
const observationSockets = new Set<unknown>();
page.on("websocket", (socket) => {
socket.on("framesent", ({ payload }) => {
if (typeof payload !== "string") return;
const frame = JSON.parse(payload);
if (
frame.type === "session" &&
frame.message.type === "fetch_agents_request" &&
frame.message.subscribe
)
observationSockets.add(socket);
});
});
const workspace = await seedWorkspace({
repoPrefix: "timeline-old-daemon-pagination-",
port: daemon.port,
@@ -99,40 +79,6 @@ test("does not repeat an assistant block when the current app paginates a publis
history.expectRepeatedEntries();
await history.expectOwnedTextRendered("Now I have a clearer picture.");
expect(observationSockets.size).toBe(1);
await test.step("send a live turn after reconnecting the old daemon", async () => {
await daemon.restart();
await expect.poll(() => observationSockets.size, { timeout: 30_000 }).toBe(2);
await expectComposerEditable(page);
await submitMessage(
page,
"compatibility-after-reconnect: emit 1 coalesced agent stream updates",
);
await expectTimelinePromptVisible(
page,
"compatibility-after-reconnect: emit 1 coalesced agent stream updates",
);
// The browser and the seed client reconnect independently after the restart.
await agent.client.connect();
await agent.client.waitForFinish(agent.agentId, 20_000);
await page.screenshot({ path: test.info().outputPath("old-daemon-live-reconnect.png") });
});
await test.step("open a terminal and receive its output", async () => {
const terminal = await agent.client.createTerminal(agent.cwd, "Compatibility terminal");
expect(terminal.error).toBeNull();
const terminalId = terminal.terminal!.id;
const route = buildHostWorkspaceRoute(serverId, workspace.workspaceId);
await page.goto(`${route}?open=${encodeURIComponent(`terminal:${terminalId}`)}`);
await focusTerminalSurface(page);
await typeInTerminal(page, "printf 'compatibility-%s\\n' terminal-output\n");
await expect
.poll(() => getTerminalBufferText(page), { timeout: 15_000 })
.toContain("compatibility-terminal-output");
await page.screenshot({ path: test.info().outputPath("old-daemon-terminal.png") });
await agent.client.killTerminal(terminalId);
});
} finally {
await agent.cleanup();
await daemon.close();
@@ -1,10 +1,6 @@
import { expect, test } from "../support/fixtures";
import {
withSpokenTimeline,
captureSpokenTimeline,
reloadSpokenTimeline,
expectSpokenTimelinePrompt,
sendSpokenTimelinePrompt,
expectSameOlderHistoryLoadingOperation,
expectStableHistoryStartGutter,
expectTimelineAtHistoryStart,
expectTimelinePromptCentered,
@@ -16,6 +12,7 @@ import {
holdOlderHistoryPages,
makeLoadedTimelineFitViewport,
openAgentTimeline,
rememberOlderHistoryLoadingOperation,
rememberTimelineViewport,
rememberTimelinePromptPosition,
reloadAgentTimelineFromPersistedReplica,
@@ -29,28 +26,42 @@ import {
expectTimelineViewportAnchoredAfterPrepend,
userNavigatesTimelineToHistoryStartWithKeyboard,
userScrollsTimelineToHistoryStart,
waitForPersistedCanonicalTimelineRange,
} from "../support/helpers/timeline-pagination";
import { trackAgentTimelineRequests } from "../support/helpers/agent-timeline-gate";
import { seedMockAgentWorkspace } from "../support/helpers/mock-agent";
test.describe("Agent timeline pagination", () => {
test("shows spoken words without voice instructions in history, live updates and after reload", async ({
page,
}, testInfo) => {
const historyText = "Please check the voice history.";
const liveText = "Now check the live voice message.";
await withSpokenTimeline(historyText, async (agent) => {
await openAgentTimeline(page, agent);
await expectSpokenTimelinePrompt(page, historyText);
await sendSpokenTimelinePrompt(agent, liveText);
await expectSpokenTimelinePrompt(page, liveText);
await captureSpokenTimeline(page, testInfo, "desktop");
await reloadSpokenTimeline(page);
await expectSpokenTimelinePrompt(page, liveText);
await captureSpokenTimeline(page, testInfo, "compact");
await expectSpokenTimelinePrompt(page, liveText);
test("resumes a persisted canonical timeline after its exact end", async ({ page }) => {
const prompt = "timeline persisted range resumes without tail replay";
const agent = await seedMockAgentWorkspace({
repoPrefix: "timeline-persisted-range-",
title: "Persisted timeline range",
initialPrompt: prompt,
});
try {
await agent.client.waitForFinish(agent.agentId, 15_000);
await openAgentTimeline(page, agent);
await expectTimelinePromptVisible(page, prompt);
const range = await waitForPersistedCanonicalTimelineRange(page, agent.agentId);
const tracker = await trackAgentTimelineRequests(page, agent.agentId);
await page.reload();
await expectTimelinePromptVisible(page, prompt);
const request = await tracker.nextRequest();
expect(request).toEqual({
direction: "after",
cursor: { epoch: range.epoch, seq: range.endSeq },
});
await tracker.waitForResponse();
expect(tracker.requests().some((entry) => entry.direction === "tail")).toBe(false);
} finally {
await agent.cleanup();
}
});
test("keeps the history-start gutter and visible position stable through the final page", async ({
test("keeps a fixed history-start gutter before, during, and after pagination", async ({
page,
}) => {
const agent = await seedLongMockAgentTimeline({ turns: 40 });
@@ -62,10 +73,8 @@ test.describe("Agent timeline pagination", () => {
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(1);
await expectStableHistoryStartGutter(page);
const viewport = await rememberTimelineViewport(page);
history.releasePage(1);
await expectTimelineViewportAnchoredAfterPrepend(page, viewport);
await history.expectSettledWithRequestedPages(1);
await expectStableHistoryStartGutter(page);
} finally {
@@ -87,9 +96,7 @@ test.describe("Agent timeline pagination", () => {
await userScrollsTimelineToHistoryStart(page);
await expectTimelinePromptVisible(page, agent.initialTailOldestPrompt);
await history.expectRequestedPages(1);
const viewport = await rememberTimelineViewport(page);
history.releasePage(1);
await expectTimelineViewportAnchoredAfterPrepend(page, viewport);
await history.expectSettledWithRequestedPages(1);
await userScrollsTimelineToHistoryStart(page);
@@ -130,6 +137,23 @@ test.describe("Agent timeline pagination", () => {
}
});
test("keeps the visible timeline position anchored while prepending a page", async ({ page }) => {
test.setTimeout(120_000);
const agent = await seedLongMockAgentTimeline({ turns: 80 });
try {
const history = await holdOlderHistoryPages(page, agent);
await openAgentTimeline(page, agent);
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(1);
const viewport = await rememberTimelineViewport(page);
history.releasePage(1);
await expectTimelineViewportAnchoredAfterPrepend(page, viewport);
} finally {
await agent.cleanup();
}
});
test("keeps visible history anchored when live output grows during a prepend", async ({
page,
}) => {
@@ -157,8 +181,7 @@ test.describe("Agent timeline pagination", () => {
test("finishes loading an older page while live output continues", async ({ page }) => {
test.setTimeout(120_000);
// The live turn streams for thirty minutes, so it is still running at every assertion.
const agent = await seedLongMockAgentTimeline({ turns: 40, liveTurns: "thirty-minute-stream" });
const agent = await seedLongMockAgentTimeline({ turns: 40 });
try {
const history = await holdOlderHistoryPages(page, agent);
await openAgentTimeline(page, agent);
@@ -170,11 +193,8 @@ test.describe("Agent timeline pagination", () => {
agent.agentId,
(snapshot) => snapshot.status === "running",
);
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
await expect(timeline.getByText("walking through").first()).toBeAttached();
history.releasePage(1);
await expect(timeline.getByText(agent.newestOlderPagePrompt, { exact: true })).toBeAttached();
await expect(page.getByTestId("load-older-history-spinner")).toBeHidden({ timeout: 5_000 });
const running = await agent.client.fetchAgents({ scope: "active" });
expect(running.entries.find((entry) => entry.agent.id === agent.agentId)?.agent.status).toBe(
@@ -185,7 +205,28 @@ test.describe("Agent timeline pagination", () => {
}
});
test("keeps loading while older pages still leave history start exposed", async ({ page }) => {
test("keeps the visible timeline anchored when the final page finishes", async ({ page }) => {
test.setTimeout(120_000);
const agent = await seedLongMockAgentTimeline({ turns: 40 });
try {
const history = await holdOlderHistoryPages(page, agent);
await openAgentTimeline(page, agent);
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(1);
const position = await rememberTimelinePromptPosition(page, agent.initialTailOldestPrompt);
history.releasePage(1);
await expectTimelinePromptPositionPreserved(page, position);
await history.expectSettledWithRequestedPages(1);
} finally {
await agent.cleanup();
}
});
test("continues one loading operation while older pages still leave history start exposed", async ({
page,
}) => {
test.setTimeout(120_000);
const agent = await seedLongMockAgentTimeline({ turns: 80 });
try {
@@ -194,12 +235,12 @@ test.describe("Agent timeline pagination", () => {
await openAgentTimeline(page, agent);
await scrollTimelineToOldestLoadedEdge(page);
await history.expectRequestedPages(1);
await expect(page.getByTestId("load-older-history-spinner")).toBeVisible();
const loading = await rememberOlderHistoryLoadingOperation(page);
history.releasePage(1);
await history.expectRequestedPages(2);
await expectTimelineAtHistoryStart(page);
await expect(page.getByTestId("load-older-history-spinner")).toBeVisible();
await expectSameOlderHistoryLoadingOperation(page, loading);
} finally {
await agent.cleanup();
}
@@ -84,7 +84,6 @@ test.describe("Assistant fork menu", () => {
await forkInFlightTurnToNewTab(page);
await expectChatHistoryAttachment(page);
expect(await forkAttachment.waitForText()).toContain(visibleBeforeFork);
await expect(page.getByRole("button", { name: "Menu backdrop", exact: true })).toHaveCount(0);
await page.getByRole("button", { name: sourceAgentTitle }).click();
await expectLiveAssistantText(page, visibleAfterFork);
@@ -1,4 +1,4 @@
import type { BrowserContext, Locator } from "@playwright/test";
import type { BrowserContext } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
@@ -97,20 +97,14 @@ async function allowRichClipboard(context: BrowserContext): Promise<void> {
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
}
/**
* The rows of one assistant message. A message renders one row per Markdown block, so
* every locator and every text walk below covers the group, not a single block.
*/
function assistantMessageBlocks(page: Page): Locator {
return page.locator("[data-message-id]").filter({ has: page.getByTestId("assistant-message") });
}
async function selectStructuredClipboardFixture(page: Page): Promise<void> {
await selectAssistantTextRange(page, "Direct matches:", "ready");
}
async function selectAssistantElement(page: Page, selector: string): Promise<void> {
const assistantMessage = assistantMessageBlocks(page);
const assistantMessage = page.getByTestId("assistant-message").filter({
hasText: "Direct matches:",
});
await assistantMessage.locator(selector).evaluate((element) => {
const selection = window.getSelection();
const range = document.createRange();
@@ -130,7 +124,9 @@ async function selectAssistantListItemFromMarker(
itemText: string,
endText: string,
): Promise<void> {
const assistantMessage = assistantMessageBlocks(page);
const assistantMessage = page.getByTestId("assistant-message").filter({
hasText: "Direct matches:",
});
const item = assistantMessage
.locator(`[data-paseo-markdown-tag="${listTag}"]`)
.filter({ hasText: itemText })
@@ -174,7 +170,9 @@ async function doubleClickAssistantMarkdownText(
tag: "code" | "strong",
text: string,
): Promise<void> {
const assistantMessage = assistantMessageBlocks(page);
const assistantMessage = page.getByTestId("assistant-message").filter({
hasText: "Direct matches:",
});
await assistantMessage
.locator(`[data-paseo-markdown-tag="${tag}"]`)
.filter({ hasText: text })
@@ -186,20 +184,12 @@ async function selectAssistantTextRange(
startText: string,
endText: string,
): Promise<void> {
await assistantMessageBlocks(page).evaluateAll(
(blocks, selectedRange) => {
// The message's text nodes in reading order, across every block row.
const nodes: Text[] = [];
for (const block of blocks) {
const blockWalker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT);
let found = blockWalker.nextNode();
while (found) {
nodes.push(found as Text);
found = blockWalker.nextNode();
}
}
let cursor = 0;
const walker = { nextNode: () => nodes[cursor++] ?? null };
const assistantMessage = page.getByTestId("assistant-message").filter({
hasText: "Direct matches:",
});
await assistantMessage.evaluate(
(element, selectedRange) => {
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
let startNode: Node | null = null;
let startOffset = -1;
let endNode: Node | null = null;
@@ -251,20 +241,12 @@ async function selectAssistantAcrossCodeLines(
startText: string,
endText: string,
): Promise<void> {
await assistantMessageBlocks(page).evaluateAll(
(blocks, selectedRange) => {
// The message's text nodes in reading order, across every block row.
const nodes: Text[] = [];
for (const block of blocks) {
const blockWalker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT);
let found = blockWalker.nextNode();
while (found) {
nodes.push(found as Text);
found = blockWalker.nextNode();
}
}
let cursor = 0;
const walker = { nextNode: () => nodes[cursor++] ?? null };
const assistantMessage = page.getByTestId("assistant-message").filter({
hasText: "Direct matches:",
});
await assistantMessage.evaluate(
(element, selectedRange) => {
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
let startNode: Node | null = null;
let startOffset = -1;
let textNode = walker.nextNode();
@@ -344,7 +326,9 @@ test("copying an assistant selection preserves Markdown structure and links", as
);
await openAgentRoute(page, agent);
const assistantMessage = assistantMessageBlocks(page);
const assistantMessage = page.getByTestId("assistant-message").filter({
hasText: "Direct matches:",
});
for (const [tag, text] of [
["strong", "strong prose"],
["em", "emphasized prose"],
+90 -180
View File
@@ -876,32 +876,6 @@ test("compact Changes keeps its actions compact and menu-only", async ({ page })
).toContainText("Scroll long lines");
});
test("compact Changes jumps to a file from the changed-files sheet", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff({ includeNestedFolders: true });
await useUnwrappedDiffLines(page);
const explorer = await openCompactChanges(page, workspace);
await openChangedFilesOverview(page);
await test.step("reopening the overview expands folders again", async () => {
await expectOverviewReopensExpanded(page, "zz-folder", "changed.ts");
});
await test.step("jumping to a nested file closes the overview and scrolls to its diff", async () => {
await jumpToOverviewFile(page, explorer, "src/zz-folder/nested/changed.ts");
});
});
test("Jump to file stays out of the desktop diff and of an empty comparison", async ({ page }) => {
const committed = await createWorkspaceWithCommittedDiff();
await openWorkspaceChangesSurface(page, committed, 90_000);
await expect(page.getByRole("button", { name: "Jump to file" })).toHaveCount(0);
const explorer = await openCompactChanges(page, committed);
await expect(explorer.getByRole("button", { name: "Jump to file" })).toBeVisible();
await selectEmptyUncommittedComparison(page, explorer);
await expect(page.getByRole("button", { name: "Jump to file" })).toHaveCount(0);
});
test("canvas diff stays sharp while its workspace pane is resized", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await useUnwrappedDiffLines(page);
@@ -980,21 +954,11 @@ test("canvas diff stays sharp while its workspace pane is resized", async ({ pag
.toBe(true);
});
test("changes diff waits for configured fonts, then applies code typography changes", async ({
page,
}) => {
test("changes diff applies code size changes to gutter and code typography", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await useCodeFont(page, 12);
await useUnwrappedDiffLines(page);
await holdBrowserFontLoads(page);
await navigateToWorkspaceChanges(page, workspace);
await test.step("commit no geometry before the configured fonts are ready", async () => {
await expect(page.getByTestId("git-diff-canvas")).toBeVisible();
await expect(page.getByTestId("diff-file-0-body")).toHaveCount(0);
await releaseBrowserFontLoads(page);
await expectExpandedMountedTabDiff(page);
});
await openWorkspaceChanges(page, workspace);
const before = await readDiffTypographyGeometry(page);
await changeCodeTypographyFromSettings(page, {
@@ -1013,31 +977,63 @@ test("changes diff waits for configured fonts, then applies code typography chan
expect(after.canvasPixels).not.toEqual(before.canvasPixels);
});
test("creates, cancels, edits, and deletes a review while keeping Changes focused", async ({
test("canvas diff does not commit geometry before configured fonts are ready", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await holdBrowserFontLoads(page);
await useUnwrappedDiffLines(page);
await page.setViewportSize({ width: 1400, height: 900 });
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
await waitForWorkspaceTabsVisible(page);
await openChangesPanel(page);
await expect(page.getByTestId("git-diff-canvas")).toBeVisible();
await expect(page.getByTestId("diff-file-0-body")).toHaveCount(0);
await releaseBrowserFontLoads(page);
await expectExpandedMountedTabDiff(page);
});
test("canvas diff creates, edits, and deletes an inline review without DOM code rows", async ({
page,
}) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await startReviewOnFirstChangedLine(page);
await cancelInlineReview(page);
await startReviewOnFirstChangedLine(page);
await saveInlineReview(page, "Please keep this branch explicit");
await editInlineReview(page, "Please keep this branch named explicitly");
await deleteInlineReview(page);
await expect(page.locator('[data-testid^="diff-code-row-"]')).toHaveCount(0);
});
test("autofocusing an inline review keeps the Changes tab focused", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
const changesTab = page.getByTestId("workspace-tab-working_diff").filter({ visible: true });
const focusedBackground = await changesTab.evaluate(
(element) => getComputedStyle(element).backgroundColor,
);
await test.step("start a focused review with the browser text menu", async () => {
await startReviewOnFirstChangedLine(page);
await expect(page.getByTestId("inline-review-editor-input")).toBeFocused();
await expect(changesTab).toHaveCSS("background-color", focusedBackground);
await page.getByTestId("inline-review-editor-input").click({ button: "right" });
await expect(page.getByTestId("diff-source-context-menu")).toHaveCount(0);
await cancelInlineReview(page);
});
await test.step("save, edit, and delete the review", async () => {
await startReviewOnFirstChangedLine(page);
await saveInlineReview(page, "Please keep this branch explicit");
await editInlineReview(page, "Please keep this branch named explicitly");
await deleteInlineReview(page);
});
await startReviewOnFirstChangedLine(page);
await expect(page.getByTestId("inline-review-editor-input")).toBeFocused();
await expect
.poll(() => changesTab.evaluate((element) => getComputedStyle(element).backgroundColor))
.toBe(focusedBackground);
});
test("inline reviews keep the browser text context menu", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await startReviewOnFirstChangedLine(page);
await page.getByTestId("inline-review-editor-input").click({ button: "right" });
await expect(page.getByTestId("diff-source-context-menu")).toHaveCount(0);
});
test("split canvas creates a review on the changed side and keeps it in that column", async ({
@@ -1147,36 +1143,36 @@ test("the whole reviewable row reveals the gutter affordance and uses a text cur
await expect(page.getByTestId("git-diff-scroll")).toHaveCSS("cursor", "text");
});
test("selects, preserves, copies, and dismisses diff text without opening a review", async ({
test("canvas diff copies a dragged character selection without opening a review", async ({
context,
page,
}) => {
await openCopyableSelectionDiff(page, "ABCDEFGHIJ");
await dragExactAddedText(page, { startOffset: 2, endOffset: 8 });
const workspace = await createWorkspaceWithExactSelectionDiff("ABCDEFGHIJ");
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await useUnwrappedDiffLines(page);
await openSelectionWorkspaceChanges(page, workspace);
await test.step("copy the selected characters with the keyboard", async () => {
await copyDiffSelectionWithKeyboard(page);
await expectClipboardText(page, "CDEFGH");
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
});
await test.step("copy the preserved selection after resizing", async () => {
await resizeDiffViewportHeight(page, 960);
await copyFromChangedLineMenu(page, "Copy");
await expectClipboardText(page, "CDEFGH");
});
await test.step("copy the complete source line", async () => {
await copyFromChangedLineMenu(page, "Copy line");
await expectClipboardText(page, "ABCDEFGHIJ");
});
await test.step("dismiss a new selection without starting a review", async () => {
await dragExactAddedText(page, { startOffset: 2, endOffset: 8 });
await clickFirstChangedLine(page);
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
await rightClickFirstChangedLine(page);
await expect(page.getByTestId("diff-source-copy-selection")).toBeDisabled();
await page.keyboard.press("Escape");
await clickFirstChangedLine(page);
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
});
await dragExactAddedText(page, { startOffset: 2, endOffset: 8 });
await page.keyboard.press("ControlOrMeta+C");
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe("CDEFGH");
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
});
test("canvas diff context menu copies selections and source lines", async ({ context, page }) => {
const workspace = await createWorkspaceWithExactSelectionDiff("ABCDEFGHIJ");
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await useUnwrappedDiffLines(page);
await openSelectionWorkspaceChanges(page, workspace);
await dragExactAddedText(page, { startOffset: 2, endOffset: 8 });
await rightClickFirstChangedLine(page);
await page.getByTestId("diff-source-copy-selection").click();
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe("CDEFGH");
await rightClickFirstChangedLine(page);
await page.getByTestId("diff-source-copy-line").click();
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe("ABCDEFGHIJ");
});
test("canvas diff clears a selection when collapsing an earlier file", async ({ page }) => {
@@ -1190,6 +1186,19 @@ test("canvas diff clears a selection when collapsing an earlier file", async ({
await expect(page.getByTestId("diff-source-copy-selection")).toBeDisabled();
});
test("clicking the canvas dismisses a selection without opening a review", async ({ page }) => {
const workspace = await createWorkspaceWithExactSelectionDiff("ABCDEFGHIJ");
await useUnwrappedDiffLines(page);
await openSelectionWorkspaceChanges(page, workspace);
await dragExactAddedText(page, { startOffset: 2, endOffset: 8 });
await clickFirstChangedLine(page);
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
await clickFirstChangedLine(page);
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
});
test("canvas diff replaces a selection with forward and backward drags", async ({
context,
page,
@@ -1384,17 +1393,14 @@ async function setOpenChangesPresentation(
}
}
/** Holds every font load in the document until released; later loads pass through. */
async function holdBrowserFontLoads(page: Page): Promise<void> {
await page.addInitScript(() => {
const fontSet = document.fonts;
const originalLoad = fontSet.load.bind(fontSet);
const pending: Array<() => void> = [];
let released = false;
Object.defineProperty(fontSet, "load", {
configurable: true,
value(font: string, text?: string) {
if (released) return originalLoad(font, text);
return new Promise<FontFace[]>((resolve, reject) => {
pending.push(() => {
originalLoad(font, text).then(resolve, reject);
@@ -1404,7 +1410,6 @@ async function holdBrowserFontLoads(page: Page): Promise<void> {
});
Object.assign(window, {
__releasePaseoDiffFontLoads() {
released = true;
for (const release of pending.splice(0)) release();
},
});
@@ -1654,72 +1659,12 @@ async function createWorkspaceWithStickyTransitionDiff(): Promise<DirtyWorkspace
}
async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> {
await navigateToWorkspaceChanges(page, workspace);
await expectExpandedMountedTabDiff(page);
}
async function navigateToWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> {
await page.setViewportSize({ width: 1400, height: 900 });
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
await waitForWorkspaceTabsVisible(page);
await page.getByTestId("workspace-explorer-toggle").first().click();
await openChangesInVisibleExplorer(page);
}
/** The Explorer overlay a phone-sized viewport shows, with its Changes tab selected. */
async function openCompactChanges(page: Page, workspace: DirtyWorkspace): Promise<Locator> {
await page.setViewportSize({ width: 390, height: 844 });
// Reload at the compact size: panel selection starts at the center on a cold
// mount, independently of any desktop sidebar used earlier in the test.
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
await page.reload();
await page.getByTestId("workspace-explorer-toggle").first().click();
const changesTab = page.getByTestId("explorer-tab-changes").filter({ visible: true });
await expect(changesTab).toBeVisible({ timeout: 30_000 });
await changesTab.click();
const explorer = page.getByTestId("explorer-content-area").filter({ visible: true });
await expect(explorer.getByTestId("changes-header")).toBeVisible({ timeout: 30_000 });
return explorer;
}
async function openChangedFilesOverview(page: Page): Promise<void> {
const jumpToFile = page.getByRole("button", { name: "Jump to file" });
await expect(jumpToFile).toBeVisible();
await jumpToFile.click();
await expect(page.getByTestId("changes-jump-to-file-sheet")).toContainText("Jump to file");
}
async function expectOverviewReopensExpanded(
page: Page,
folderName: string,
childFileName: string,
): Promise<void> {
const sheet = page.getByTestId("changes-jump-to-file-sheet");
const tree = changesTree(page);
const folder = tree
.getByRole("button")
.filter({ has: page.getByText(folderName, { exact: true }) });
const child = tree.getByText(childFileName, { exact: true });
await expect(folder).toBeVisible();
await expect(child).toBeVisible();
await folder.click();
await expect(child).toHaveCount(0);
await sheet.getByRole("button", { name: "Close", exact: true }).click();
await openChangedFilesOverview(page);
await expect(child).toBeVisible();
}
async function jumpToOverviewFile(page: Page, explorer: Locator, filePath: string): Promise<void> {
const sheet = page.getByTestId("changes-jump-to-file-sheet");
await changesTree(page).getByText(path.basename(filePath), { exact: true }).click();
await expect(sheet).toHaveCount(0);
await expect(diffHeaderForPath(explorer, filePath)).toBeInViewport();
}
async function selectEmptyUncommittedComparison(page: Page, explorer: Locator): Promise<void> {
await explorer.getByRole("button", { name: "Diff mode", exact: true }).click();
await page.getByRole("menuitem", { name: "Uncommitted", exact: true }).click();
await expect(explorer.getByText("No changes to display", { exact: true })).toBeVisible();
await expectExpandedMountedTabDiff(page);
}
async function openWorkspaceChangesSurface(
@@ -1875,41 +1820,6 @@ async function clickFirstChangedLine(page: Page): Promise<void> {
await page.mouse.click(bodyBounds.x + 120, bodyBounds.y + lineHeight * 1.5);
}
async function openCopyableSelectionDiff(page: Page, content: string): Promise<void> {
const workspace = await createWorkspaceWithExactSelectionDiff(content);
await page.context().grantPermissions(["clipboard-read", "clipboard-write"]);
await configureDiffPresentation(page, { layout: "unified", wrapLines: false });
await openSelectionWorkspaceChanges(page, workspace);
}
async function copyDiffSelectionWithKeyboard(page: Page): Promise<void> {
await page.evaluate(() => navigator.clipboard.writeText(""));
await page.keyboard.press("ControlOrMeta+C");
}
async function copyFromChangedLineMenu(page: Page, action: "Copy" | "Copy line"): Promise<void> {
await page.evaluate(() => navigator.clipboard.writeText(""));
await rightClickFirstChangedLine(page);
const item = page.getByRole("menuitem", { name: action, exact: true });
await expect(item).toBeEnabled();
await item.click();
}
async function expectClipboardText(page: Page, text: string): Promise<void> {
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(text);
}
async function resizeDiffViewportHeight(page: Page, height: number): Promise<void> {
const canvas = page.getByTestId("git-diff-canvas");
const previousViewport = page.viewportSize()!;
const previousHeight = await canvas.evaluate((element) => element.getBoundingClientRect().height);
await page.setViewportSize({ width: previousViewport.width, height });
await expect(canvas).toHaveCSS(
"height",
`${previousHeight + height - previousViewport.height}px`,
);
}
async function rightClickFirstChangedLine(page: Page, fileIndex = 0): Promise<void> {
const body = page.getByTestId(`diff-file-${fileIndex}-body`);
const canvas = page.getByTestId("git-diff-canvas");
@@ -1,5 +1,5 @@
import { expect, test } from "../support/fixtures";
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
import { gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
@@ -12,7 +12,6 @@ test("Changes shortcut reveals the Changes tree in Explorer", async ({ page }) =
await page.setViewportSize({ width: 1400, height: 900 });
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await clickNewChat(page);
await page.keyboard.press(CHANGES_SHORTCUT);
-530
View File
@@ -1,530 +0,0 @@
import type { TestInfo } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures";
import {
openAgentRoute,
seedMockAgentWorkspace,
type MockAgentWorkspace,
} from "../support/helpers/mock-agent";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import {
expectReconnectingToastGone,
expectReconnectingToastVisible,
} from "../support/helpers/workspace-ui";
import { trackPromptJumpRequests } from "../support/helpers/agent-timeline-gate";
import {
composerLocator,
fillComposerDraft,
expectComposerDraft,
expectComposerFocused,
} from "../support/helpers/composer";
import { openCommandCenter, closeCommandCenter } from "../support/helpers/command-center";
const MAC_USER_AGENT =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36";
const FILLER = Array.from({ length: 400 }, (_, index) => `filler${index}`).join(" ");
const STREAMED_RESPONSE = [
"First paragraph with alpha-needle and pair-needle inside.",
"Second paragraph with beta-needle and pair-needle inside.",
`Third paragraph ${FILLER}`,
].join("\n\n");
const RESPONSE =
'İ😀hello **world**; hello *world*.\n\n[hello &amp; world](https://example.com/hidden-destination)\n\n```ts\nconst a = "a.b";\n```\n\n- first cell\n- second cell';
// One message far taller than the viewport, so its first and last paragraph rows are
// never in the virtualizer's window at the same time.
const PARAGRAPH_PADDING = Array.from({ length: 40 }, (_, index) => `padding${index}`).join(" ");
const TALL_RESPONSE = Array.from({ length: 30 }, (_, index) => {
if (index === 0) return `Opening paragraph with span-needle. ${PARAGRAPH_PADDING}`;
if (index === 29) return `Closing paragraph with span-needle. ${PARAGRAPH_PADDING}`;
return `Paragraph ${index}. ${PARAGRAPH_PADDING}`;
}).join("\n\n");
function query(page: Page) {
return page.getByRole("textbox", { name: "Find in pane", exact: true });
}
function status(page: Page) {
return page.getByRole("status", { name: "Find matches" });
}
async function openChatFind(page: Page) {
await page.getByTestId("assistant-message").filter({ visible: true }).last().click();
await page.keyboard.press("ControlOrMeta+f");
await expect(query(page)).toBeFocused();
}
async function searchChat(page: Page, text: string) {
await openChatFind(page);
await query(page).fill(text);
}
async function expectHighlight(page: Page, text: string) {
await expect
.poll(() =>
page.evaluate(() => {
const entries = Array.from(CSS.highlights.entries()).filter(([name]) =>
name.startsWith("paseo-chat-find-"),
);
const texts: string[] = [];
for (const [, highlight] of entries) {
for (const range of highlight) texts.push(range.toString());
}
return texts;
}),
)
.toEqual([text]);
await expect
.poll(() =>
page.evaluate(() => {
const entry = Array.from(CSS.highlights.entries()).find(([name]) =>
name.startsWith("paseo-chat-find-"),
);
const range = entry && Array.from(entry[1])[0];
if (!(range instanceof Range)) return false;
const rect = range.getBoundingClientRect();
const row = range.startContainer.parentElement?.closest(
'[data-testid="agent-chat-scroll"]',
);
const viewport = row?.getBoundingClientRect();
return (
!!viewport && rect.height > 0 && rect.bottom > viewport.top && rect.top < viewport.bottom
);
}),
)
.toBe(true);
}
async function nextMatch(page: Page) {
await query(page).press("Enter");
}
async function previousMatch(page: Page) {
await query(page).press("Shift+Enter");
}
async function expectFindStatus(page: Page, search: string, matches: string) {
await query(page).fill(search);
await expect(status(page)).toHaveText(matches, { timeout: 15_000 });
}
async function expectNextMatch(page: Page, matches: string, highlighted: string) {
await nextMatch(page);
await expect(status(page)).toHaveText(matches);
await expectHighlight(page, highlighted);
}
async function closeFind(page: Page) {
await query(page).press("Escape");
await expect(query(page)).toHaveCount(0);
await expect
.poll(() =>
page.evaluate(() =>
Array.from(CSS.highlights.keys()).filter((key) => key.startsWith("paseo-chat-find-")),
),
)
.toEqual([]);
}
test("loads older history through prompt navigation and finds repeated rendered Markdown", async ({
page,
}, testInfo) => {
test.setTimeout(120_000);
const agent = await seedMockAgentWorkspace({
repoPrefix: "chat-find-",
title: "Chat Find",
featureValues: { mockAssistantResponse: RESPONSE },
});
try {
for (let index = 0; index < 45; index++) {
await agent.client.sendAgentMessage(agent.agentId, `Prompt number ${index}`);
await agent.client.waitForFinish(agent.agentId, 15_000);
}
const jumps = await trackPromptJumpRequests(page, agent.agentId);
await openAgentRoute(page, agent);
await expect(page.getByText("Prompt number 44", { exact: true })).toBeVisible();
await expect(page.getByText("Prompt number 0", { exact: true })).toHaveCount(0);
await searchChat(page, "hello world");
await expectHighlight(page, "hello world");
await expect(status(page)).toHaveText("1 of 90");
await page.screenshot({ path: testInfo.outputPath("chat-find-highlight.png") });
expect(jumps.requests()[0]).toMatchObject({ limit: 40, mergeWindow: true });
await nextMatch(page);
await expect(status(page)).toHaveText("2 of 90");
await expectHighlight(page, "hello world");
await previousMatch(page);
await expect(status(page)).toHaveText("1 of 90");
await query(page).fill("hello & world");
await expectHighlight(page, "hello & world");
await query(page).fill("a.b");
await expectHighlight(page, "a.b");
await query(page).fill("Prompt number 0");
await expectHighlight(page, "Prompt number 0");
await closeFind(page);
} finally {
await agent.cleanup();
}
});
// The scope of the search is the whole chat, so the counter is the position across
// every message that holds a hit, and Next walks off the end of one into the next.
const ACROSS_PROMPT = "Find scope-needle in this chat";
const ACROSS_RESPONSE = "Reply with scope-needle here.\n\nAnd scope-needle again there.";
/** The test id of the message that holds the current match, so it can be watched crossing one. */
async function highlightedMessage(page: Page) {
return page.evaluate(() => {
const entry = Array.from(CSS.highlights.entries()).find(([name]) =>
name.startsWith("paseo-chat-find-"),
);
const range = entry && Array.from(entry[1])[0];
if (!(range instanceof Range)) return null;
const message = range.startContainer.parentElement?.closest<HTMLElement>(
'[data-testid="user-message"], [data-testid="assistant-message"]',
);
return message?.dataset.testid ?? null;
});
}
async function expectHighlightedMessage(page: Page, testId: string) {
await expect.poll(() => highlightedMessage(page)).toBe(testId);
}
test("counts and steps through every match in the chat, not just the selected message", async ({
page,
}, testInfo) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "chat-find-across-",
title: "Chat Find across messages",
initialPrompt: ACROSS_PROMPT,
featureValues: { mockAssistantResponse: ACROSS_RESPONSE },
});
try {
await agent.client.waitForFinish(agent.agentId, 15_000);
await openAgentRoute(page, agent);
await expect(page.getByTestId("assistant-message").first()).toBeVisible();
// One hit in the prompt and two in the reply: three across the chat.
await searchChat(page, "scope-needle");
await expect(status(page)).toHaveText("1 of 3");
await expectHighlight(page, "scope-needle");
await expectHighlightedMessage(page, "user-message");
await page.screenshot({ path: testInfo.outputPath("chat-find-across-messages.png") });
await expectNextMatch(page, "2 of 3", "scope-needle");
await expectHighlightedMessage(page, "assistant-message");
await expectNextMatch(page, "3 of 3", "scope-needle");
await expectHighlightedMessage(page, "assistant-message");
await page.screenshot({ path: testInfo.outputPath("chat-find-last-match.png") });
await expectNextMatch(page, "1 of 3", "scope-needle");
await expectHighlightedMessage(page, "user-message");
await previousMatch(page);
await expect(status(page)).toHaveText("3 of 3");
await expectHighlight(page, "scope-needle");
await expectHighlightedMessage(page, "assistant-message");
await closeFind(page);
} finally {
await agent.cleanup();
}
});
test("rejects invisible source matches and ignores a late reply after closing", async ({
page,
}) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "chat-find-cancel-",
title: "Chat Find cancellation",
initialPrompt: "Render the response",
featureValues: { mockAssistantResponse: RESPONSE },
});
try {
await agent.client.waitForFinish(agent.agentId, 15_000);
const gate = await installDaemonWebSocketGate(page);
await openAgentRoute(page, agent);
await expect(page.getByTestId("assistant-message").first()).toBeVisible();
await searchChat(page, "hidden-destination");
await expect(status(page)).toHaveText("No matches");
await query(page).fill("first cell second cell");
await expect(status(page)).toHaveText("No matches");
gate.holdNextServerMessage("agent.timeline.search.response");
await query(page).fill("hello world");
await expect(status(page)).toHaveText("Searching…");
await gate.waitForHeldServerMessage("agent.timeline.search.response");
await closeFind(page);
gate.releaseHeldServerMessage("agent.timeline.search.response");
await searchChat(page, "a.b");
await expectHighlight(page, "a.b");
await closeFind(page);
} finally {
await agent.cleanup();
}
});
test("shows a disconnected search failure and recovers with Retry", async ({ page }) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "chat-find-retry-",
title: "Chat Find retry",
initialPrompt: "Render the response",
featureValues: { mockAssistantResponse: RESPONSE },
});
try {
await agent.client.waitForFinish(agent.agentId, 15_000);
const gate = await installDaemonWebSocketGate(page);
await openAgentRoute(page, agent);
await expect(page.getByTestId("assistant-message").first()).toBeVisible();
await gate.drop();
await expectReconnectingToastVisible(page);
await searchChat(page, "hello world");
await expect(status(page)).toHaveText("Failed");
await expect(
page.getByText("Could not search this chat. Check the host connection and retry.", {
exact: true,
}),
).toBeVisible();
await expect(page.getByRole("button", { name: "Retry", exact: true })).toBeVisible();
gate.restore();
await expectReconnectingToastGone(page);
await page.getByRole("button", { name: "Retry", exact: true }).click();
await expectHighlight(page, "hello world");
} finally {
await agent.cleanup();
}
});
interface StreamedReplyChat extends MockAgentWorkspace {
/** Resolves once the streamed reply has finished arriving. */
replyFinished(): Promise<void>;
}
/**
* Opens the chat first, then streams a multi-block reply into it and waits for a
* later block, so the block holding the early needles is no longer the growing row.
*/
async function openChatStreamingMultiBlockReply(page: Page): Promise<StreamedReplyChat> {
const agent = await seedMockAgentWorkspace({
repoPrefix: "chat-find-live-blocks-",
title: "Chat Find live blocks",
featureValues: {
mockStreamingAssistantResponse: STREAMED_RESPONSE,
mockStreamingAssistantIntervalMs: 60,
},
});
try {
await openAgentRoute(page, agent);
await agent.client.sendAgentMessage(agent.agentId, "Stream a multi block answer");
await expect(page.getByText("beta-needle", { exact: false }).last()).toBeVisible({
timeout: 30_000,
});
return {
...agent,
replyFinished: async () => {
await agent.client.waitForFinish(agent.agentId, 60_000);
},
};
} catch (error) {
await agent.cleanup();
throw error;
}
}
test("finds a hit in a message that streamed while the chat was open", async ({ page }) => {
test.setTimeout(120_000);
const chat = await openChatStreamingMultiBlockReply(page);
try {
await openChatFind(page);
// The only hit is in the first paragraph, which is no longer the row that grows.
await expectFindStatus(page, "alpha-needle", "1 of 1");
await chat.replyFinished();
await expectFindStatus(page, "beta-needle", "1 of 1");
// One message, two Markdown blocks, one count across both.
await expectFindStatus(page, "pair-needle", "1 of 2");
await expectNextMatch(page, "2 of 2", "pair-needle");
await closeFind(page);
} finally {
await chat.cleanup();
}
});
/**
* Opens a chat whose newest reply is a single message far taller than the viewport,
* behind enough older turns that the oldest one is not hydrated yet.
*/
async function openChatWithVirtualizedTallMessage(page: Page): Promise<MockAgentWorkspace> {
const agent = await seedMockAgentWorkspace({
repoPrefix: "chat-find-virtualized-",
title: "Chat Find virtualized message",
featureValues: { mockAssistantResponse: TALL_RESPONSE },
});
try {
for (let index = 0; index < 6; index++) {
await agent.client.sendAgentMessage(agent.agentId, `Prompt number ${index}`);
await agent.client.waitForFinish(agent.agentId, 15_000);
}
await openAgentRoute(page, agent);
await expect(page.getByText("Prompt number 5", { exact: true })).toBeVisible();
await expect(page.getByText("Prompt number 0", { exact: true })).toHaveCount(0);
return agent;
} catch (error) {
await agent.cleanup();
throw error;
}
}
// Every block row of the selected message must be mounted for Find to see it, whatever
// the virtualizer's scroll window holds. Otherwise a hit in a far paragraph is missing
// from the count and Next walks off to the next message instead.
test("counts every occurrence of a virtualized message and steps between them", async ({
page,
}) => {
test.setTimeout(120_000);
const chat = await openChatWithVirtualizedTallMessage(page);
try {
await openChatFind(page);
await expectFindStatus(page, "span-needle", "1 of 12");
await expectHighlight(page, "span-needle");
await expectNextMatch(page, "2 of 12", "span-needle");
await closeFind(page);
} finally {
await chat.cleanup();
}
});
test("finds text beyond the normal render cap and restores the cap when Find closes", async ({
page,
}) => {
const tail = "oversized-tail-needle";
const agent = await seedMockAgentWorkspace({
repoPrefix: "chat-find-cap-",
title: "Chat Find long message",
initialPrompt: "Render a long response",
// One paragraph, so the needle is past the render cap of the row that holds it.
featureValues: { mockAssistantResponse: `${"background ".repeat(3100)}${tail}` },
});
try {
await agent.client.waitForFinish(agent.agentId, 15_000);
await openAgentRoute(page, agent);
const message = page.getByTestId("assistant-message");
await expect(message).toBeVisible();
await expect(message).not.toContainText(tail);
await searchChat(page, tail);
await expectHighlight(page, tail);
await closeFind(page);
await expect(message).not.toContainText(tail);
await expect(message.getByTestId("assistant-message-capped-notice")).toBeVisible();
} finally {
await agent.cleanup();
}
});
async function findFromComposer(page: Page, shortcut: string, testInfo: TestInfo) {
const agent = await seedMockAgentWorkspace({
repoPrefix: "chat-find-composer-",
title: "Chat Find from composer",
initialPrompt: "Render the response",
featureValues: { mockAssistantResponse: RESPONSE },
});
try {
await agent.client.waitForFinish(agent.agentId, 15_000);
await openAgentRoute(page, agent);
await searchFromComposerWithoutLosingDraft(page, shortcut);
await refocusFindAndReplaceQuery(page, shortcut);
await page.screenshot({ path: testInfo.outputPath("find-from-composer.png") });
await closeFind(page);
await expectCommandCenterOwnsFindShortcut(page, shortcut);
await fillComposerDraft(page, "Draft after closing Find");
await expectComposerDraft(page, "Draft after closing Find");
} finally {
await agent.cleanup();
}
}
test("opens and refocuses Find with Control+f from the composer", async ({ page }, testInfo) => {
await findFromComposer(page, "Control+f", testInfo);
});
test.describe("macOS", () => {
test.use({ userAgent: MAC_USER_AGENT });
test("opens and refocuses Find with Meta+f from the composer", async ({ page }, testInfo) => {
await findFromComposer(page, "Meta+f", testInfo);
});
// Control+F moves the caret forward on macOS, so chat Find must leave it alone.
test("leaves Control+f to the composer", async ({ page }) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "chat-find-mac-control-f-",
title: "Chat Find macOS Control+f",
initialPrompt: "Render the response",
featureValues: { mockAssistantResponse: RESPONSE },
});
try {
await agent.client.waitForFinish(agent.agentId, 15_000);
await openAgentRoute(page, agent);
await fillComposerDraft(page, "Keep this draft");
await expectComposerFocused(page);
await recordComposerKeydown(page);
await page.keyboard.press("Control+f");
await expect
.poll(() => recordedComposerFindKeydown(page))
.toEqual([{ key: "f", defaultPrevented: false }]);
await expect(query(page)).toHaveCount(0);
await expectComposerFocused(page);
await expectComposerDraft(page, "Keep this draft");
} finally {
await agent.cleanup();
}
});
});
interface RecordedKeydown {
key: string;
defaultPrevented: boolean;
}
/**
* Chat Find listens on `document` in the capture phase, so the composer only ever
* sees a Find keystroke after Find decided about it. That makes `defaultPrevented`
* on the composer the evidence of who owns the key.
*/
async function recordComposerKeydown(page: Page) {
await composerLocator(page).evaluate((element) => {
const store = window as unknown as { __findKeydown: RecordedKeydown[] };
store.__findKeydown = [];
element.addEventListener("keydown", (event) => {
store.__findKeydown.push({
key: (event as KeyboardEvent).key,
defaultPrevented: event.defaultPrevented,
});
});
});
}
async function recordedComposerFindKeydown(page: Page): Promise<RecordedKeydown[]> {
const recorded = await page.evaluate(
() => (window as unknown as { __findKeydown: RecordedKeydown[] }).__findKeydown,
);
return recorded.filter((entry) => entry.key.toLowerCase() === "f");
}
async function openFindFromComposer(page: Page, shortcut: string) {
await page.keyboard.press(shortcut);
await expect(query(page)).toBeFocused();
}
async function searchFromComposerWithoutLosingDraft(page: Page, shortcut: string) {
await fillComposerDraft(page, "Keep this draft");
await expectComposerFocused(page);
await openFindFromComposer(page, shortcut);
await expectComposerDraft(page, "Keep this draft");
await query(page).fill("hello world");
await expectHighlight(page, "hello world");
}
async function refocusFindAndReplaceQuery(page: Page, shortcut: string) {
await composerLocator(page).click();
await expectComposerFocused(page);
await openFindFromComposer(page, shortcut);
await expect(query(page)).toHaveValue("hello world");
await expectComposerDraft(page, "Keep this draft");
await page.keyboard.type("a.b");
await expect(query(page)).toHaveValue("a.b");
await expectHighlight(page, "a.b");
}
async function expectCommandCenterOwnsFindShortcut(page: Page, shortcut: string) {
await openCommandCenter(page);
await page.keyboard.press(shortcut);
await expect(page.getByTestId("command-center-panel")).toBeVisible();
await expect(query(page)).toHaveCount(0);
await closeCommandCenter(page);
}
@@ -17,14 +17,10 @@ import {
hoverChatOutlinePrompt,
chatOutlineRail,
movePointerOffChatOutline,
observePromptIndexRequests,
pointAtChatOutlineRowEdge,
pressEnterOnFocusedPrompt,
splitCurrentPanelRight,
withStreamingMarkdownOutline,
expectReadingStreamedMarkdown,
} from "../support/helpers/chat-outline";
import { createCreationScenario } from "../support/helpers/creation";
import {
expectTimelineAtMaximumScrollWithPromptVisible,
expectTimelinePromptNotMounted,
@@ -45,31 +41,6 @@ const WIDE_VIEWPORT = { width: 1440, height: 900 };
const LOADED_TURNS = 16;
test.describe("desktop chat outline", () => {
test("keeps the prompt marked while reading split Markdown blocks and after completion", async ({
page,
}) => {
test.setTimeout(120_000);
const prompt = "Explain in several paragraphs.";
await withStreamingMarkdownOutline(async (agent) => {
await agent.client.sendAgentMessage(agent.agentId, "Earlier prompt.");
await agent.client.waitForFinish(agent.agentId, 30_000);
await page.setViewportSize(WIDE_VIEWPORT);
await openAgentTimeline(page, agent);
await agent.client.sendAgentMessage(agent.agentId, prompt);
await expectChatOutlinePrompts(page, 2);
await expectReadingStreamedMarkdown(page, prompt);
await expectActiveChatOutlinePrompt(page, 2);
await agent.client.waitForFinish(agent.agentId, 30_000);
await expectActiveChatOutlinePrompt(page, 2);
await page.reload({ waitUntil: "domcontentloaded" });
await expectActiveChatOutlinePrompt(page, 2);
await clickChatOutlineRowEdge(page, 2);
await expectTimelinePromptLandedBelowTop(page, prompt);
});
});
test("indexes unloaded prompts and jumps with one bounded merged page", async ({ page }) => {
test.setTimeout(120_000);
const agent = await seedLongMockAgentTimeline({ turns: 80 });
@@ -268,21 +239,3 @@ test.describe("desktop chat outline", () => {
}
});
});
test("indexes a new agent's prompts only once the agent exists", async ({ page }) => {
const promptIndex = await observePromptIndexRequests(page);
const creation = await createCreationScenario(page);
try {
await page.setViewportSize(WIDE_VIEWPORT);
await creation.openAgentDraft();
await creation.submitPrompt("Start an agent with an outline.");
await creation.expectPromptVisible();
const agentIds = await creation.agentIds();
expect(agentIds).toHaveLength(1);
await promptIndex.waitForRequestFor(agentIds[0]!);
expect(promptIndex.requestedAgentIds()).toEqual(agentIds);
} finally {
await creation.cleanup();
}
});
@@ -1,122 +0,0 @@
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import { seedWorkspace } from "../support/helpers/seed-client";
import { openAgentRoute } from "../support/helpers/mock-agent";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { connectDaemonClient } from "../support/helpers/daemon-client-loader";
// This fixture catalog advertises Ultrafast on Sol; another model offers Fast only.
// The real daemon discovers both through the same Codex model/list boundary.
test.use({
e2eDaemonConfig: {
version: 1,
agents: {
providers: {
codex: {
enabled: true,
command: [process.execPath, path.resolve("e2e/fixtures/catalog-codex.mjs")],
},
claude: { enabled: false },
copilot: { enabled: false },
opencode: { enabled: false },
pi: { enabled: false },
omp: { enabled: false },
},
},
},
});
async function openSpeedSelector(page: Page): Promise<void> {
const speed = page.getByRole("button", { name: /^(Select speed|Speed: .+)$/ });
const features = page.getByRole("button", { name: "Open agent features", exact: true });
const modelSettings = page.getByRole("button", { name: /^Select model \(/ });
await expect(speed.or(features).or(modelSettings).filter({ visible: true }).first()).toBeVisible({
timeout: 30_000,
});
if (!(await speed.isVisible())) {
if (await features.isVisible()) await features.click();
else await modelSettings.click();
}
await expect(speed).toBeVisible({ timeout: 30_000 });
await speed.click();
await expect(page.getByText("Normal", { exact: true }).last()).toBeVisible();
}
async function selectSpeed(page: Page, label: string): Promise<void> {
await page.getByRole("button", { name: label, exact: true }).last().click();
const toolbarTrigger = page.getByRole("button", { name: `Speed: ${label}`, exact: true });
const sheetTrigger = page.getByRole("button", { name: "Select speed", exact: true });
await expect(toolbarTrigger.or(sheetTrigger)).toBeVisible();
if (await toolbarTrigger.isVisible()) await expect(toolbarTrigger).toHaveText("");
else await expect(sheetTrigger).toContainText(label);
}
async function expectSpeedChoices(page: Page, choices: string[]): Promise<void> {
for (const choice of ["Normal", "Fast", "Ultrafast"]) {
const option = page.getByText(choice, { exact: true }).last();
if (choices.includes(choice)) {
await expect(option).toBeVisible();
await expect(option).toBeInViewport({ ratio: 1 });
} else await expect(option).not.toBeVisible();
}
}
async function switchAgentModel(agentId: string, modelId: string): Promise<void> {
const client = await connectDaemonClient<
Pick<DaemonClient, "setAgentModel" | "close" | "connect">
>({
clientIdPrefix: "codex-speed-model",
});
try {
await client.setAgentModel(agentId, modelId);
} finally {
await client.close();
}
}
for (const viewport of [
{ name: "desktop", width: 1280, height: 850 },
{ name: "compact", width: 390, height: 844 },
]) {
test(`Codex catalog drives speed choices on ${viewport.name}`, async ({ page }, testInfo) => {
test.setTimeout(180_000);
await page.setViewportSize(viewport);
const workspace = await seedWorkspace({ repoPrefix: "codex-speed-" });
try {
const agent = await workspace.client.createAgent({
provider: "codex",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Speed settings",
model: "gpt-6.1-sol",
modeId: "full-access",
initialPrompt: "Get ready to work on this project.",
});
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: agent.id });
if (viewport.name === "desktop") {
const trigger = page.getByRole("button", { name: "Speed: Normal", exact: true });
await expect(trigger).toBeVisible({ timeout: 30_000 });
await expect(trigger).toHaveText("");
await page.screenshot({ path: testInfo.outputPath("desktop-normal-trigger.png") });
}
await openSpeedSelector(page);
await expectSpeedChoices(page, ["Normal", "Fast", "Ultrafast"]);
await page.screenshot({ path: testInfo.outputPath(`${viewport.name}-speed-options.png`) });
await selectSpeed(page, "Ultrafast");
await openSpeedSelector(page);
await expectSpeedChoices(page, ["Normal", "Fast", "Ultrafast"]);
await page.screenshot({
path: testInfo.outputPath(`${viewport.name}-ultrafast-selected.png`),
});
await selectSpeed(page, "Normal");
if (viewport.name === "desktop") {
await switchAgentModel(agent.id, "gpt-6-sol");
await openSpeedSelector(page);
await expectSpeedChoices(page, ["Normal", "Fast"]);
await page.screenshot({ path: testInfo.outputPath("desktop-fast-only.png") });
}
} finally {
await workspace.cleanup();
}
});
}
@@ -1,4 +1,4 @@
import { expect, test } from "../support/fixtures";
import { test } from "../support/fixtures";
import type { FormPreferences } from "@/create-agent-preferences/preferences";
import { closeCommandCenter, openCommandCenter } from "../support/helpers/command-center";
import {
@@ -79,27 +79,6 @@ test.describe("Command Center agent controls", () => {
}
});
test("shows an existing agent's only supported thinking level", async ({ page }) => {
const workspace = await seedMockAgentWorkspace({
repoPrefix: "agent-controls-single-thinking-",
title: "Single thinking level",
model: "max-only-thinking-stream",
thinkingOptionId: "max",
});
try {
await openAgentRoute(page, {
workspaceId: workspace.workspaceId,
agentId: workspace.agentId,
});
await expect(
page.getByTestId("agent-thinking-selector").filter({ visible: true }),
).toHaveAccessibleName("Select thinking option (Max)");
} finally {
await workspace.cleanup();
}
});
test("activates the best-matching row, not the highest-ranked group", async ({ page }) => {
// "Mode › Load Test" matches "load" at offset 0; every "Model › Mock Load Test › …" row
// matches at offset 5. Models used to outrank modes by fixed group rank, so Enter picked a
@@ -0,0 +1,33 @@
import { randomUUID } from "node:crypto";
import { test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { createIdleAgent } from "../support/helpers/archive-tab";
import { openCommandCenter } from "../support/helpers/command-center";
import { expectAgentTabActive } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
test.describe("Command center host labels", () => {
test.describe.configure({ timeout: 180_000 });
test("selecting an agent result opens its workspace tab", async ({ page }) => {
const seeded = await seedWorkspace({ repoPrefix: "command-center-agent-navigation-" });
const title = `cc-navigation-${randomUUID().slice(0, 8)}`;
try {
const agent = await createIdleAgent(seeded.client, {
cwd: seeded.repoPath,
workspaceId: seeded.workspaceId,
title,
});
await gotoAppShell(page);
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill(title);
await page.keyboard.press("Enter");
await expectAgentTabActive(page, agent.id);
} finally {
await seeded.cleanup();
}
});
});
@@ -3,7 +3,6 @@ import { expect } from "@playwright/test";
import { test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { createIdleAgent } from "../support/helpers/archive-tab";
import { expectAgentTabActive } from "../support/helpers/launcher";
import { openCommandCenter } from "../support/helpers/command-center";
import { addOfflineHostAndReload } from "../support/helpers/hosts";
import { expectAppRoute } from "../support/helpers/route-assertions";
@@ -110,13 +109,6 @@ test.describe("Command center workspaces", () => {
await expectAppRoute(page, buildHostWorkspaceRoute(getServerId(), seeded.workspaceId), {
timeout: 30_000,
});
await test.step("find an agent and open its workspace tab", async () => {
const agentPanel = await openCommandCenter(page);
await agentPanel.getByTestId("command-center-input").fill(AGENT_TITLE);
await page.keyboard.press("Enter");
await expectAgentTabActive(page, agent.id);
});
} finally {
await seeded.cleanup();
}
@@ -87,25 +87,7 @@ test("commit history shows dates and shares diff layout preferences", async ({
await expect(panel.getByTestId("git-diff-canvas")).toBeVisible();
});
test("a fresh commit says just now and its age advances", async ({ page, withWorkspace }) => {
const workspace = await withWorkspace({ prefix: "commit-relative-time-" });
const committedAt = new Date();
await createFeatureCommit(workspace.repoPath, committedAt.toISOString());
await page.clock.install({ time: committedAt });
await workspace.navigateTo();
await openChangesTreePanel(page);
await page.getByRole("button", { name: /Commits/i }).click();
const row = page.locator('[data-testid^="commit-row-"]').filter({ hasText: COMMIT_SUBJECT });
await expect(row).toContainText("just now", { timeout: 30_000 });
await page.clock.fastForward("03:00");
await expect(row).toContainText("3m ago");
});
async function createFeatureCommit(
repoPath: string,
authorDate = "2020-01-15T12:00:00Z",
): Promise<void> {
async function createFeatureCommit(repoPath: string): Promise<void> {
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoPath, stdio: "ignore" });
await writeFile(path.join(repoPath, "feature.txt"), "before\nafter\n");
execFileSync("git", ["add", "feature.txt"], { cwd: repoPath, stdio: "ignore" });
@@ -114,8 +96,8 @@ async function createFeatureCommit(
stdio: "ignore",
env: {
...process.env,
GIT_AUTHOR_DATE: authorDate,
GIT_COMMITTER_DATE: authorDate,
GIT_AUTHOR_DATE: "2020-01-15T12:00:00Z",
GIT_COMMITTER_DATE: "2020-01-15T12:00:00Z",
},
});
}
@@ -38,7 +38,6 @@ import { seedWorkspace } from "../support/helpers/seed-client";
import { hasGithubAuth, createTempGithubRepo } from "../support/helpers/github-fixtures";
import { getServerId } from "../support/helpers/server-id";
import { openFileExplorer } from "../support/helpers/file-explorer";
import { attachFileFromMenu, controlFileUploadCompletion } from "../support/helpers/composer";
const MINIMAL_PNG = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
@@ -53,29 +52,17 @@ const TEST_JSON = {
};
test.describe("Composer attachments", () => {
test("selected file shows a loading attachment until upload is acknowledged", async ({
page,
withWorkspace,
}) => {
const upload = await controlFileUploadCompletion(page);
const workspace = await withWorkspace({ prefix: "attach-upload-pending-" });
test("Plus menu shows image and GitHub options", async ({ page, withWorkspace }) => {
test.setTimeout(60_000);
const workspace = await withWorkspace({ prefix: "attach-plus-" });
await workspace.navigateTo();
await clickNewChat(page);
await expectComposerVisible(page);
upload.hold();
await attachFileFromMenu(page, TEST_JSON);
await upload.waitForUpload();
await openAttachmentMenu(page);
const pending = page.getByTestId("composer-pending-file-attachment");
await expect(pending).toContainText(TEST_JSON.name);
await expect(pending.getByRole("progressbar")).toBeVisible();
await expect(page.getByTestId("composer-file-attachment-pill")).toHaveCount(0);
upload.complete();
await expect(pending).toHaveCount(0);
await expect(page.getByTestId("composer-file-attachment-pill")).toContainText(TEST_JSON.name);
await expectComposerEditable(page);
await expect(page.getByTestId("message-input-attachment-menu-item-image")).toBeVisible();
await expect(page.getByTestId("message-input-attachment-menu-item-github")).toBeVisible();
});
test("compact Plus menu aligns attachment rows with its sheet title", async ({
@@ -114,27 +101,59 @@ test.describe("Composer attachments", () => {
).toBeVisible({ timeout: 10_000 });
});
test("attaches an issue and a pull request from the repository picker", async ({ page }) => {
test("GitHub issue attachment pill visible after search and selection", async ({ page }) => {
test.setTimeout(120_000);
if (!hasGithubAuth()) {
test.skip(true, "GitHub auth not available in this environment");
}
const ghRepo = await createTempGithubRepo({
category: "attachments",
issues: [{ title: "fix: attachment issue" }],
prs: [{ title: "feat: attachment pull request", state: "open" }],
category: "attach-issue",
issues: [{ title: "fix: attach-issue-unique-alpha" }],
prs: [{ title: "feat: attach-issue-dummy-pr", state: "open" }],
});
const handle = await openGithubWorkspace(page, ghRepo.prs[0].localPath);
try {
await clickNewChat(page);
await expectComposerVisible(page);
// Choose from the repository list. Newly created repositories are not
// necessarily available in GitHub's separate text-search index yet.
await selectGithubOption(page, "", `issue:${ghRepo.issues[0].number}`);
await expectGithubAttachmentPill(page, ghRepo.issues[0]);
await selectGithubOption(page, "", `change_request:${ghRepo.prs[0].number}`);
await expectGithubAttachmentPill(page, ghRepo.prs[0]);
await expectGithubAttachmentPill(page, ghRepo.issues[0]);
await selectGithubOption(
page,
"attach-issue-unique-alpha",
`issue:${ghRepo.issues[0].number}`,
);
await expectGithubAttachmentPill(page, {
number: ghRepo.issues[0].number,
title: ghRepo.issues[0].title,
});
} finally {
await handle.cleanup();
await ghRepo.cleanup();
}
});
test("GitHub PR attachment pill visible after search and selection", async ({ page }) => {
test.setTimeout(120_000);
if (!hasGithubAuth()) {
test.skip(true, "GitHub auth not available in this environment");
}
const ghRepo = await createTempGithubRepo({
category: "attach-pr",
prs: [{ title: "feat: attach-pr-unique-beta", state: "open" }],
});
const handle = await openGithubWorkspace(page, ghRepo.prs[0].localPath);
try {
await clickNewChat(page);
await expectComposerVisible(page);
await selectGithubOption(page, "attach-pr-unique-beta", `pr:${ghRepo.prs[0].number}`);
await expectGithubAttachmentPill(page, {
number: ghRepo.prs[0].number,
title: ghRepo.prs[0].title,
});
} finally {
await handle.cleanup();
await ghRepo.cleanup();
@@ -149,7 +168,7 @@ test.describe("Composer attachments", () => {
// needed before this can be exercised end-to-end.
});
test("attaches, previews, and removes an image before dropping a file", async ({
test("image lightbox opens on pill click and closes on Escape", async ({
page,
withWorkspace,
}) => {
@@ -159,25 +178,38 @@ test.describe("Composer attachments", () => {
await clickNewChat(page);
await expectComposerVisible(page);
await test.step("open the available attachment choices", async () => {
await openAttachmentMenu(page);
await expect(page.getByTestId("message-input-attachment-menu-item-image")).toBeVisible();
await expect(page.getByTestId("message-input-attachment-menu-item-github")).toBeVisible();
await page.keyboard.press("Escape");
});
await attachImageFromMenu(page, TEST_IMAGE);
await expectAttachmentPill(page, "composer-image-attachment-pill");
await openImageLightbox(page);
await closeImageLightbox(page);
});
await test.step("remove the image and attach a dropped file", async () => {
await removeAttachmentPill(page, "composer-image-attachment-pill", "Remove image attachment");
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0);
await dropFileOnComposer(page, TEST_JSON);
await expectAttachmentPill(page, "composer-file-attachment-pill");
});
test("image attachment pill renders after file is selected", async ({ page, withWorkspace }) => {
test.setTimeout(60_000);
const workspace = await withWorkspace({ prefix: "attach-pill-" });
await workspace.navigateTo();
await clickNewChat(page);
await expectComposerVisible(page);
await attachImageFromMenu(page, TEST_IMAGE);
await expectAttachmentPill(page, "composer-image-attachment-pill");
});
test("dropped JSON file renders as a file attachment in active chat", async ({
page,
withWorkspace,
}) => {
test.setTimeout(60_000);
const workspace = await withWorkspace({ prefix: "attach-drop-json-" });
await workspace.navigateTo();
await clickNewChat(page);
await expectComposerVisible(page);
await dropFileOnComposer(page, TEST_JSON);
await expectAttachmentPill(page, "composer-file-attachment-pill");
});
test("dropped JSON file renders as a file attachment in New Workspace", async ({ page }) => {
@@ -206,6 +238,23 @@ test.describe("Composer attachments", () => {
}
});
test("clicking the X on an image pill removes it", async ({ page, withWorkspace }) => {
test.setTimeout(60_000);
const workspace = await withWorkspace({ prefix: "attach-remove-" });
await workspace.navigateTo();
await clickNewChat(page);
await expectComposerVisible(page);
await attachImageFromMenu(page, TEST_IMAGE);
await expectAttachmentPill(page, "composer-image-attachment-pill");
await removeAttachmentPill(page, "composer-image-attachment-pill", "Remove image attachment");
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0, {
timeout: 5_000,
});
});
test("submitting while agent is running queues the message and clears the draft", async ({
page,
}) => {
@@ -9,8 +9,6 @@ import { expectWorkspaceTabVisible, openSessions } from "../support/helpers/arch
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import { getServerId } from "../support/helpers/server-id";
import { switchWorkspaceViaSidebar } from "../support/helpers/workspace-ui";
import { expectMobileAgentSidebarVisible } from "../support/helpers/sidebar";
import { expectNewWorkspaceProjectSelected } from "../support/helpers/new-workspace";
const TEST_COMMANDS = [
{
@@ -339,16 +337,6 @@ function expectPopoverFramesStable(frames: PopoverFrame[]): void {
).toBeUndefined();
}
function recordCommandRequestsByAgentId(page: Page): string[] {
const requests: string[] = [];
const record = ({ payload }: { payload: string | Buffer }) => {
if (typeof payload !== "string" || !payload.includes("list_commands_request")) return;
if (!payload.includes("draftConfig")) requests.push(payload);
};
page.on("websocket", (socket) => socket.on("framesent", record));
return requests;
}
function expectPopoverDoesNotDisappearAfterFirstVisible(frames: PopoverFrame[]): void {
const firstVisibleIndex = frames.findIndex(
(frame) =>
@@ -370,25 +358,6 @@ function expectPopoverDoesNotDisappearAfterFirstVisible(frames: PopoverFrame[]):
}
test.describe("Composer autocomplete", () => {
test("keeps New workspace off the running-agent command list before a project is chosen", async ({
page,
}) => {
const commandRequestsByAgentId = recordCommandRequestsByAgentId(page);
await page.goto("/new");
await expectNewWorkspaceProjectSelected(page, "Choose project");
await composerLocator(page).fill("/");
const popover = page
.getByTestId("composer-autocomplete-popover")
.filter({ visible: true })
.first();
await expect(popover).toContainText("Choose a project to see commands", { timeout: 30_000 });
await expect(popover).not.toContainText("/clear");
await expect(popover).not.toContainText("/exit");
expect(commandRequestsByAgentId).toEqual([]);
});
test("stays visible after returning from app-wide routes", async ({ page }) => {
await installListCommandsStub(page);
const serverId = getServerId();
@@ -702,7 +671,7 @@ test.describe("Composer autocomplete", () => {
});
await page.getByRole("button", { name: "Open menu" }).click();
await expectMobileAgentSidebarVisible(page);
await expect(page.getByTestId("sidebar-sessions")).toBeInViewport({ timeout: 5_000 });
const popoverBox = await popover.boundingBox();
expect(popoverBox).not.toBeNull();
@@ -70,7 +70,6 @@ test.describe("Composer control density across tab switches", () => {
});
await gotoWorkspace(page, workspace.workspaceId);
await clickNewChat(page);
await clickNewChat(page);
const draftTabs = page
.locator('[data-testid^="workspace-tab-draft"]')
@@ -1,4 +1,4 @@
import { writeFile } from "node:fs/promises";
import { rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
@@ -30,13 +30,10 @@ async function seedChangedAgent(repoPrefix: string) {
const workspace = await seedMockAgentWorkspace({
repoPrefix,
title: "Composer diff stat",
repo: {
withRemote: true,
// Exclude the local remote before the daemon starts observing this checkout.
files: [{ path: ".gitignore", content: "/remote.git/\n" }],
},
repo: { withRemote: true },
});
try {
await rm(path.join(workspace.cwd, "remote.git"), { recursive: true });
await writeFile(
path.join(workspace.cwd, "README.md"),
"# Temp Repo\nexport const one = 1;\nexport const two = 2;\n",
@@ -65,19 +65,6 @@ test("blank composer lines remain present and keep their measured height", async
const blankLines = "\n\n\n\n\n";
const collapsedHeight = await composerHeight(page);
await test.step("remeasure the current draft when the window narrows", async () => {
await page.setViewportSize({ width: 1280, height: 1200 });
const text = "A sentence that wraps when the editor becomes narrower. ".repeat(8);
await composer.fill(text);
await expect(composer).toHaveValue(text);
await expect.poll(() => composerHeight(page)).toBeGreaterThan(collapsedHeight);
const wideHeight = await composerHeight(page);
await page.setViewportSize({ width: 480, height: 1200 });
await expect.poll(() => composerHeight(page)).toBeGreaterThan(wideHeight);
await expect(composer).toHaveValue(text);
await page.setViewportSize({ width: 1280, height: 720 });
});
await test.step("grow the composer with blank lines followed by text", async () => {
await composer.fill(`${blankLines}x`);
await expect(composer).toHaveValue(`${blankLines}x`);
@@ -1,70 +0,0 @@
import { test } from "../support/fixtures";
import {
expectContentColumnWidth,
expectCustomContentWidthSetting,
expectDefaultContentWidthSetting,
openAppearanceSettings,
resetContentWidth,
setContentWidth,
} from "../support/helpers/content-width";
import { openFileExplorer, openFileFromExplorer } from "../support/helpers/file-explorer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
const ULTRAWIDE_VIEWPORT = { width: 2560, height: 1080 };
test("widens chat and Markdown files to the chosen content width and resets to the default", async ({
page,
}, testInfo) => {
test.setTimeout(120_000);
const agent = await seedMockAgentWorkspace({
repoPrefix: "content-width-",
title: "Content width",
initialPrompt: "Write a long paragraph.",
});
try {
await agent.client.waitForFinish(agent.agentId, 30_000);
await page.setViewportSize(ULTRAWIDE_VIEWPORT);
const assistantMessage = page.getByTestId("assistant-message").first();
const markdownPreview = page.getByTestId("markdown-preview-frame").first();
await test.step("chat starts at the default width", async () => {
await openAgentRoute(page, agent);
await expectContentColumnWidth(assistantMessage, { max: 820 });
});
await test.step("settings show the default without a reset", async () => {
await openAppearanceSettings(page);
await expectDefaultContentWidthSetting(page, 820);
});
await test.step("a custom width is saved and offers a reset", async () => {
await setContentWidth(page, 1600);
await expectCustomContentWidthSetting(page, 1600);
await page.screenshot({ path: testInfo.outputPath("settings-custom-width.png") });
});
await test.step("chat and Markdown files use the custom width", async () => {
await openAgentRoute(page, agent);
await expectContentColumnWidth(assistantMessage, { min: 1200, max: 1600 });
await page.screenshot({ path: testInfo.outputPath("chat-custom-width.png") });
await openFileExplorer(page);
await openFileFromExplorer(page, "README.md");
await expectContentColumnWidth(markdownPreview, { min: 1200, max: 1600 });
await page.screenshot({ path: testInfo.outputPath("markdown-custom-width.png") });
});
await test.step("reset returns to the default without storing it", async () => {
await openAppearanceSettings(page);
await resetContentWidth(page);
await expectDefaultContentWidthSetting(page, 820);
await openAgentRoute(page, agent);
await expectContentColumnWidth(assistantMessage, { max: 820 });
await page.screenshot({ path: testInfo.outputPath("chat-default-width.png") });
});
} finally {
await agent.cleanup();
}
});
@@ -1,71 +0,0 @@
import type { Locator } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { expectComposerVisible } from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
// Where the progress arc is painted, as its centroid relative to the ring's centre in pixels.
// Reads the rendered pixels, so any rotation that does not reach the screen counts as none.
async function progressArcCentroid(meter: Locator): Promise<{ x: number; y: number }> {
const ring = meter.locator("svg");
const progressColor = await ring
.locator("circle")
.last()
.evaluate((circle) => getComputedStyle(circle).stroke);
const screenshot = await ring.screenshot();
return ring.evaluate(
async (_svg, { png, color }) => {
const image = await createImageBitmap(
await (await fetch(`data:image/png;base64,${png}`)).blob(),
);
const canvas = new OffscreenCanvas(image.width, image.height);
const context = canvas.getContext("2d")!;
context.drawImage(image, 0, 0);
const { data } = context.getImageData(0, 0, image.width, image.height);
const [r, g, b] = color.match(/\d+/g)!.map(Number);
let sumX = 0;
let sumY = 0;
let count = 0;
for (let y = 0; y < image.height; y += 1) {
for (let x = 0; x < image.width; x += 1) {
const i = (y * image.width + x) * 4;
const distance =
Math.abs(data[i] - r) + Math.abs(data[i + 1] - g) + Math.abs(data[i + 2] - b);
if (distance < 40) {
sumX += x;
sumY += y;
count += 1;
}
}
}
if (count === 0) {
throw new Error(`No pixels painted in the progress colour ${color}`);
}
return { x: sumX / count - image.width / 2, y: sumY / count - image.height / 2 };
},
{ png: screenshot.toString("base64"), color: progressColor },
);
}
test.describe("context window meter", () => {
test("draws usage clockwise from twelve o'clock", async ({ page }) => {
test.setTimeout(180_000);
// 32,000 of the mock's 128,000-token window: a quarter, from twelve to three o'clock.
const session = await seedMockAgentWorkspace({
repoPrefix: "context-window-meter-",
title: "Context window meter e2e",
initialPrompt: "emit 32000 byte file agent stream payload",
});
try {
await openAgentRoute(page, session);
await expectComposerVisible(page);
const meter = page.getByTestId("context-window-meter");
await expect(meter).toHaveAccessibleName(/25%/, { timeout: 30_000 });
const centroid = await progressArcCentroid(meter);
expect(centroid.x).toBeGreaterThan(1);
expect(centroid.y).toBeLessThan(-1);
} finally {
await session.cleanup();
}
});
});
@@ -1,130 +0,0 @@
import type { createCreationScenario } from "../support/helpers/creation";
import { expect } from "../support/fixtures";
import { test } from "../support/creation-fixtures";
for (const isolation of ["local", "worktree"] as const) {
test(`repeated Create clicks before a render create only one ${isolation} workspace`, async ({
creation,
}) => {
await creation.openWorkspaceForm(isolation);
await creation.submitRepeatedly("Create");
await creation.expectOneCreatedWorkspace();
});
}
test("repeated Send clicks before a render create only one agent", async ({ creation }) => {
await creation.openAgentDraft();
await creation.submitRepeatedly("Send message", "Start exactly one agent for this prompt.");
await creation.expectPromptVisible();
await creation.expectAgentCount(1);
});
test("retrying the app's agent creation returns the same agent for every attempt", async ({
creation,
agentRetries,
}) => {
await creation.openAgentDraft();
await creation.submitPrompt("Start exactly one agent for this prompt.");
expect(new Set(await agentRetries.completedAgentIds()).size).toBe(1);
await creation.expectAgentCount(1);
});
test("the first prompt still names an agent created with a receipt", async ({ creation }) => {
await creation.openAgentDraft();
await creation.submitPrompt("Name this new agent from its first prompt.");
await creation.expectPromptVisible();
await creation.expectAgentTitle("Name this new agent from its first prompt.");
});
test("reconnecting after a lost creation response recovers the agent and initial message", async ({
creation,
promptRetry,
}) => {
await creation.openAgentDraft();
promptRetry.holdAcknowledgement();
await creation.submitPrompt("Deliver this initial prompt once.");
await promptRetry.waitForDeliveredPrompt();
await promptRetry.disconnectAndReconnect();
await promptRetry.expectSameAgentAndMessage();
await creation.expectAgentCount(1);
});
test("a remounted draft reconciles its original creation after reconnect", async ({
creation,
promptRetry,
}) => {
await creation.openAgentDraft();
promptRetry.holdAcknowledgement();
await creation.submitPrompt("Deliver this initial prompt once.");
await promptRetry.waitForDeliveredPrompt();
await creation.evictAndReturnToDraft();
await creation.expectPromptVisible("Deliver this initial prompt once.");
await promptRetry.disconnectAndReconnect();
await promptRetry.expectSameAgentAndMessage();
await creation.expectAgentCount(1);
});
test("repeated new-workspace prompt submissions create one workspace and one agent", async ({
creation,
}) => {
await creation.openWorkspaceForm("local");
await creation.submitRepeatedly("Create", "Start one agent in one new workspace.");
await creation.expectPromptVisible();
await creation.expectOneCreatedWorkspace();
await creation.expectAgentCount(1);
});
test("separate drafts can intentionally create two agents in the same workspace", async ({
creation,
delayedCreation,
}) => {
await creation.openAgentDraft();
await creation.submitPrompt("Start the first agent.");
await creation.expectPromptVisible("Start the first agent.");
await delayedCreation.waitForDelayedCreatedStatus();
await creation.startAnotherDraft();
await creation.submitPrompt("Start the second agent.");
await creation.expectPromptVisible("Start the second agent.");
await delayedCreation.waitForDelayedCreatedStatus();
delayedCreation.release();
await creation.expectAgentCount(2);
});
test("new workspace navigation and optimistic prompt precede agent initialization", async ({
creation,
startup,
delayedCreation,
}) => {
await creation.openWorkspaceForm("worktree");
await creation.submitPrompt("Show this prompt while the agent is starting.", "Create");
await creation.expectAgentStillStarting();
delayedCreation.expectSingleWorkspaceIntent();
delayedCreation.release();
await startup.release();
await creation.expectOneCreatedWorkspace();
await creation.expectAgentCount(1);
});
for (const scenario of [
{ suffix: "", prepare: async () => {} },
{
suffix: " after remount",
prepare: async (creation: Awaited<ReturnType<typeof createCreationScenario>>) =>
creation.evictAndReturnToDraft(),
},
]) {
test(`retrying failed agent initialization preserves its workspace${scenario.suffix}`, async ({
creation,
startup,
}) => {
await creation.openWorkspaceForm("local");
await creation.submitPrompt("Retry this workspace and agent together.", "Create");
await creation.expectAgentStillStarting();
await startup.fail();
await creation.expectStartupFailure();
await creation.expectOneCreatedWorkspace();
await scenario.prepare(creation);
await creation.submitPrompt("Retry this workspace and agent together.");
await creation.expectAgentCount(1);
});
}
@@ -1,230 +0,0 @@
/* eslint-disable no-empty-pattern -- Playwright reads fixture dependencies from destructured parameters. */
import { randomUUID } from "node:crypto";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { metroTest, expect } from "../support/fixtures";
import { connectDaemonClient } from "../support/helpers/daemon-client-loader";
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
import { withProjectOwnership } from "../support/helpers/project-ownership";
import { createTempGitRepo } from "../support/helpers/workspace";
import type { Page } from "@playwright/test";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
import { buildSeededHost, buildCreateAgentPreferences } from "../support/helpers/daemon-registry";
import { seedWorkspace } from "../support/helpers/seed-client";
import {
openNewWorkspaceComposer,
selectWorkspaceIsolation,
loadSessionMessageReaders,
} from "../support/helpers/new-workspace";
import { fillComposerDraft } from "../support/helpers/composer";
import { pressSubmitBeforeTheNextRender } from "../support/helpers/creation";
import { createAgentTabFromMenu } from "../support/helpers/workspace-tabs";
for (const version of ["0.2.5", "0.7.2", "0.8.0"]) {
let daemon: Awaited<ReturnType<typeof startIsolatedHostDaemon>>;
const test = metroTest.extend<{
client: DaemonClient;
gate: Awaited<ReturnType<typeof holdLegacyAgentCreation>>;
repo: Awaited<ReturnType<typeof createTempGitRepo>>;
project: Awaited<ReturnType<typeof seedWorkspace>>;
}>({
gate: async ({ page }, provide) => {
const gate = await holdLegacyAgentCreation(page, daemon.port);
await provide(gate);
gate.release();
},
client: async ({}, provide) => {
const client = withProjectOwnership(
await connectDaemonClient<DaemonClient>({
clientIdPrefix: "legacy-creation",
port: daemon.port,
}),
);
await provide(client);
await client.close();
},
repo: async ({}, provide) => {
const repo = await createTempGitRepo("legacy-creation-");
await provide(repo);
await repo.cleanup();
},
project: async ({}, provide) => {
const project = await seedWorkspace({ repoPrefix: "legacy-app-", port: daemon.port });
await provide(project);
await project.cleanup();
},
});
test.describe(`published daemon ${version}`, () => {
test.beforeAll(async () => {
test.setTimeout(120_000);
daemon = await startIsolatedHostDaemon(`srv_creation_${randomUUID()}`, {
publishedVersion: version,
});
});
test.afterAll(async () => {
await daemon?.close();
});
for (const kind of ["workspace", "agent"] as const) {
test(`current client creates a keyed ${kind} and initial prompt on a published old daemon`, async ({
repo,
client,
}) => {
const prompt = `Create ${kind}: emit 1 coalesced agent stream updates`;
const features = client.getLastServerInfoMessage()?.features;
expect(features?.creationLifecycle).not.toBe(true);
expect(features?.workspaceRequestReceipts).not.toBe(true);
expect(features?.agentRequestReceipts === true).toBe(version === "0.8.0");
const create = await prepareCreation(client, kind, repo.path, prompt);
const [agent, duplicate] = await Promise.all([create(), create()]);
expect(duplicate.id).toBe(agent.id);
expect(agent.title).toBe(prompt);
await client.waitForFinish(agent.id, 20_000);
const agents = await client.fetchAgents();
expect(agents.entries).toHaveLength(1);
expect(agents.entries[0]!.agent.title).toBe(prompt);
const timeline = await client.fetchAgentTimeline(agent.id);
expect(
timeline.entries.filter(
({ item }) => item.type === "user_message" && item.text === prompt,
),
).toHaveLength(1);
});
}
test("current app creates a workspace, navigates before agent startup, and creates another agent on an old daemon", async ({
page,
project,
client,
gate,
}) => {
await openOldHost(page, project.workspaceId);
await openNewWorkspaceComposer(page, project);
await selectWorkspaceIsolation(page, "worktree");
const prompt = "Create this workspace: emit 1 coalesced agent stream updates";
await fillComposerDraft(page, prompt);
await pressSubmitBeforeTheNextRender(page, "Create");
await gate.waitForRequest();
await expect(page).toHaveURL(/\/workspace\//);
await expect(
page.getByTestId("user-message").filter({ visible: true }).filter({ hasText: prompt }),
).toBeVisible();
expect((await client.fetchAgents()).entries).toHaveLength(0);
const workspaces = (await client.fetchWorkspaces()).entries.filter(
(workspace) =>
workspace.projectId === project.projectId && workspace.id !== project.workspaceId,
);
expect(workspaces).toHaveLength(1);
gate.release();
await expect.poll(async () => (await client.fetchAgents()).entries.length).toBe(1);
const first = (await client.fetchAgents()).entries[0]!.agent;
await client.waitForFinish(first.id, 20_000);
await expectPromptOnce(client, first.id, prompt);
await expect(
page.getByTestId(`workspace-tab-agent_${first.id}`).filter({ visible: true }),
).toHaveText(prompt);
await createAgentTabFromMenu(page);
const secondPrompt = "Create another agent: emit 1 coalesced agent stream updates";
await fillComposerDraft(page, secondPrompt);
await pressSubmitBeforeTheNextRender(page, "Send message");
await expect.poll(async () => (await client.fetchAgents()).entries.length).toBe(2);
const second = (await client.fetchAgents()).entries.find(
({ agent }) => agent.id !== first.id,
)!.agent;
expect(second.workspaceId).toBe(first.workspaceId);
await client.waitForFinish(second.id, 20_000);
await expectPromptOnce(client, second.id, secondPrompt);
await expect(
page.getByTestId(`workspace-tab-agent_${second.id}`).filter({ visible: true }),
).toHaveText(secondPrompt);
await expect(
page
.getByTestId("user-message")
.filter({ visible: true })
.filter({ hasText: secondPrompt }),
).toBeVisible();
await page.screenshot({ path: test.info().outputPath("old-daemon-creation.png") });
});
async function expectPromptOnce(client: DaemonClient, agentId: string, text: string) {
const timeline = await client.fetchAgentTimeline(agentId);
expect(
timeline.entries.filter(({ item }) => item.type === "user_message" && item.text === text),
).toHaveLength(1);
}
async function openOldHost(page: Page, workspaceId: string) {
const host = buildSeededHost({
serverId: daemon.serverId,
endpoint: `127.0.0.1:${daemon.port}`,
nowIso: new Date().toISOString(),
});
await page.addInitScript(
({ seededHost, preferences }) => {
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededHost]));
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
},
{ seededHost: host, preferences: buildCreateAgentPreferences() },
);
await page.goto(buildHostWorkspaceRoute(daemon.serverId, workspaceId));
}
async function prepareCreation(
client: DaemonClient,
kind: "workspace" | "agent",
cwd: string,
prompt: string,
) {
const input = { provider: "mock", cwd, initialPrompt: prompt };
if (kind === "workspace")
return async () => {
const result = await client.createWorkspace({
idempotencyKey: "workspace-intent",
source: { kind: "worktree", cwd, worktreeSlug: "created-once" },
agent: input,
});
expect(result.error).toBeNull();
expect(result.workspace?.workspaceDirectory).not.toBe(cwd);
return result.agent!;
};
const workspace = await client.createWorkspace({
idempotencyKey: "empty-workspace",
source: { kind: "directory", path: cwd },
});
return () =>
client.createAgent({
...input,
workspaceId: workspace.workspace!.id,
idempotencyKey: "agent-intent",
});
}
});
}
async function holdLegacyAgentCreation(page: Page, port: number) {
const readers = await loadSessionMessageReaders();
const arrived = Promise.withResolvers<void>();
let release = () => {};
let holding = true;
await page.routeWebSocket(new RegExp(`:${port}/ws`), (browser) => {
const server = browser.connectToServer();
browser.onMessage((frame) => {
if (holding && readers.client(frame)?.type === "create_agent_request") {
holding = false;
release = () => server.send(frame);
arrived.resolve();
} else server.send(frame);
});
server.onMessage((frame) => browser.send(frame));
});
return {
waitForRequest: () => arrived.promise,
release() {
const send = release;
release = () => {};
send();
},
};
}

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