Compare commits

..
Author SHA1 Message Date
Mohamed Boudra aa1dc3ac21 chore(release): cut 0.1.29 2026-03-18 14:33:44 +07:00
Mohamed Boudra 00659bc32d docs(changelog): note 0.1.29 hotfix 2026-03-18 14:33:35 +07:00
Mohamed Boudra 6c11a082c5 Fix agent spawn PATH resolution and add provider binary checks to status
Shell env PATH was being overridden by process.env PATH in
applyProviderEnv, causing agent spawns to fail with ENOENT when the
daemon runs from the Tauri desktop app (minimal GUI PATH). Flip the
merge order so login shell env wins.

Add a Providers section to `paseo daemon status` that resolves each
agent binary (claude, codex, opencode) and runs --version using the
same applyProviderEnv environment the daemon uses to spawn agents.
2026-03-18 14:31:16 +07:00
1302 changed files with 77041 additions and 134479 deletions
-15
View File
@@ -1,15 +0,0 @@
# These are supported funding model platforms
github: [boudra]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
+19 -32
View File
@@ -4,7 +4,6 @@ on:
push:
tags:
- "v*"
- "android-v*"
workflow_dispatch:
inputs:
tag:
@@ -17,7 +16,7 @@ concurrency:
cancel-in-progress: false
env:
SOURCE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
jobs:
publish-android-apk:
@@ -32,36 +31,6 @@ jobs:
fetch-depth: 0
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
- name: Resolve release tag
shell: bash
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
- name: Ensure GitHub release exists
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
if gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" >/dev/null 2>&1; then
exit 0
fi
release_args=(
release create "$RELEASE_TAG"
--repo "${{ github.repository }}"
--title "Paseo $RELEASE_TAG"
--generate-notes
)
if [[ "$IS_PRERELEASE" == "true" ]]; then
release_args+=(--prerelease)
fi
if ! gh "${release_args[@]}"; then
echo "Release creation raced with another workflow; continuing."
fi
- name: Setup Node
uses: actions/setup-node@v4
with:
@@ -125,6 +94,24 @@ jobs:
echo "asset_name=$asset_name" >> "$GITHUB_OUTPUT"
echo "asset_path=$asset_path" >> "$GITHUB_OUTPUT"
- name: Wait for GitHub release tag
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
for attempt in $(seq 1 90); do
if gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" >/dev/null 2>&1; then
echo "Found release for tag $RELEASE_TAG"
exit 0
fi
echo "Release for $RELEASE_TAG is not available yet (attempt $attempt/90)."
sleep 20
done
echo "Timed out waiting for GitHub release tag $RELEASE_TAG."
exit 1
- name: Upload APK to GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-209
View File
@@ -1,209 +0,0 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
jobs:
format:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm install
- name: Check formatting
run: npx biome format .
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm install
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
- name: Build relay dependency
run: npm run build --workspace=@getpaseo/relay
- name: Typecheck all packages
run: npm run typecheck
server-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Fetch origin/main (worktree tests)
run: git fetch --no-tags origin main:refs/remotes/origin/main
- name: Install dependencies
run: npm install
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
- name: Build relay dependency
run: npm run build --workspace=@getpaseo/relay
- name: Run server tests
run: npm run test --workspace=@getpaseo/server
env:
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
server-tests-windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm install
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
- name: Build relay dependency
run: npm run build --workspace=@getpaseo/relay
- name: Run Windows-critical server tests
working-directory: packages/server
run: npx vitest run src/utils/executable.test.ts src/utils/spawn.test.ts src/utils/run-git-command.test.ts src/server/agent/provider-registry.test.ts src/server/agent/provider-launch-config.test.ts src/server/agent/provider-snapshot-manager.test.ts src/server/persisted-config.test.ts
app-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm install
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
- name: Run app unit tests
run: npm run test --workspace=@getpaseo/app
playwright:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm install
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
- name: Build relay dependency
run: npm run build --workspace=@getpaseo/relay
- name: Build server dependency
run: npm run build --workspace=@getpaseo/server
- name: Install agent CLIs for provider tests
run: npm install -g @openai/codex@0.105.0 opencode-ai
- name: Run Playwright E2E tests
run: npm run test:e2e --workspace=@getpaseo/app
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
- name: Upload test artifacts
uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-results
path: |
packages/app/test-results/
packages/app/playwright-report/
retention-days: 7
relay-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm install
- name: Build relay
run: npm run build --workspace=@getpaseo/relay
- name: Run relay tests
run: npm run test --workspace=@getpaseo/relay
cli-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm install
- name: Install agent CLIs for provider tests
run: npm install -g @openai/codex@0.105.0 opencode-ai
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
- name: Run CLI tests
run: npm run test --workspace=@getpaseo/cli
env:
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: '0'
PASEO_DICTATION_ENABLED: '0'
PASEO_VOICE_MODE_ENABLED: '0'
-5
View File
@@ -4,9 +4,7 @@ on:
push:
tags:
- 'v*'
- '!v*-rc.*'
- 'app-v*'
- '!app-v*-rc.*'
workflow_dispatch:
jobs:
@@ -28,9 +26,6 @@ jobs:
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
- name: Typecheck
run: npm run typecheck --workspace=@getpaseo/app
-3
View File
@@ -9,13 +9,10 @@ on:
- 'package-lock.json'
- 'patches/**'
- '.github/workflows/deploy-website.yml'
release:
types: [published, edited]
workflow_dispatch:
jobs:
deploy:
if: ${{ github.event_name != 'release' || (!github.event.release.prerelease && !github.event.release.draft) }}
runs-on: ubuntu-latest
steps:
+442 -245
View File
@@ -3,21 +3,21 @@ name: Desktop Release
on:
push:
tags:
- 'v*'
- 'desktop-v*'
- 'desktop-macos-v*'
- 'desktop-linux-v*'
- 'desktop-windows-v*'
- "v*"
- "desktop-v*"
- "desktop-macos-v*"
- "desktop-linux-v*"
- "desktop-windows-v*"
workflow_dispatch:
inputs:
tag:
description: 'Existing tag to build (e.g. v0.1.0)'
description: "Existing tag to build (e.g. v0.1.0)"
required: true
type: string
platform:
description: 'Optional desktop platform to build.'
description: "Optional desktop platform to build."
required: false
default: 'all'
default: "all"
type: choice
options:
- all
@@ -31,55 +31,18 @@ concurrency:
env:
SOURCE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
DESKTOP_WORKSPACE: '@getpaseo/desktop'
DESKTOP_PACKAGE_PATH: 'packages/desktop'
jobs:
create-release:
if: ${{ (github.event_name == 'push' && !startsWith(github.ref_name, 'desktop-macos-v') && !startsWith(github.ref_name, 'desktop-linux-v') && !startsWith(github.ref_name, 'desktop-windows-v')) || (github.event_name == 'workflow_dispatch' && github.event.inputs.platform == 'all') }}
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: scripts
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
- name: Resolve release metadata
shell: bash
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
- name: Create GitHub release
if: env.IS_SMOKE_TAG != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" > /dev/null 2>&1; then
echo "Release $RELEASE_TAG already exists, skipping creation"
else
prerelease_flag=""
if [[ "$IS_PRERELEASE" == "true" ]]; then
prerelease_flag="--prerelease"
fi
gh release create "$RELEASE_TAG" \
--repo "${{ github.repository }}" \
--title "Paseo $RELEASE_TAG" \
--generate-notes \
$prerelease_flag
fi
publish-macos:
needs: [create-release]
if: ${{ always() && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') && ((github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'macos')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-macos-v')))) }}
if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'macos')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-macos-v'))) }}
strategy:
fail-fast: false
matrix:
include:
- runner: macos-14
electron_arch: arm64
rust_target: aarch64-apple-darwin
- runner: macos-15-intel
electron_arch: x64
rust_target: x86_64-apple-darwin
permissions:
contents: write
packages: read
@@ -93,169 +56,185 @@ jobs:
- name: Resolve release metadata
shell: bash
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
run: |
set -euo pipefail
source_tag="${SOURCE_TAG}"
if [[ "$source_tag" =~ ^(desktop-(windows|linux|macos)-|desktop-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then
release_tag="v${BASH_REMATCH[3]}"
else
release_tag="$source_tag"
fi
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
version="${release_tag#v}"
echo "DESKTOP_VERSION=$version" >> "$GITHUB_ENV"
if [[ "$source_tag" == *gha-smoke* ]]; then
echo "IS_SMOKE_TAG=true" >> "$GITHUB_ENV"
else
echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV"
fi
- name: Set desktop version from tag
shell: bash
run: |
node <<'NODE'
const fs = require('node:fs');
const path = require('node:path');
const version = process.env.DESKTOP_VERSION;
if (!version) throw new Error('DESKTOP_VERSION env var is missing');
console.log(`Setting desktop version to ${version}`);
const tauriConfPath = path.join('packages', 'desktop', 'src-tauri', 'tauri.conf.json');
const tauriConfText = fs.readFileSync(tauriConfPath, 'utf8');
const tauriRe = /("version"\s*:\s*")([^"]+)(")/;
if (!tauriRe.test(tauriConfText)) throw new Error('Failed to find version in tauri.conf.json');
fs.writeFileSync(tauriConfPath, tauriConfText.replace(tauriRe, `$1${version}$3`));
const cargoTomlPath = path.join('packages', 'desktop', 'src-tauri', 'Cargo.toml');
const lines = fs.readFileSync(cargoTomlPath, 'utf8').split(/\r?\n/);
let inPackage = false, updated = false;
const result = lines.map((line) => {
if (/^\[package\]\s*$/.test(line)) inPackage = true;
else if (inPackage && /^\[/.test(line)) inPackage = false;
if (inPackage && /^version\s*=\s*".*"\s*$/.test(line)) {
updated = true;
return `version = "${version}"`;
}
return line;
});
if (!updated) throw new Error('Failed to update Cargo.toml version');
fs.writeFileSync(cargoTomlPath, result.join('\n') + '\n');
NODE
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
node-version: "22"
cache: "npm"
cache-dependency-path: package-lock.json
registry-url: 'https://npm.pkg.github.com'
scope: '@boudra'
registry-url: "https://npm.pkg.github.com"
scope: "@boudra"
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.rust_target }}
- name: Restore Rust cache
uses: Swatinem/rust-cache@v2
with:
shared-key: desktop-release-macos-${{ matrix.rust_target }}
workspaces: |
.
packages/desktop/src-tauri -> target
- name: Install JS dependencies
run: npm ci
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Set desktop package version from tag
shell: bash
run: |
node <<'NODE'
const fs = require('node:fs');
const path = require('node:path');
const version = process.env.DESKTOP_VERSION;
if (!version) throw new Error('DESKTOP_VERSION env var is missing');
const packageJsonPath = path.join(process.env.DESKTOP_PACKAGE_PATH, 'package.json');
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
packageJson.version = version;
fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
NODE
- name: Build web app for desktop
- name: Build web app for Tauri
run: npm run build:web --workspace=@getpaseo/app
- name: Build desktop release
shell: bash
- name: Build managed runtime
run: npm run prepare:managed-runtime --workspace=@getpaseo/desktop
- name: Validate managed runtime bundle
run: npm run validate:managed-runtime --workspace=@getpaseo/desktop
- name: Import Apple code-signing certificate
uses: apple-actions/import-codesign-certs@v3
with:
p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }}
p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
- name: Sign bundled managed runtime
env:
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
run: node ./packages/desktop/scripts/sign-managed-runtime-macos.mjs
- name: Detect existing GitHub release state
if: env.IS_SMOKE_TAG != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
EP_GH_IGNORE_TIME: true
CSC_LINK: ${{ secrets.APPLE_CERTIFICATE }}
CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
shell: bash
run: |
set -euo pipefail
publish_mode="never"
publish_args=()
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
publish_mode="always"
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
if release_draft="$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json isDraft --jq '.isDraft' 2>/dev/null)"; then
:
else
release_draft="false"
fi
echo "RELEASE_DRAFT=$release_draft" >> "$GITHUB_ENV"
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --mac --${{ matrix.electron_arch }} "${publish_args[@]}"
- name: Upload manifest artifact
- name: Build and publish macOS Tauri release
if: env.IS_SMOKE_TAG != 'true'
uses: actions/upload-artifact@v4
id: tauri_build
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
with:
name: mac-manifest-${{ matrix.electron_arch }}
path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/latest-mac.yml
retention-days: 1
projectPath: packages/desktop
tagName: ${{ env.RELEASE_TAG }}
releaseName: Paseo ${{ env.RELEASE_TAG }}
releaseBody: See the assets to download and install this version.
releaseDraft: ${{ env.RELEASE_DRAFT }}
prerelease: false
args: --target ${{ matrix.rust_target }}
finalize-mac-manifest:
needs: [publish-macos]
if: ${{ needs.publish-macos.result == 'success' }}
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: scripts
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
- name: Resolve release tag
shell: bash
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
- name: Download manifest artifacts
if: env.IS_SMOKE_TAG != 'true'
uses: actions/download-artifact@v4
with:
pattern: mac-manifest-*
- name: Merge manifests
- name: Notarize and re-upload DMG
if: env.IS_SMOKE_TAG != 'true'
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
artifacts='${{ steps.tauri_build.outputs.artifactPaths }}'
dmg_path=$(echo "$artifacts" | jq -r '.[] | select(endswith(".dmg"))')
if [ -z "$dmg_path" ]; then
echo "::error::No DMG found in tauri build artifacts"
exit 1
fi
echo "DMG: $dmg_path"
node <<'NODE'
const fs = require('node:fs');
echo "Signing DMG..."
codesign --force --sign "$APPLE_SIGNING_IDENTITY" --timestamp "$dmg_path"
// Simple YAML parser for electron-builder's latest-mac.yml format
function parseManifest(text) {
const lines = text.split('\n');
const result = { files: [] };
let currentFile = null;
echo "Submitting DMG for notarization..."
xcrun notarytool submit "$dmg_path" \
--apple-id "$APPLE_ID" \
--password "$APPLE_PASSWORD" \
--team-id "$APPLE_TEAM_ID" \
--wait
for (const line of lines) {
if (line.startsWith('version:')) result.version = line.split(': ')[1].trim();
else if (line.startsWith('path:')) result.path = line.split(': ')[1].trim();
else if (line.startsWith('sha512:') && !currentFile) result.sha512 = line.split(': ')[1].trim();
else if (line.startsWith('releaseDate:')) result.releaseDate = line.split(': ')[1].trim().replace(/'/g, '');
else if (line.trim().startsWith('- url:')) {
currentFile = { url: line.trim().replace('- url: ', '') };
result.files.push(currentFile);
} else if (line.trim().startsWith('sha512:') && currentFile) {
currentFile.sha512 = line.trim().split(': ')[1].trim();
} else if (line.trim().startsWith('size:') && currentFile) {
currentFile.size = parseInt(line.trim().split(': ')[1].trim(), 10);
currentFile = null;
}
}
return result;
}
echo "Stapling notarization ticket..."
xcrun stapler staple "$dmg_path"
function toYaml(manifest) {
let out = `version: ${manifest.version}\n`;
out += `files:\n`;
for (const f of manifest.files) {
out += ` - url: ${f.url}\n`;
out += ` sha512: ${f.sha512}\n`;
out += ` size: ${f.size}\n`;
}
out += `path: ${manifest.path}\n`;
out += `sha512: ${manifest.sha512}\n`;
out += `releaseDate: '${manifest.releaseDate}'\n`;
return out;
}
echo "Verifying..."
spctl --assess --type install --verbose "$dmg_path"
const arm64Text = fs.readFileSync('mac-manifest-arm64/latest-mac.yml', 'utf8');
const x64Text = fs.readFileSync('mac-manifest-x64/latest-mac.yml', 'utf8');
echo "Replacing release asset with notarized DMG..."
gh release upload "$RELEASE_TAG" "$dmg_path" --repo "${{ github.repository }}" --clobber
const arm64 = parseManifest(arm64Text);
const x64 = parseManifest(x64Text);
// Merge: all files from both, default path points to arm64 zip
const merged = {
version: arm64.version,
files: [...arm64.files, ...x64.files],
path: arm64.path,
sha512: arm64.sha512,
releaseDate: arm64.releaseDate || x64.releaseDate,
};
const output = toYaml(merged);
fs.writeFileSync('latest-mac.yml', output);
console.log('Merged manifest:\n' + output);
NODE
- name: Upload merged manifest to release
if: env.IS_SMOKE_TAG != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release upload "$RELEASE_TAG" latest-mac.yml --clobber --repo "${{ github.repository }}"
- name: Build macOS app (smoke only)
if: env.IS_SMOKE_TAG == 'true'
run: npm run tauri --workspace=@getpaseo/desktop build -- --target ${{ matrix.rust_target }} --no-bundle
publish-linux:
needs: [create-release]
if: ${{ always() && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') && ((github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'linux')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-linux-v')))) }}
if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'linux')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-linux-v'))) }}
permissions:
contents: write
packages: read
@@ -269,60 +248,211 @@ jobs:
- name: Resolve release metadata
shell: bash
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
run: |
set -euo pipefail
source_tag="${SOURCE_TAG}"
if [[ "$source_tag" =~ ^(desktop-(windows|linux|macos)-|desktop-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then
release_tag="v${BASH_REMATCH[3]}"
else
release_tag="$source_tag"
fi
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
version="${release_tag#v}"
echo "DESKTOP_VERSION=$version" >> "$GITHUB_ENV"
if [[ "$source_tag" == *gha-smoke* ]]; then
echo "IS_SMOKE_TAG=true" >> "$GITHUB_ENV"
else
echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV"
fi
- name: Set desktop version from tag
shell: bash
run: |
node <<'NODE'
const fs = require('node:fs');
const path = require('node:path');
const version = process.env.DESKTOP_VERSION;
if (!version) throw new Error('DESKTOP_VERSION env var is missing');
console.log(`Setting desktop version to ${version}`);
const tauriConfPath = path.join('packages', 'desktop', 'src-tauri', 'tauri.conf.json');
const tauriConfText = fs.readFileSync(tauriConfPath, 'utf8');
const tauriRe = /("version"\s*:\s*")([^"]+)(")/;
if (!tauriRe.test(tauriConfText)) throw new Error('Failed to find version in tauri.conf.json');
fs.writeFileSync(tauriConfPath, tauriConfText.replace(tauriRe, `$1${version}$3`));
const cargoTomlPath = path.join('packages', 'desktop', 'src-tauri', 'Cargo.toml');
const lines = fs.readFileSync(cargoTomlPath, 'utf8').split(/\r?\n/);
let inPackage = false, updated = false;
const result = lines.map((line) => {
if (/^\[package\]\s*$/.test(line)) inPackage = true;
else if (inPackage && /^\[/.test(line)) inPackage = false;
if (inPackage && /^version\s*=\s*".*"\s*$/.test(line)) {
updated = true;
return `version = "${version}"`;
}
return line;
});
if (!updated) throw new Error('Failed to update Cargo.toml version');
fs.writeFileSync(cargoTomlPath, result.join('\n') + '\n');
NODE
- name: Install Linux packaging dependencies
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libappindicator3-dev librsvg2-dev patchelf libfuse2
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
node-version: "22"
cache: "npm"
cache-dependency-path: package-lock.json
registry-url: 'https://npm.pkg.github.com'
scope: '@boudra'
registry-url: "https://npm.pkg.github.com"
scope: "@boudra"
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
- name: Restore Rust cache
uses: Swatinem/rust-cache@v2
with:
shared-key: desktop-release-linux
workspaces: |
.
packages/desktop/src-tauri -> target
- name: Install JS dependencies
run: npm ci
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Set desktop package version from tag
shell: bash
run: |
node <<'NODE'
const fs = require('node:fs');
const path = require('node:path');
const version = process.env.DESKTOP_VERSION;
if (!version) throw new Error('DESKTOP_VERSION env var is missing');
const packageJsonPath = path.join(process.env.DESKTOP_PACKAGE_PATH, 'package.json');
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
packageJson.version = version;
fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
NODE
- name: Build web app for desktop
- name: Build web app for Tauri
run: npm run build:web --workspace=@getpaseo/app
- name: Build desktop release
- name: Build managed runtime
run: npm run prepare:managed-runtime --workspace=@getpaseo/desktop
- name: Validate managed runtime bundle
run: npm run validate:managed-runtime --workspace=@getpaseo/desktop
- name: Strip CUDA dependencies from onnxruntime
shell: bash
run: |
find packages/desktop/src-tauri/resources/managed-runtime -path '*/onnxruntime-node/bin/*' \( -name '*cuda*' -o -name '*tensorrt*' \) -delete || true
# Remove CUDA shared library references from onnxruntime .so files so linuxdeploy
# doesn't try to bundle them (they're optional runtime deps, not needed for CPU inference)
for f in $(find packages/desktop/src-tauri/resources/managed-runtime -path '*/onnxruntime-node/bin/*' \( -name '*.so' -o -name '*.so.*' \)); do
for lib in $(patchelf --print-needed "$f" 2>/dev/null | grep -iE 'cublas|cudnn|cudart|cufft|curand|cusolver|cusparse|nccl|nvrtc|tensorrt|nvinfer'); do
echo "Removing needed $lib from $f"
patchelf --remove-needed "$lib" "$f"
done
done
- name: Detect existing GitHub release state
if: env.IS_SMOKE_TAG != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
EP_GH_IGNORE_TIME: true
shell: bash
run: |
set -euo pipefail
publish_mode="never"
publish_args=()
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
publish_mode="always"
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
if release_draft="$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json isDraft --jq '.isDraft' 2>/dev/null)"; then
:
else
release_draft="false"
fi
echo "RELEASE_DRAFT=$release_draft" >> "$GITHUB_ENV"
- name: Build Linux Tauri release
if: env.IS_SMOKE_TAG != 'true'
id: linux_tauri
continue-on-error: true
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
NO_STRIP: "1"
APPIMAGE_EXTRACT_AND_RUN: "1"
shell: bash
run: |
set -euo pipefail
npm run tauri --workspace=@getpaseo/desktop build -- --bundles appimage
- name: Attempt manual Linux AppImage fallback
if: env.IS_SMOKE_TAG != 'true' && steps.linux_tauri.outcome == 'failure'
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
shell: bash
run: |
set -euxo pipefail
appimage_dir="packages/desktop/src-tauri/target/release/bundle/appimage"
appdir_path="$appimage_dir/Paseo.AppDir"
canonical_appimage="$appimage_dir/Paseo_${DESKTOP_VERSION}_amd64.AppImage"
existing_appimage="$(find "$appimage_dir" -maxdepth 1 -type f -name '*.AppImage' | head -n 1)"
if [ -n "$existing_appimage" ]; then
if [ "$existing_appimage" != "$canonical_appimage" ]; then
mv "$existing_appimage" "$canonical_appimage"
fi
if [ ! -f "$canonical_appimage.sig" ]; then
npx tauri signer sign "$canonical_appimage"
fi
exit 0
fi
if [ ! -d "$appdir_path" ]; then
echo "::error::AppDir was not generated at $appdir_path"
exit 1
fi
cp --remove-destination "$appdir_path/usr/share/applications/Paseo.desktop" "$appdir_path/Paseo.desktop"
cp --remove-destination "$appdir_path/Paseo.png" "$appdir_path/.DirIcon"
env | sort | grep -E '^(APPIMAGE|DESKTOP_VERSION|NO_STRIP|RELEASE_TAG|SOURCE_TAG|TAURI_)' || true
tools_dir="$(mktemp -d)"
curl -fsSL https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage -o "$tools_dir/appimagetool-x86_64.AppImage"
chmod +x "$tools_dir/appimagetool-x86_64.AppImage"
ARCH=x86_64 APPIMAGE_EXTRACT_AND_RUN=1 "$tools_dir/appimagetool-x86_64.AppImage" "$appdir_path" "$canonical_appimage"
npx tauri signer sign "$canonical_appimage"
- name: Fail Linux release when AppImage bundling fails
if: env.IS_SMOKE_TAG != 'true' && steps.linux_tauri.outcome == 'failure'
shell: bash
run: |
set -euo pipefail
shopt -s nullglob
assets=(
packages/desktop/src-tauri/target/release/bundle/appimage/*.AppImage
packages/desktop/src-tauri/target/release/bundle/appimage/*.AppImage.sig
)
if [ "${#assets[@]}" -eq 0 ]; then
echo "::error::Linux AppImage assets are still missing after the manual fallback."
exit 1
fi
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --linux --x64 "${publish_args[@]}"
- name: Upload Linux release assets
if: env.IS_SMOKE_TAG != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
shopt -s nullglob
assets=(
packages/desktop/src-tauri/target/release/bundle/appimage/*.AppImage
packages/desktop/src-tauri/target/release/bundle/appimage/*.AppImage.sig
)
if [ "${#assets[@]}" -eq 0 ]; then
echo "::error::No Linux AppImage assets were produced."
exit 1
fi
printf 'Uploading Linux assets:\n%s\n' "${assets[@]}"
gh release upload "$RELEASE_TAG" "${assets[@]}" --repo "${{ github.repository }}" --clobber
- name: Build Linux app (smoke only)
if: env.IS_SMOKE_TAG == 'true'
run: npm run tauri --workspace=@getpaseo/desktop build -- --no-bundle
publish-windows:
needs: [create-release]
if: ${{ always() && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') && ((github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'windows')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-windows-v')))) }}
if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'windows')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-windows-v'))) }}
permissions:
contents: write
packages: read
@@ -336,61 +466,128 @@ jobs:
- name: Resolve release metadata
shell: bash
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
run: |
set -euo pipefail
source_tag="${SOURCE_TAG}"
if [[ "$source_tag" =~ ^(desktop-(windows|linux|macos)-|desktop-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then
release_tag="v${BASH_REMATCH[3]}"
else
release_tag="$source_tag"
fi
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
version="${release_tag#v}"
echo "DESKTOP_VERSION=$version" >> "$GITHUB_ENV"
if [[ "$source_tag" == *gha-smoke* ]]; then
echo "IS_SMOKE_TAG=true" >> "$GITHUB_ENV"
else
echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV"
fi
- name: Set desktop version from tag
shell: bash
run: |
node <<'NODE'
const fs = require('node:fs');
const path = require('node:path');
const version = process.env.DESKTOP_VERSION;
if (!version) throw new Error('DESKTOP_VERSION env var is missing');
console.log(`Setting desktop version to ${version}`);
const tauriConfPath = path.join('packages', 'desktop', 'src-tauri', 'tauri.conf.json');
const tauriConfText = fs.readFileSync(tauriConfPath, 'utf8');
const tauriRe = /("version"\s*:\s*")([^"]+)(")/;
if (!tauriRe.test(tauriConfText)) throw new Error('Failed to find version in tauri.conf.json');
fs.writeFileSync(tauriConfPath, tauriConfText.replace(tauriRe, `$1${version}$3`));
const cargoTomlPath = path.join('packages', 'desktop', 'src-tauri', 'Cargo.toml');
const lines = fs.readFileSync(cargoTomlPath, 'utf8').split(/\r?\n/);
let inPackage = false, updated = false;
const result = lines.map((line) => {
if (/^\[package\]\s*$/.test(line)) inPackage = true;
else if (inPackage && /^\[/.test(line)) inPackage = false;
if (inPackage && /^version\s*=\s*".*"\s*$/.test(line)) {
updated = true;
return `version = "${version}"`;
}
return line;
});
if (!updated) throw new Error('Failed to update Cargo.toml version');
fs.writeFileSync(cargoTomlPath, result.join('\n') + '\n');
NODE
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
node-version: "22"
cache: "npm"
cache-dependency-path: package-lock.json
registry-url: 'https://npm.pkg.github.com'
scope: '@boudra'
registry-url: "https://npm.pkg.github.com"
scope: "@boudra"
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
- name: Restore Rust cache
uses: Swatinem/rust-cache@v2
with:
shared-key: desktop-release-windows
workspaces: |
.
packages/desktop/src-tauri -> target
- name: Install JS dependencies
run: npm ci
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Set desktop package version from tag
shell: bash
run: |
node <<'NODE'
const fs = require('node:fs');
const path = require('node:path');
const version = process.env.DESKTOP_VERSION;
if (!version) throw new Error('DESKTOP_VERSION env var is missing');
const packageJsonPath = path.join(process.env.DESKTOP_PACKAGE_PATH, 'package.json');
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
packageJson.version = version;
fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
NODE
- name: Build workspace dependencies
run: npm run build:workspace-deps --workspace=@getpaseo/app
- name: Build web app for desktop
- name: Build web app for Tauri
shell: pwsh
run: |
$patchPath = (Get-Item "$env:GITHUB_WORKSPACE/scripts/metro-config-windows-loader-patch.cjs").FullName
$env:NODE_OPTIONS = "--require=$patchPath"
npx expo export --platform web
working-directory: packages/app
npm run build:web --workspace=@getpaseo/app
- name: Build desktop release
shell: bash
- name: Build managed runtime
run: npm run prepare:managed-runtime --workspace=@getpaseo/desktop
- name: Validate managed runtime bundle
run: npm run validate:managed-runtime --workspace=@getpaseo/desktop
- name: Detect existing GitHub release state
if: env.IS_SMOKE_TAG != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
EP_GH_IGNORE_TIME: true
shell: bash
run: |
set -euo pipefail
publish_mode="never"
publish_args=()
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
publish_mode="always"
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
if release_draft="$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json isDraft --jq '.isDraft' 2>/dev/null)"; then
:
else
release_draft="false"
fi
echo "RELEASE_DRAFT=$release_draft" >> "$GITHUB_ENV"
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --win --x64 "${publish_args[@]}"
- name: Build and publish Windows Tauri release
if: env.IS_SMOKE_TAG != 'true'
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
with:
projectPath: packages/desktop
tagName: ${{ env.RELEASE_TAG }}
releaseName: Paseo ${{ env.RELEASE_TAG }}
releaseBody: See the assets to download and install this version.
releaseDraft: ${{ env.RELEASE_DRAFT }}
prerelease: false
args: --bundles nsis
- name: Build Windows app (smoke only)
if: env.IS_SMOKE_TAG == 'true'
run: npm run tauri --workspace=@getpaseo/desktop build -- --no-bundle
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
-45
View File
@@ -1,45 +0,0 @@
name: Fix Nix hash
on:
push:
branches: [main]
paths:
- 'package.json'
- 'package-lock.json'
pull_request:
paths:
- 'package.json'
- 'package-lock.json'
permissions:
contents: write
jobs:
fix-nix-hash:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.head_ref || github.ref }}
token: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- uses: cachix/install-nix-action@v31
with:
nix_path: nixpkgs=channel:nixos-unstable
- name: Fix lockfile and update hash
run: ./scripts/update-nix.sh
- name: Commit changes
run: |
git diff --quiet package-lock.json nix/package.nix && exit 0
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add package-lock.json nix/package.nix
git commit -m "fix: update lockfile signatures and Nix hash"
git push
-59
View File
@@ -1,59 +0,0 @@
name: Nix Build
on:
push:
branches: [main]
paths:
- 'nix/**'
- 'flake.nix'
- 'flake.lock'
- 'package.json'
- 'package-lock.json'
- 'packages/highlight/**'
- 'packages/server/**'
- 'packages/relay/**'
- 'packages/cli/**'
pull_request:
branches: [main]
paths:
- 'nix/**'
- 'flake.nix'
- 'flake.lock'
- 'package.json'
- 'package-lock.json'
- 'packages/highlight/**'
- 'packages/server/**'
- 'packages/relay/**'
- 'packages/cli/**'
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
continue-on-error: false
steps:
- uses: actions/checkout@v4
- uses: cachix/install-nix-action@v31
with:
nix_path: nixpkgs=channel:nixos-unstable
- name: Build Nix package
run: nix build .#default -o result
- name: Verify lockfile is complete
# npm silently omits resolved/integrity fields in workspace monorepos.
# Nix needs them for offline builds. See https://github.com/npm/cli/issues/4460
run: |
node scripts/fix-lockfile.mjs package-lock.json
git diff --exit-code package-lock.json || {
echo "ERROR: package-lock.json has missing resolved/integrity fields."
echo "This is a known npm bug: https://github.com/npm/cli/issues/4460"
echo "Run 'node scripts/fix-lockfile.mjs' and commit the result."
exit 1
}
- name: Check npmDepsHash is up to date
run: ./scripts/update-nix.sh --check
+10
View File
@@ -19,6 +19,11 @@ on:
required: false
default: false
type: boolean
draft:
description: "Create missing release as draft."
required: false
default: false
type: boolean
concurrency:
group: release-notes-sync-${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
@@ -43,6 +48,7 @@ jobs:
REF: ${{ github.ref }}
INPUT_TAG: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && github.ref_name || github.event.inputs.tag }}
INPUT_CREATE_IF_MISSING: ${{ github.event.inputs.create_if_missing }}
INPUT_DRAFT: ${{ github.event.inputs.draft }}
shell: bash
run: |
set -euo pipefail
@@ -64,4 +70,8 @@ jobs:
args+=(--create-if-missing)
fi
if [ "${INPUT_DRAFT:-false}" = "true" ]; then
args+=(--draft)
fi
node scripts/sync-release-notes-from-changelog.mjs "${args[@]}"
-3
View File
@@ -36,9 +36,6 @@ jobs:
- name: Install server dependencies
run: npm install --workspace=@getpaseo/server --include-workspace-root
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
- name: Build relay dependency
run: npm run build --workspace=@getpaseo/relay
-3
View File
@@ -46,9 +46,6 @@ test-results/
# Vercel
.vercel/
# Expo
.expo/
# Misc
*.pem
.vercel
+21
View File
@@ -0,0 +1,21 @@
# Dependencies
node_modules
# Build outputs
dist
.next
.expo
build
*.tsbuildinfo
# Coverage
coverage
# Lock files
*.lock
package-lock.json
# Generated
android
ios
.turbo
+7
View File
@@ -0,0 +1,7 @@
{
"semi": false,
"singleQuote": true,
"trailingComma": "es5",
"tabWidth": 2,
"printWidth": 100
}
+6 -389
View File
@@ -1,395 +1,12 @@
# Changelog
## 0.1.55 - 2026-04-14
### Added
- Provider profiles — define custom providers in your Paseo config that appear alongside built-ins. Override a built-in's binary, env, or models, or create entirely new providers. See the [configuration guide](https://github.com/getpaseo/paseo/blob/main/docs/CUSTOM-PROVIDERS.md).
- ACP agent support — add any ACP-compatible agent to Paseo with `extends: "acp"` in your provider config. No code changes needed.
- Choose provider and model when creating scheduled agents.
- Max reasoning effort option for Opus 4.6 models.
- Cmd+, (Ctrl+, on Windows/Linux) opens settings.
## 0.1.29 - 2026-03-18
### Improved
- Git operations are dramatically faster — workspace status, PR checks, and branch data all use a shared cached snapshot service instead of shelling out to git on every request. Running 20+ workspaces simultaneously is now smooth.
- Windows support — the daemon and CLI run natively on Windows with proper shell quoting, executable resolution, and path handling.
- iPad and tablet layouts work correctly across all screen sizes.
- IME composition (Chinese, Japanese, Korean input) no longer submits prematurely when pressing Enter.
- Improved `paseo daemon status` with provider binary resolution and version checks for Claude, Codex, and OpenCode.
### Fixed
- Creating a worktree no longer briefly flashes it as a standalone project before placing it under the correct repository.
- Worktree creation spinner stays visible throughout the process instead of disappearing on mouse-out.
- Workspace navigation updates correctly when switching between workspaces in the same project.
- Desktop workspace header alignment and model selector no longer overflow on narrow windows.
- Loading indicators are visible in light mode.
## 0.1.54 - 2026-04-12
### Added
- Inline image previews in agent messages — screenshots and images generated by agents render directly in the conversation instead of showing as raw markdown links.
### Improved
- Paseo tools are no longer injected into agents by default — opt in from Settings when you need agent-to-agent orchestration.
- Agent provider and mode are now resolved server-side, so CLI commands like `paseo run` use consistent defaults without client-side lookups.
### Fixed
- Shift+Enter now correctly inserts a newline in agent terminal input instead of submitting.
- Windows: MCP configuration is no longer mangled when spawning Claude agents.
- Branch ahead/behind count no longer errors for branches with no remote tracking branch.
## 0.1.53 - 2026-04-12
### Added
- Agents get Paseo tools automatically — every new agent gets access to terminals, schedules, worktrees, and other agents through MCP. Toggle it off in Settings under "Inject Paseo tools".
- Git pull — pull remote changes directly from the workspace header. Promoted to the primary action when your branch is behind origin.
- Child agent notifications — parent agents are automatically notified when a child agent finishes, errors, or needs permission approval.
- Agent reload — `paseo agent reload` restarts an agent's underlying process from the CLI.
- Middle-click to close tabs on desktop.
- Keyboard shortcut to cycle themes.
### Improved
- Unavailable git actions now explain why in a toast instead of being silently greyed out.
- Streaming markdown on mobile renders significantly faster.
- Sidebar, branch switcher, and agent panel no longer re-render unnecessarily — noticeable on large workspaces.
- Paseo tool calls in agent timelines show the Paseo logo and human-readable names.
- Relay and pairing URLs are stripped from daemon logs.
### Fixed
- Closed agent tabs no longer reappear after reconnecting.
- Desktop notification badge counts match across all workspaces.
- Host switcher status syncs correctly when switching between hosts.
## 0.1.52 - 2026-04-10
### Added
- Theme selector — choose from six themes including Midnight, Claude, and Ghostty dark variants.
- Branch switching — switch git branches directly from the workspace header, with automatic stash and restore for uncommitted changes.
- Auto-download updates — desktop updates download silently in the background so they're ready to install when you are.
### Fixed
- Layout now responds correctly when resizing the window or rotating a tablet — previously the app could get stuck in mobile layout on a large screen.
- Terminal no longer causes massive memory spikes from snapshot thrashing during heavy output.
- Typing in the terminal works reliably — special keys, Ctrl combos, and paste are handled natively by the terminal emulator.
- Initializing agents no longer show a loading spinner as if they're running.
- Reconnecting to a running agent now works even when session persistence is unavailable.
- Error screens on desktop are now scrollable.
- Model list refreshes in the background when you open the model selector.
- Draft agent feature preferences (like thinking mode) are remembered across sessions.
## 0.1.51 - 2026-04-09
### Added
- Image attachments for OpenCode — attach screenshots and images to OpenCode agent prompts.
- WebStorm — added to the "Open in editor" list alongside Cursor, VS Code, and Zed.
- Send behavior setting — choose whether pressing Enter while an agent is running interrupts immediately or queues your message.
### Fixed
- Model selector no longer crashes on iPad.
- Pairing now uses the correct hostname, fixing connection failures on some network setups.
- OpenCode agents show the correct terminal state and refresh models reliably.
- Follow-up messages to agents that just finished a turn now work correctly.
- Commands now load properly for Pi agents.
- Internal debug output no longer appears in Claude agent timelines.
- QR scan screen cleaned up with simpler visuals.
## 0.1.50 - 2026-04-07
### Added
- Context window meter — see how much of the context window your agent has used, with color thresholds at 70% and 90%. Works with Claude Code, Codex, and OpenCode.
- Open in editor — jump from any workspace straight into Cursor, VS Code, Zed, or your file manager. Paseo remembers your choice.
- Side-by-side diffs — toggle between unified and split-column diff views, with a whitespace visibility option.
- Spoken messages — when using voice mode, agent speech now appears as regular messages in the conversation instead of raw tool output.
- Plan actions — plan cards now show the actions your agent supports (e.g. "Implement", "Deny") instead of generic accept/reject buttons.
- Background git fetch — ahead/behind counts in the Changes pane stay up to date automatically.
### Improved
- Workspaces load instantly on connect instead of waiting for a full sync.
- File explorer and diff pane remember which folders are expanded when you switch tabs.
- Closing a workspace tab is now instant.
- Settings shows a Refresh button for providers and displays error details inline.
- Reload agent moved away from the close button to prevent accidental taps.
### Fixed
- Voice mode no longer drifts into false speech detection during long sessions.
- Garbled overlapping text on plan cards.
- Changes pane could show stale diffs when working with git worktrees.
- Restarting an agent quickly could crash the session.
- Copilot no longer pauses for permission prompts in autopilot mode.
- Connection and pairing dialogs now display correctly on tablets.
- Orchestration errors from agents are now surfaced instead of silently lost.
- Diff stats no longer reset to zero when reconnecting.
## 0.1.49 - 2026-04-07
### Fixed
- Models and providers now load reliably on first connect instead of requiring a manual refresh.
- Model picker only shows models from the agent's own provider, not every provider on the server.
- Model lists stay consistent regardless of which screen you open first.
## 0.1.48 - 2026-04-05
### Added
- Provider diagnostics — tap a provider in Settings to see binary path, version, model count, and status at a glance. Helps troubleshoot why an agent type isn't available.
- Provider snapshot system — daemon now pushes real-time provider availability and model lists to the app, replacing the old poll-based approach. Models and modes update live as providers come online or go offline.
- Codex question handling — Codex agents can now ask the user questions mid-session (e.g. "which file?") and receive answers inline, matching the Claude Code question flow.
- Reload tab action — right-click a workspace tab to reload its agent list without restarting the app.
### Improved
- Model selector redesigned — grouped by provider with status badges, search, and better touch targets on mobile.
- Enter key now submits question card answers and confirms dictation, matching the expected keyboard flow.
- Removed noisy agent lifecycle toasts that fired on every state change.
### Fixed
- Desktop app now resolves the user's full login shell environment at startup, fixing tools like `codex`, `node`, `bun`, and `direnv` not being found when Paseo is launched from Finder or Dock. Terminals spawned by Paseo now inherit the same PATH and environment variables as a normal terminal session. Approach adapted from VS Code's battle-tested shell environment resolution.
- Input field on running agent screens now correctly receives keyboard focus.
- Mobile model selector alignment and sizing.
## 0.1.47 - 2026-04-05
### Fixed
- Voice TTS in Electron — sherpa now requests copied buffers and the voice MCP bridge sets `ELECTRON_RUN_AS_NODE`, preventing "external buffers not allowed" crashes.
- QR pairing in desktop — CLI JSON output parsing now tolerates Node deprecation warnings in stdout.
- STT segment race condition — segment ID and audio buffer are snapshotted before the async transcription call, so rapid commits no longer interleave.
- Per-host "Add connection" button removed — it blocked multi-host setups by scoping new connections to a single server.
## 0.1.46 - 2026-04-04
### Fixed
- Voice activation in packaged builds — Silero VAD model is now copied out of the Electron asar archive so native code can read it.
- App version sent in probe client hello so the daemon's version gate no longer hides Pi/Copilot from reconnected sessions.
- `worktreeRoot` schema made backward-compatible for old clients and daemons that don't send the field.
- Punycode deprecation warning (DEP0040) suppressed in CLI and desktop daemon entrypoints.
## 0.1.45 - 2026-04-04
### Added
- Pi (pi.dev) agent provider — connect Pi as a new ACP-based agent type with thinking levels and tool call support.
- Copilot agent provider re-enabled after ACP compatibility fixes.
- `paseo .` and `paseo <path>` open the desktop app with the given project, similar to `code .`.
- Provider-declared features system — providers can expose dynamic toggles and selects that the app renders automatically. First consumer: Codex fast mode.
- Codex plan mode — start agents in plan-only mode with a dedicated plan card UI for reviewing proposed changes before execution.
- OpenCode custom agents and slash commands — user-defined agents from opencode.json now appear in the mode picker, and slash commands accept optional arguments.
- Desktop Integrations settings — install the Paseo CLI and orchestration skills directly from the app without touching the terminal.
- Daemon status dialog in desktop settings for quick health checks.
- Auto-restart daemon on version mismatch — the desktop app detects when the running daemon is outdated and restarts it automatically.
- Setup hint and paseo.sh link on the mobile welcome screen so new App Store users know what to do next.
### Improved
- Desktop startup is faster — existing daemon connections are raced against bootstrap so the app is usable sooner.
- Settings sections reordered for better grouping (integrations and daemon together).
- Sidebar projects and workspaces now persist across sessions, with a context menu to remove projects.
### Fixed
- Sidebar crash when switching iOS theme (Unistyles/Reanimated interaction).
- Silero VAD crash caused by external buffer mode in CircularBuffer.
- Bulk close now correctly archives stored agents instead of leaving orphans.
- Pinned archived agents are no longer pruned when closing tabs.
- OpenCode event stream starvation during slash command execution.
- Duplicate workspaces when multiple git worktrees share the same root.
- `gh` executable resolution for desktop users whose login shell sets a different PATH.
- Agent creation timeout increased to 60s to handle slow first-launch scenarios.
- Forward-compatible provider handling so older app clients don't break on new provider types.
- Input event listener race condition in the web scrollbar hook.
- Open-project screen content now vertically centered.
- Website download page fetches the release version at runtime with asset validation, fixing stale links.
## 0.1.44 - 2026-04-03
### Fixed
- Desktop app now stops the daemon cleanly before auto-update restarts.
- Disabled claude-acp and copilot providers from the agent registry.
- Keyboard focus scope resolution now checks multiple candidates for broader compatibility.
- OpenCode interrupt now reaches correct terminal state parity with tool-call flows.
- Shell injection, symlink escape, and pairing endpoint security hardening.
## 0.1.43 - 2026-04-02
### Added
- Copilot agent support via ACP base provider — connect GitHub Copilot as a new agent type.
- Searchable model favorites — quickly find and pin preferred models.
- Slash command support for OpenCode agents.
### Improved
- Refined model selector UX with better mobile sheet behavior.
- Workspace status now uses amber alert styling for "needs input" state.
- Themed scrollbar on message input for consistent styling.
### Fixed
- Ctrl+C/V copy and paste now works correctly in the terminal on Windows and Linux.
- Shell arguments with spaces are now properly quoted on Windows.
- Claude models with 1M context support are now correctly reported.
## 0.1.42 - 2026-04-01
### Fixed
- Fixed Claude Code failing to launch on Windows when installed to a path with spaces (e.g. `C:\Program Files\...`).
## 0.1.41 - 2026-04-01
### Fixed
- Fixed agent spawning on Windows — all providers (Claude, Codex, OpenCode) now use shell mode so npm shims and `.cmd` wrappers resolve correctly.
- Fixed terminal creation on Windows defaulting to a Unix shell instead of `cmd.exe`.
- Fixed path handling across the app to support Windows drive-letter paths (`C:\...`) and UNC paths (`\\...`).
- Fixed executable resolution on Windows to work with `nvm4w` and similar Node version managers.
- Eliminated white flash on window resize in dark mode by setting the native window background color to match the theme.
- Fixed titlebar drag region — replaced the fragile pointer-event approach with VS Code's proven static CSS `app-region: drag` pattern.
- Fixed context menu for copy/paste across the desktop app.
- Fixed shortcut rebinding UI to show held modifier keys and recognize additional keys (Tab, Delete, Home, End, Page Up/Down, Insert, F1–F12).
- Removed the 40-item cap on activity timeline output so long agent sessions display their full history.
### Improved
- Improved light mode theming with dedicated workspace background, scrollbar handle colors, and lighter shadows.
- Window controls overlay on Windows/Linux reduced from 48px to 29px height for a more compact titlebar.
## 0.1.40 - 2026-04-01
### Added
- Workspace tabs can now be closed in batches.
### Improved
- Provider model lists are now cached per server and provider, reducing redundant model lookups in the UI.
### Fixed
- OpenCode reasoning content no longer appears duplicated as assistant text.
- Daemon no longer crashes when a Codex binary is missing or fails to spawn.
- Archive tab now correctly reconciles agent visibility after archiving.
- File diff tracking in workspaces now works correctly on Linux.
- iPad layout now renders correctly in desktop mode.
- macOS auto-updater now correctly delivers both arm64 and x64 binaries — previously whichever architecture finished building last would overwrite the other's update manifest.
## 0.1.39 - 2026-03-30
### Added
- **Terminal management from the CLI** — new `paseo terminal` command group lets you list, create, and interact with workspace terminals without leaving your terminal.
- **Material file icons in the explorer** — the file explorer tree now shows language-specific icons (TypeScript, JSON, Markdown, etc.) so you can spot files at a glance.
### Fixed
- Fixed iOS sidebar scroll flicker caused by redundant overflow clipping.
- Centralized window controls padding into a shared hook, eliminating layout inconsistencies across platforms.
## 0.1.38 - 2026-03-30
### Fixed
- Fixed daemon startup race where the app could time out connecting on first launch because the PID file advertised a listen address before the server was ready.
- Fixed daemon log rotation losing startup traces — trace-level WebSocket logs no longer include full message payloads.
## 0.1.37 - 2026-03-29
### Added
- Custom window controls on Windows and Linux — the native titlebar is replaced with overlay controls that match the app's design.
- Desktop file logging with electron-log for easier debugging of daemon and app issues.
### Fixed
- Fixed broken PATH propagation and Claude binary resolution on Windows.
- Dictation errors now show a visible toast instead of failing silently.
## 0.1.36 - 2026-03-27
### Fixed
- Fixed Windows drive-letter path handling across the codebase.
- Fixed stale Nix hash with automatic lockfile-change detection.
### Added
- Added metrics collection and terminal performance tests.
## 0.1.35 - 2026-03-26
### Improved
- Faster app startup by redirecting to the welcome screen immediately and showing host connection status inline.
- Codex file deletions now display correctly as removed lines in diffs.
- OpenCode questions are now surfaced in the permission UI.
### Fixed
- Fixed queued prompt dispatch after idle transition.
- Replaced bash-only `mapfile` with a portable `while-read` loop in the chat script.
### Added
- Added support for Nix and NixOS installation.
## 0.1.34 - 2026-03-25
### Added
- Added `paseo archive` as a top-level alias for `paseo agent archive`.
- Added the `PASEO_AGENT_ID` environment variable for Claude and Codex agents.
- Added a redesigned command autocomplete with a detail card and dropdown styling.
- Linked Android download surfaces to the Google Play Store.
### Improved
- Autonomous turns now complete gracefully on interrupt instead of being canceled.
- Thinking/model selection now always resolves to a real option instead of showing a generic Default choice.
- Restored per-provider form preferences and removed the Auto model fallback.
- Improved Codex activity logs with clearer tool-call summaries.
- Reduced unnecessary re-renders in the agent panel and input area for smoother interaction.
- Improved chat transcript readability.
### Fixed
- Fixed `paseo send --no-wait` not taking effect.
- Fixed stale abort results contaminating replacement turns after an interrupt.
- Fixed Claude interrupt handling and autonomous wake reliability.
- Fixed nested Claude Code session detection and provider availability checks.
- Fixed agent input focus scoping across panels.
- Fixed terminal snapshot ordering when subscribing.
- Fixed `chat read --since` to accept message IDs.
- Fixed keyboard pane focus syncing with the active panel.
- Fixed assistant text selection on web.
- Fixed archived-agent notifications still appearing in chat rooms.
- Fixed the attach-images button interaction in the message composer.
- Pruned wrong-platform native binaries from Electron desktop builds.
## 0.1.33 - 2026-03-23
### Fixed
- Fixed the desktop app failing to reopen after closing on macOS — the daemon and agent processes were registering with Launch Services as instances of the main app, blocking subsequent launches.
- Fixed dictation not working in the packaged desktop app — the microphone entitlement was missing from the hardened runtime configuration.
- Fixed leaked Claude Code child processes when agents were closed — the SDK query stream was not being properly shut down.
- The notification test button now surfaces errors instead of failing silently.
## 0.1.32 - 2026-03-23
### Added
- Fully rebindable keyboard shortcuts with chord support — all shortcuts are now declarative with proper Cmd (Mac) vs Ctrl (Windows/Linux) separation.
- Migrated the desktop app from Tauri to Electron, with macOS notarization, code signing, and Linux Wayland support.
- Added line numbers and word-wrap toggle to file previews.
- Added an archived agent callout with an unarchive button so you can restore agents directly from the chat view.
- Added workspace kind indicators in the sidebar (e.g. worktree vs standalone).
- Expanded diff syntax highlighting to cover more languages.
- Added status bar tooltips for project and agent status.
### Improved
- Redesigned the mobile tab switcher as a compact header row with quick access to new agents and terminals.
- Streamlined workspace creation — worktrees are now created inline with a single action instead of a multi-step flow.
- Agent history now streams from disk on reconnect, so you see past messages immediately instead of a blank screen.
- Automatic cleanup of stale workspaces: deleted worktree directories and fully-archived workspaces are pruned automatically.
- After archiving a workspace, the app now redirects to the next available workspace instead of leaving you on a dead screen.
- Reopening an archived agent tab now keeps it open instead of collapsing back to archived state.
- Reduced unnecessary re-renders across the workspace screen, sidebar, and agent list for smoother scrolling and interaction.
- Agent list no longer refreshes in the background when the screen is unfocused, saving resources.
- Desktop key repeat now works correctly on macOS.
- Desktop notifications on macOS are more reliable.
- Daemon startup no longer blocks on model downloads.
- Better error messages from the daemon — RPC errors now include the actual underlying details.
### Fixed
- Fixed user messages appearing as assistant output in the timeline when messages contained structured content blocks.
- Fixed archived workspace routing so navigating to an archived session no longer breaks the app.
- Fixed Linux AppImage failing to launch on Wayland-only desktops.
- Fixed desktop window drag coordinates being applied when they shouldn't be.
## 0.1.30 - 2026-03-19
### Added
- Added terminal tabs, split pane controls, and drop previews for workspace layouts.
- Added a combined model selector and agent mode visuals across key UI surfaces.
- Added Open Graph metadata improvements for richer website sharing previews.
### Improved
- Improved workspace navigation with better active-workspace tracking and keyboard-driven pane interactions.
- Improved terminal scrollbar behavior, pane focus handling, and status bar/message input spacing.
- Improved project picker path display and general workspace UI polish.
### Fixed
- Fixed agent startup reliability by tightening PATH resolution and surfacing missing provider binaries in status.
- Fixed workspace route syncing, drag hit areas, and git diff panel header styling regressions.
- Fixed website mobile horizontal scrolling and ensured the workspace audio module builds during EAS installs.
- Fixed desktop-managed agent startup failures caused by the daemon using the GUI process PATH instead of the user's login shell PATH.
## 0.1.28 - 2026-03-15
@@ -540,7 +157,7 @@
- Redesigned the website get-started experience into a clearer two-step flow.
- Simplified website GitHub navigation and changelog headings.
- Improved app draft/new-agent UX with clearer working directory placeholder and empty-state messaging.
- Enabled drag interactions in previously unhandled areas on the desktop draft screen.
- Enabled drag interactions in previously unhandled areas on the desktop (Tauri) draft screen.
- Hid empty filter groups in the left sidebar.
### Fixed
@@ -562,7 +179,7 @@
- Improved new worktree-agent defaults by prefilling CWD to the main repository.
- Improved desktop command autocomplete behavior to match combobox interactions.
- Improved git sync UX by simplifying sync labels and only showing Sync when a branch diverges from origin.
- Improved desktop settings and permissions UX on desktop.
- Improved desktop settings and permissions UX in Tauri.
- Improved scrollbar visibility, drag interactions, tracking, and animation timing on web/desktop.
### Fixed
@@ -601,7 +218,7 @@
- Fixed stuck "send while running" recovery across app and server session handling.
- Fixed Claude session identity preservation when reloading existing agents.
- Fixed combobox option behavior and related interactions.
- Fixed desktop file-drop listener cleanup to avoid uncaught unlisten errors.
- Fixed Tauri file-drop listener cleanup to avoid uncaught unlisten errors.
- Fixed web tool-detail wheel event routing at scroll edges.
## 0.1.7 - 2026-02-16
+6 -54
View File
@@ -12,7 +12,7 @@ This is an npm workspace monorepo:
- `packages/app` — Mobile + web client (Expo)
- `packages/cli` — Docker-style CLI (`paseo run/ls/logs/wait`)
- `packages/relay` — E2E encrypted relay for remote access
- `packages/desktop` — Electron desktop wrapper
- `packages/desktop` — Tauri desktop wrapper
- `packages/website` — Marketing site (paseo.sh)
## Documentation
@@ -24,7 +24,6 @@ This is an npm workspace monorepo:
| [docs/TESTING.md](docs/TESTING.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
| [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
| [docs/RELEASE.md](docs/RELEASE.md) | Release playbook, draft releases, completion checklist |
| [docs/CUSTOM-PROVIDERS.md](docs/CUSTOM-PROVIDERS.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
| [docs/ANDROID.md](docs/ANDROID.md) | App variants, local/cloud builds, EAS workflows |
| [docs/DESIGN.md](docs/DESIGN.md) | How to design features before implementation |
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
@@ -36,8 +35,6 @@ npm run dev # Start daemon + Expo in Tmux
npm run cli -- ls -a -g # List all agents
npm run cli -- daemon status # Check daemon status
npm run typecheck # Always run after changes
npm run format # Auto-format with Biome
npm run format:check # Check formatting without writing
```
See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requirements, and debugging.
@@ -48,55 +45,10 @@ See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requir
- **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.
- **Always run typecheck after every change.**
- **Run `npm run format` before committing.** This repo uses Biome for formatting. Do not manually fix formatting — let the formatter handle it.
- **NEVER make breaking changes to WebSocket or message schemas.** The primary compatibility path is old mobile app clients talking to newly updated daemons. Users update desktop and daemon first, then keep running the old app for a while. Every schema change MUST be backward-compatible for old clients against new daemons:
- New fields: always `.optional()` with a sensible default or `.transform()` fallback.
- Never change a field from optional to required.
- Never remove a field — deprecate it (keep accepting it, stop sending it).
- Never narrow a field's type (e.g. `string` → `enum`, `nullable` → non-null).
- Test with: "does a 6-month-old client still parse this?" and "does a 6-month-old daemon still send something this client accepts?"
## Platform gating
## Orchestrator mode
The app runs on iOS, Android, web (browser), and web (Electron desktop). Code is cross-platform by default. Gate only when you must. Import gates from `@/constants/platform`.
### The four gates
| Gate | Type | When to use |
|---|---|---|
| `isWeb` | constant | DOM APIs — `document`, `window`, `<div>`, `addEventListener`, `ResizeObserver`. This is the **exception**, not the default. |
| `isNative` | constant | Native-only APIs — Haptics, `StatusBar.currentHeight`, push tokens, camera/scanner, `expo-av`. |
| `getIsElectron()` | cached fn | Desktop wrapper features — file dialogs, titlebar drag region, daemon management, app updates, dock badges. |
| `useIsCompactFormFactor()` | hook | Layout decisions — sidebar overlay vs pinned, modal vs full screen, single-panel vs split. From `@/constants/layout`. |
### Decision matrix
| I need to... | Use |
|---|---|
| Access DOM (`document`, `window`, `<div>`, `addEventListener`) | `if (isWeb)` |
| Use a native-only API (Haptics, push tokens, camera) | `if (isNative)` |
| Use an Electron bridge (file dialog, titlebar, updates) | `if (getIsElectron())` |
| Switch layout between phone and tablet/desktop | `useIsCompactFormFactor()` |
| Show something on hover, always-visible on native | `isHovered \|\| isNative \|\| isCompact` (hover only works on web) |
| Gate to iOS or Android specifically | `Platform.OS === "ios"` / `Platform.OS === "android"` (rare, keep inline) |
### Rules
- **Default is cross-platform.** Don't gate unless you have a specific reason.
- **Prefer Metro file extensions over `if` statements.** When a module has fundamentally different implementations per platform, use `.web.ts` / `.native.ts` file extensions instead of runtime `if (isWeb)` branches. Metro resolves the correct file at build time — the unused platform code is never bundled. Reserve `if (isWeb)` for small, inline checks (a single line or a few props). If you find yourself writing a large `if (isWeb) { ... } else { ... }` block, split into separate files instead.
```
hooks/
use-audio-recorder.web.ts ← uses Web Audio API
use-audio-recorder.native.ts ← uses expo-audio
```
Import as `@/hooks/use-audio-recorder` — Metro picks the right file automatically.
- **NEVER use raw DOM APIs without `isWeb` guard.** DOM APIs crash native. Casting a RN ref to `HTMLElement` is a red flag — ensure the block is web-only.
- **NEVER use `onPointerEnter`/`onPointerLeave`.** They don't fire on native iOS.
- **Hover only works on web.** React Native's `onHoverIn`/`onHoverOut` on `Pressable` does NOT fire on native iOS/iPad — the underlying W3C pointer events are behind disabled experimental flags. For hover-to-show UI (kebab menus, action buttons), use `isHovered || isNative || isCompact` so the controls are always visible on native and hover-to-show on web.
- **Don't use Platform.OS as a proxy for layout capabilities.** Use breakpoints for layout decisions, not platform checks.
- **Import `isWeb`/`isNative` from `@/constants/platform`.** Never write `const isWeb = Platform.OS === "web"` locally.
## Debugging
Find the complete daemon logs and traces in the $PASEO_HOME/daemon.log
- Prefix agent titles with "🎭" (e.g., "🎭 Feature Implementation")
- Launch agents in the most permissive mode
- Set cwd to the repository root
- When agent control tool calls fail, list agents first — it may be a wait timeout
-168
View File
@@ -1,168 +0,0 @@
# Contributing to Paseo
Thanks for taking the time to contribute.
## How this project works
Paseo is a BDFL project. Product direction, scope, and what ships are the maintainer's call.
This means:
- PRs submitted without prior discussion will likely be rejected, heavily modified, or scoped down.
- The maintainer may rewrite, split, cherry-pick from, or close any PR at their discretion.
- There is no obligation to merge a PR as-submitted, regardless of code quality.
This is not meant to discourage contributions. It is meant to set clear expectations so nobody wastes their time.
## How to contribute
1. **Open an issue first.** Describe the problem or improvement. Get a thumbs up before writing code.
2. **Keep it small.** One bug, one flow, one focused change.
3. **Open a PR** once there is alignment on scope.
If you want to propose a direction change, start a conversation.
## Before you start
Please read these first:
- [README.md](README.md)
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)
- [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md)
- [docs/CODING_STANDARDS.md](docs/CODING_STANDARDS.md)
- [docs/TESTING.md](docs/TESTING.md)
- [CLAUDE.md](CLAUDE.md)
## What is most helpful
The most useful contributions right now are:
- bug fixes
- windows and linux specific fixes
- regression fixes
- doc improvements
- packaging / platform fixes
- focused UX improvements that fit the existing product direction
- tests that lock down important behavior
## Scope expectations
Please keep PRs narrow.
Good:
- fix one bug
- improve one flow
- add one focused panel or command
- tighten one piece of UI
Bad:
- combine multiple product ideas in one PR
- bundle unrelated refactors with a feature
- sneak in roadmap decisions
If a contribution contains multiple ideas, split it up.
## Product fit matters
Paseo is an opinionated product.
When reviewing contributions, the bar is not just:
- is this useful?
- is this well implemented?
It is also:
- does this fit Paseo?
- does this add product surface that will be hard to maintain?
- does the value justify the maintenance surface it adds?
- does this solve a common need or over-serve an edge case?
- does this preserve the product's current direction?
## Development setup
### Prerequisites
- Node.js matching `.tool-versions`
- npm workspaces
### Start local development
```bash
# runs both daemon and expo app
npm run dev
```
Useful commands:
```bash
npm run dev:server
npm run dev:app
npm run dev:desktop
npm run dev:website
npm run cli -- ls -a -g
```
Read [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for build-sync gotchas, local state, ports, and daemon details.
## Multi-platform testing
Paseo ships to mobile (iOS/Android), web, and desktop (Electron). Every UI change must be tested on mobile and web at minimum, and desktop if relevant. Things that look fine on one surface regularly break on another.
Common checks:
```bash
npm run typecheck
npm run test --workspaces --if-present
```
Important rules:
- always run `npm run typecheck` after changes
- tests should be deterministic
- prefer real dependencies over mocks when possible
- do not make breaking WebSocket / protocol changes
- app and daemon versions in the wild lag each other, so compatibility matters
If you touch protocol or shared client/server behavior, read the compatibility notes in [CLAUDE.md](CLAUDE.md).
## Coding standards
Paseo has explicit standards. Follow them.
The full guide lives in [docs/CODING_STANDARDS.md](docs/CODING_STANDARDS.md).
## PR checklist
Before opening a PR, make sure:
- there was prior discussion and alignment on scope (issue or conversation)
- the change is focused, one idea per PR
- the PR description explains what changed and why
- **UI changes include screenshots or videos** for every affected platform (mobile, web, desktop)
- UI changes have been tested on mobile and web at minimum
- typecheck passes
- tests pass, or you clearly explain what could not be run
- relevant docs were updated if needed
## Communication
If you are unsure whether something fits, ask first.
That is especially true for:
- new core UX
- naming / terminology changes
- new extension points
- new orchestration models
- anything that would be hard to remove later
Early alignment saves everyone time.
## Forks are fine
If you want to explore a different product direction, a fork is completely fine.
Paseo is open source on purpose. Not every idea needs to land in the main repo to be valuable.
+11 -90
View File
@@ -4,122 +4,47 @@
<h1 align="center">Paseo</h1>
<p align="center">
<a href="https://github.com/getpaseo/paseo/stargazers">
<img src="https://img.shields.io/github/stars/getpaseo/paseo?style=flat&logo=github" alt="GitHub stars">
</a>
<a href="https://github.com/getpaseo/paseo/releases">
<img src="https://img.shields.io/github/v/release/getpaseo/paseo?style=flat&logo=github" alt="GitHub release">
</a>
<a href="https://x.com/moboudra">
<img src="https://img.shields.io/badge/%40moboudra-555?logo=x" alt="X">
</a>
<a href="https://discord.gg/jz8T2uahpH">
<img src="https://img.shields.io/badge/Discord-555?logo=discord" alt="Discord">
</a>
</p>
<p align="center">One interface for all your Claude Code, Codex and OpenCode agents.</p>
<p align="center">One interface for all your coding agents.</p>
<p align="center">
<img src="https://paseo.sh/hero-mockup.png" alt="Paseo app screenshot" width="100%">
</p>
<p align="center">
<img src="https://paseo.sh/mobile-mockup.png" alt="Paseo mobile app" width="100%">
<img src="https://paseo.sh/paseo-mockup.png" alt="Paseo app screenshot" width="100%">
</p>
---
Run agents in parallel on your own machines. Ship from your phone or your desk.
- **Self-hosted:** Agents run on your machine with your full dev environment. Use your tools, your configs, and your skills.
- **Multi-provider:** Claude Code, Codex, and OpenCode through the same interface. Pick the right model for each job.
- **Voice control:** Dictate tasks or talk through problems in voice mode. Hands-free when you need it.
- **Cross-device:** iOS, Android, desktop, web, and CLI. Start work at your desk, check in from your phone, script it from the terminal.
- **Privacy-first:** Paseo doesn't have any telemetry, tracking, or forced log-ins.
- **Self-hosted** — Agents run on your machine with your full dev environment. Use your tools, your configs, and your skills.
- **Multi-provider** — Claude Code, Codex, and OpenCode through the same interface. Pick the right model for each job.
- **Voice control** — Dictate tasks or talk through problems in voice mode. Hands-free when you need it.
- **Cross-device** — iOS, Android, desktop, web, and CLI. Start work at your desk, check in from your phone, script it from the terminal.
## Getting Started
Paseo runs a local server called the daemon that manages your coding agents. Clients like the desktop app, mobile app, web app, and CLI connect to it.
Download the desktop app from [paseo.sh](https://paseo.sh) or the [GitHub releases page](https://github.com/getpaseo/paseo/releases) — it bundles the daemon so there's nothing else to install.
### Prerequisites
### Headless / server mode
You need at least one agent CLI installed and configured with your credentials:
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code)
- [Codex](https://github.com/openai/codex)
- [OpenCode](https://github.com/anomalyco/opencode)
### Desktop app (recommended)
Download it from [paseo.sh/download](https://paseo.sh/download) or the [GitHub releases page](https://github.com/getpaseo/paseo/releases). Open the app and the daemon starts automatically. Nothing else to install.
To connect from your phone, scan the QR code shown in Settings.
### CLI / headless
Install the CLI and start Paseo:
To run the daemon on a remote or headless machine:
```bash
npm install -g @getpaseo/cli
paseo
```
This shows a QR code in the terminal. Connect from any client. This path is useful for servers and remote machines.
Then connect from the desktop app, mobile app, or CLI.
For full setup and configuration, see:
- [Docs](https://paseo.sh/docs)
- [Configuration reference](https://paseo.sh/docs/configuration)
## CLI
Everything you can do in the app, you can do from the terminal.
```bash
paseo run --provider claude/opus-4.6 "implement user authentication"
paseo run --provider codex/gpt-5.4 --worktree feature-x "implement feature X"
paseo ls # list running agents
paseo attach abc123 # stream live output
paseo send abc123 "also add tests" # follow-up task
# run on a remote daemon
paseo --host workstation.local:6767 run "run the full test suite"
```
See the [full CLI reference](https://paseo.sh/docs/cli) for more.
## Orchestration skills (Unstable)
Experimental skills that teach agents how to use the Paseo CLI to orchestrate other agents. I am updating these very frequently as I learn new things, expect changes without notice, might be coupled to my own setup, use at your own risk.
```bash
npx skills add getpaseo/paseo
```
Then use them in any agent conversation:
```bash
# Use handoff when you discuss something with an agent but want another one to implement.
# I use this to plan with Claude and then handoff to Codex to implement.
/paseo-handoff hand off the authentication fix to codex 5.4 in a worktree
# Use loops when you have clear acceptance criteria (aka Ralph loops).
/paseo-loop loop a codex agent to fix the backend tests, use sonnet to verify, max 10 iterations
# Orchestrator teaches the agent how to create teams and manage them via a chat room.
# Very opinionated and expects both Codex and Claude to work.
/paseo-orchestrator spin up a team to implement the database refactor, use chat to coordinate. use claude to plan and codex to implement and review
```
## Development
Quick monorepo package map:
- `packages/server`: Paseo daemon (agent process orchestration, WebSocket API, MCP server)
- `packages/app`: Expo client (iOS, Android, web)
- `packages/cli`: `paseo` CLI for daemon and agent workflows
- `packages/desktop`: Electron desktop app
- `packages/desktop`: Tauri desktop app
- `packages/relay`: Relay package for remote connectivity
- `packages/website`: Marketing site and documentation (`paseo.sh`)
@@ -132,12 +57,8 @@ npm run dev
# run individual surfaces
npm run dev:server
npm run dev:app
npm run dev:desktop
npm run dev:website
# build the daemon
npm run build:daemon
# repo-wide checks
npm run typecheck
```
+5 -17
View File
@@ -22,7 +22,7 @@ The relay is designed to be untrusted. All traffic between your phone and daemon
1. The daemon generates a persistent ECDH keypair and stores it locally
2. When you scan the QR code or click the pairing link, your phone receives the daemon's public key
3. Your phone sends a handshake message with its own public key. The daemon will not accept any commands until this handshake completes.
4. Both sides perform an ECDH key exchange to derive a shared secret. All subsequent messages are encrypted with XSalsa20-Poly1305 (NaCl box).
4. Both sides perform an ECDH key exchange to derive a shared secret. All subsequent messages are encrypted with AES-256-GCM.
The relay sees only: IP addresses, timing, message sizes, and session IDs. It cannot read message contents, forge messages, or derive encryption keys from observing the handshake.
@@ -31,26 +31,14 @@ The relay sees only: IP addresses, timing, message sizes, and session IDs. It ca
The daemon requires a valid cryptographic handshake before processing any commands. A compromised relay cannot:
- **Send commands** — Without your phone's private key, it cannot complete the handshake
- **Read your traffic** — All messages are encrypted with XSalsa20-Poly1305 (NaCl box) after the handshake
- **Forge messages** — NaCl box provides authenticated encryption; tampered messages are rejected
- **Replay old messages across sessions** — Each session derives fresh encryption keys, so ciphertext from one session cannot be replayed into another session. Within a live session, replay protection is not yet implemented; the protocol uses random nonces and does not track nonce reuse or message counters.
- **Read your traffic** — All messages are encrypted with AES-256-GCM after the handshake
- **Forge messages** — GCM provides authenticated encryption; tampered messages are rejected
- **Replay old messages** — Each session derives fresh encryption keys
### Trust model
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.
## Local daemon trust boundary
By default, the daemon binds to `127.0.0.1`. The local control plane is trusted by network reachability, not by an additional authentication token.
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.
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.
For remote access, use the relay connection. It is the supported path for reaching the daemon off-machine, and it adds end-to-end encryption plus a pairing handshake before commands are accepted.
Host header validation and CORS origin checks are defense-in-depth controls for localhost exposure. They help block DNS rebinding and browser-based attacks, but they do not replace network isolation.
## DNS rebinding protection
CORS is not a complete security boundary. It controls which browser origins can make requests, but does not prevent a malicious website from resolving its domain to your local machine (DNS rebinding).
@@ -63,4 +51,4 @@ Paseo wraps agent CLIs (Claude Code, Codex, OpenCode) but does not manage their
## Reporting vulnerabilities
If you discover a security vulnerability, please report it privately by emailing hello@moboudra.com. Do not open a public issue.
If you discover a security vulnerability, please report it privately by emailing mo@faro.so. Do not open a public issue.
-32
View File
@@ -1,32 +0,0 @@
{
"$schema": "https://biomejs.dev/schemas/2.0.0/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"includes": ["**", "!*.lock"]
},
"formatter": {
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100
},
"javascript": {
"formatter": {
"quoteStyle": "double",
"trailingCommas": "all",
"semicolons": "always"
}
},
"css": {
"parser": {
"cssModules": true,
"tailwindDirectives": true
}
},
"linter": {
"enabled": false
}
}
-159
View File
@@ -1,159 +0,0 @@
# Ad-hoc daemon testing
Spin up an isolated daemon programmatically without touching the main daemon on port 6767.
## Quick start
```typescript
import os from "node:os";
import path from "node:path";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import pino from "pino";
import { createPaseoDaemon } from "./bootstrap.js";
import { DaemonClient } from "./test-utils/daemon-client.js";
const logger = pino({ level: "warn" });
const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-test-"));
const paseoHome = path.join(paseoHomeRoot, ".paseo");
await mkdir(paseoHome, { recursive: true });
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
const daemon = await createPaseoDaemon(
{
listen: "127.0.0.1:0", // OS picks a free port
paseoHome,
corsAllowedOrigins: [],
allowedHosts: true,
mcpEnabled: false,
staticDir,
mcpDebug: false,
agentClients: {},
agentStoragePath: path.join(paseoHome, "agents"),
relayEnabled: false,
relayEndpoint: "relay.paseo.sh:443",
appBaseUrl: "https://app.paseo.sh",
// Add custom config here, e.g.:
// providerOverrides: { ... },
},
logger,
);
await daemon.start();
const target = daemon.getListenTarget();
const port = target!.type === "tcp" ? target!.port : null;
const client = new DaemonClient({
url: `ws://127.0.0.1:${port}/ws`,
appVersion: "0.1.54", // see gotcha #1
});
await client.connect();
await client.fetchAgents({ subscribe: { subscriptionId: "test" } });
// ... do your testing ...
await client.close();
await daemon.stop();
await rm(paseoHomeRoot, { recursive: true, force: true });
await rm(staticDir, { recursive: true, force: true });
```
Run with:
```bash
npx tsx packages/server/src/server/your-script.ts
```
## Using the test helper
For simpler cases, `createTestPaseoDaemon` + `DaemonClient` handles temp dirs and port selection:
```typescript
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
import { DaemonClient } from "./test-utils/daemon-client.js";
const daemon = await createTestPaseoDaemon();
const client = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
appVersion: "0.1.54",
});
await client.connect();
await client.fetchAgents({ subscribe: { subscriptionId: "test" } });
// ... test ...
await client.close();
await daemon.close(); // stops daemon + cleans up temp dirs
```
The test helper does **not** expose `providerOverrides`. Use `createPaseoDaemon` directly when you need it (see quick start above).
## Common client methods
```typescript
// Provider discovery
const snapshot = await client.getProvidersSnapshot({ cwd: "/tmp" });
const models = await client.listProviderModels("claude");
const modes = await client.listProviderModes("claude");
// Agent lifecycle
const agent = await client.createAgent({ provider: "claude", cwd: "/tmp" });
await client.sendMessage(agent.id, "Hello");
const updated = await client.waitForAgentUpsert(agent.id, (s) => s.status === "idle");
```
## Gotchas
### 1. appVersion gates provider visibility
The daemon hides non-legacy providers (anything other than claude, codex, opencode) from clients that don't send an `appVersion >= 0.1.45`. The `DaemonClient` sends no version by default, so custom providers like ACP-based ones will be invisible in snapshot responses.
Always pass `appVersion`:
```typescript
const client = new DaemonClient({
url: `ws://127.0.0.1:${port}/ws`,
appVersion: "0.1.54",
});
```
### 2. Provider snapshots are async
After the daemon starts, providers are probed in the background. The first `getProvidersSnapshot()` call will likely return `status: "loading"` for most providers. Poll until the provider you care about is no longer loading:
```typescript
let snapshot = await client.getProvidersSnapshot({ cwd: "/tmp" });
for (let i = 0; i < 20; i++) {
const entry = snapshot.entries.find((e) => e.provider === "gemini");
if (entry && entry.status !== "loading") break;
await new Promise((r) => setTimeout(r, 2_000));
snapshot = await client.getProvidersSnapshot({ cwd: "/tmp" });
}
```
### 3. fetchAgents is required before most operations
Call `client.fetchAgents()` after connecting. The daemon session expects this handshake before it processes other requests — without it, messages like `get_providers_snapshot_request` will silently hang.
### 4. listen: "127.0.0.1:0" for port allocation
Always use port `0` so the OS picks a free port. Never hardcode a port — it will collide with the main daemon or other test runs.
### 5. Script must live inside packages/server
The test utilities use relative imports through the TypeScript project. Place your script somewhere under `packages/server/src/` and import from there. Scripts outside the repo will fail with module resolution errors.
### 6. Cleanup on failure
Wrap your test logic in try/finally to ensure the daemon stops and temp dirs are cleaned up, even if an assertion fails:
```typescript
try {
// ... test logic ...
} finally {
await client.close();
await daemon.stop().catch(() => undefined);
await rm(paseoHomeRoot, { recursive: true, force: true });
}
```
### 7. ACP providers spawn real processes
When testing ACP providers (e.g., Gemini with `extends: "acp"`), the daemon will spawn real processes to probe for models and modes. The binary must be installed and on PATH. Probing can take 5-15 seconds depending on the provider.
+1 -3
View File
@@ -46,13 +46,11 @@ adb exec-out screencap -p > screenshot.png
## Cloud build + submit (EAS)
Stable tag pushes like `v0.1.0` trigger:
Tag pushes like `v0.1.0` trigger:
- `packages/app/.eas/workflows/release-mobile.yml` on Expo servers (iOS + Android build + submit)
- `.github/workflows/android-apk-release.yml` on GitHub Actions (APK asset on GitHub Release)
Release candidate tags like `v0.1.1-rc.1` only trigger the GitHub APK workflow. They publish a GitHub prerelease APK for testing and do not submit to the stores.
### Useful commands
```bash
+4 -12
View File
@@ -9,7 +9,7 @@ Your code never leaves your machine. Paseo is local-first.
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Mobile App │ │ CLI │ │ Desktop App │
│ (Expo) │ │ (Commander) │ │ (Electron) │
│ (Expo) │ │ (Commander) │ │ (Tauri) │
└──────┬───────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
│ WebSocket │ WebSocket │ Managed subprocess
@@ -31,14 +31,6 @@ Your code never leaves your machine. Paseo is local-first.
└───────────┘ └────────┘ └──────────┘
```
## Components at a glance
- **Daemon:** Local server that spawns and manages agent processes and exposes the WebSocket API.
- **App:** Cross-platform Expo client for iOS, Android, web, and the shared UI used by desktop.
- **CLI:** Terminal interface for agent workflows that can also start and manage the daemon.
- **Desktop app:** Electron wrapper around the web app that bundles and auto-manages its own daemon.
- **Relay:** Optional encrypted bridge for remote access without opening ports directly.
## Packages
### `packages/server` — The daemon
@@ -98,9 +90,9 @@ Enables remote access when the daemon is behind a firewall.
See [SECURITY.md](../SECURITY.md) for the full threat model.
### `packages/desktop` — Desktop app (Electron)
### `packages/desktop` — Desktop app (Tauri)
Electron wrapper for macOS, Linux, and Windows.
Tauri wrapper for macOS, Linux, and Windows.
- Can spawn the daemon as a managed subprocess
- Native file access for workspace integration
@@ -188,5 +180,5 @@ $PASEO_HOME/
## Deployment models
1. **Local daemon** (default): `paseo daemon start` on `127.0.0.1:6767`
2. **Managed desktop**: Electron app spawns daemon as subprocess
2. **Managed desktop**: Tauri app spawns daemon as subprocess
3. **Remote + relay**: Daemon behind firewall, relay bridges with E2E encryption
-522
View File
@@ -1,522 +0,0 @@
# Custom Provider Configuration
Paseo supports configuring custom agent providers through `config.json` (located at `$PASEO_HOME/config.json`, typically `~/.paseo/config.json`). You can extend built-in providers with different API backends, add ACP-compatible agents, set custom binaries, disable providers, and create multiple profiles for the same underlying provider.
All provider configuration lives under `agents.providers` in config.json:
```json
{
"version": 1,
"agents": {
"providers": {
"provider-id": { ... }
}
}
}
```
Provider IDs must be lowercase alphanumeric with hyphens (`/^[a-z][a-z0-9-]*$/`).
---
## Table of Contents
- [Extending a built-in provider](#extending-a-built-in-provider)
- [Z.AI (Zhipu) coding plan](#zai-zhipu-coding-plan)
- [Alibaba Cloud (Qwen) coding plan](#alibaba-cloud-qwen-coding-plan)
- [Multiple profiles for the same provider](#multiple-profiles-for-the-same-provider)
- [Custom binary for a provider](#custom-binary-for-a-provider)
- [Disabling a provider](#disabling-a-provider)
- [ACP providers](#acp-providers)
- [Provider override reference](#provider-override-reference)
---
## Extending a built-in provider
Use `extends` to create a new provider entry that inherits from a built-in provider (claude, codex, copilot, opencode, pi). The new provider gets its own entry in the provider list, with its own label, environment, and model definitions.
```json
{
"agents": {
"providers": {
"my-claude": {
"extends": "claude",
"label": "My Claude",
"description": "Claude with custom API endpoint",
"env": {
"ANTHROPIC_API_KEY": "sk-ant-...",
"ANTHROPIC_BASE_URL": "https://my-proxy.example.com/v1"
}
}
}
}
}
```
Required fields for custom providers:
- `extends` — which built-in provider to inherit from (or `"acp"`)
- `label` — display name in the UI
---
## Z.AI (Zhipu) coding plan
[Z.AI](https://z.ai) is a Chinese AI company (Zhipu AI) that offers an Anthropic-compatible API endpoint. Their GLM Coding Plan provides flat-rate access to GLM models through Claude Code's Anthropic API protocol. These are **not** Anthropic Claude models — they are Zhipu's own GLM models exposed through an Anthropic-compatible API.
### Setup
1. Register at [z.ai](https://z.ai) and subscribe to a coding plan
2. Create an API key from the Z.AI dashboard
3. Add a provider entry in config.json:
```json
{
"agents": {
"providers": {
"zai": {
"extends": "claude",
"label": "ZAI",
"env": {
"ANTHROPIC_AUTH_TOKEN": "<your-zai-api-key>",
"ANTHROPIC_BASE_URL": "https://api.z.ai/api/anthropic",
"API_TIMEOUT_MS": "3000000"
},
"models": [
{ "id": "glm-4.5-air", "label": "GLM 4.5 Air" },
{ "id": "glm-5-turbo", "label": "GLM 5 Turbo", "isDefault": true },
{ "id": "glm-5.1", "label": "GLM 5.1" }
]
}
}
}
}
```
### Available models
| Model | Tier |
|---|---|
| `glm-5.1` | Advanced (flagship) |
| `glm-5-turbo` | Advanced |
| `glm-4.7` | Standard |
| `glm-4.5-air` | Lightweight |
### Notes
- `ANTHROPIC_AUTH_TOKEN` is used instead of `ANTHROPIC_API_KEY` — this is the z.ai API key
- The `API_TIMEOUT_MS` env var extends the request timeout (z.ai can be slower than direct Anthropic)
- If you get auth errors, run `/logout` inside Claude Code before switching to the z.ai provider
- Automated setup is also available: `npx @z_ai/coding-helper`
- Official docs: [docs.z.ai/devpack/tool/claude](https://docs.z.ai/devpack/tool/claude)
---
## Alibaba Cloud (Qwen) coding plan
[Alibaba Cloud Model Studio](https://www.alibabacloud.com/en/campaign/ai-scene-coding) offers a coding plan that routes Claude Code requests to Qwen models through an Anthropic-compatible API. Like z.ai, these are **not** Anthropic Claude models.
### Setup
1. Go to the [Coding Plan page](https://modelstudio.console.alibabacloud.com/ap-southeast-1/?tab=globalset#/efm/coding_plan) on Alibaba Cloud Model Studio (Singapore region)
2. Subscribe to the Pro plan ($50/month)
3. Obtain your plan-specific API key (format: `sk-sp-xxxxx`) — this is different from a standard Model Studio key
4. Add a provider entry in config.json:
```json
{
"agents": {
"providers": {
"qwen": {
"extends": "claude",
"label": "Qwen (Alibaba)",
"env": {
"ANTHROPIC_AUTH_TOKEN": "sk-sp-<your-coding-plan-key>",
"ANTHROPIC_BASE_URL": "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic"
},
"models": [
{ "id": "qwen3.5-plus", "label": "Qwen 3.5 Plus", "isDefault": true },
{ "id": "qwen3-coder-next", "label": "Qwen 3 Coder Next" },
{ "id": "kimi-k2.5", "label": "Kimi K2.5" }
]
}
}
}
}
```
### API endpoints
| Mode | Base URL |
|---|---|
| Coding plan (subscription) | `https://coding-intl.dashscope.aliyuncs.com/apps/anthropic` |
| Pay-as-you-go (no subscription) | `https://dashscope-intl.aliyuncs.com/apps/anthropic` |
For pay-as-you-go, use `ANTHROPIC_API_KEY` with a standard Model Studio key (`sk-xxxxx`) instead of `ANTHROPIC_AUTH_TOKEN`.
### Available models
**Recommended for coding plan:**
| Model | Notes |
|---|---|
| `qwen3.5-plus` | Vision capable, recommended |
| `qwen3-coder-next` | Optimized for coding |
| `kimi-k2.5` | Vision capable |
| `glm-5` | Zhipu GLM |
| `MiniMax-M2.5` | MiniMax |
**Additional models (pay-as-you-go):**
`qwen3-max`, `qwen3.5-flash`, `qwen3-coder-plus`, `qwen3-coder-flash`, `qwen3-vl-plus`, `qwen3-vl-flash`
### Notes
- API keys must be created in the **Singapore region**
- The coding plan is for personal use only in interactive coding tools
- Official docs: [alibabacloud.com/help/en/model-studio/claude-code-coding-plan](https://www.alibabacloud.com/help/en/model-studio/claude-code-coding-plan)
---
## Multiple profiles for the same provider
You can create multiple entries that extend the same built-in provider. Each gets its own entry in the provider list with independent credentials, models, and environment.
Example: two different Anthropic accounts as separate profiles:
```json
{
"agents": {
"providers": {
"claude-work": {
"extends": "claude",
"label": "Claude (Work)",
"description": "Work Anthropic account",
"env": {
"ANTHROPIC_API_KEY": "sk-ant-work-..."
}
},
"claude-personal": {
"extends": "claude",
"label": "Claude (Personal)",
"description": "Personal Anthropic account",
"env": {
"ANTHROPIC_API_KEY": "sk-ant-personal-..."
}
}
}
}
}
```
Each profile appears as a separate provider in the Paseo app. You can select which one to use when launching an agent.
You can also combine profiles with model overrides to pin specific models per profile:
```json
{
"agents": {
"providers": {
"claude-fast": {
"extends": "claude",
"label": "Claude (Fast)",
"models": [
{ "id": "claude-sonnet-4-6", "label": "Sonnet 4.6", "isDefault": true }
]
},
"claude-smart": {
"extends": "claude",
"label": "Claude (Smart)",
"models": [
{ "id": "claude-opus-4-6", "label": "Opus 4.6", "isDefault": true }
]
}
}
}
}
```
---
## Custom binary for a provider
Override the command used to launch any provider with the `command` field. This is an array where the first element is the binary and the rest are arguments.
### Override a built-in provider's binary
```json
{
"agents": {
"providers": {
"claude": {
"command": ["/opt/claude-nightly/claude"]
}
}
}
}
```
### Use a custom wrapper script
```json
{
"agents": {
"providers": {
"claude": {
"command": ["/usr/local/bin/my-claude-wrapper", "--verbose"]
}
}
}
}
```
### Custom binary on a derived provider
```json
{
"agents": {
"providers": {
"my-codex": {
"extends": "codex",
"label": "Codex (Custom Build)",
"command": ["/home/user/codex-dev/target/release/codex"]
}
}
}
}
```
The `command` array completely replaces the default command for that provider. The binary must exist on the system — Paseo checks for its availability and will mark the provider as unavailable if not found.
---
## Disabling a provider
Set `enabled: false` to hide a provider from the provider list. The provider will not appear in the app or CLI.
```json
{
"agents": {
"providers": {
"copilot": { "enabled": false },
"codex": { "enabled": false }
}
}
}
```
This works for both built-in and custom providers. To re-enable, set `enabled: true` or remove the `enabled` field entirely (providers are enabled by default).
---
## ACP providers
The [Agent Client Protocol (ACP)](https://agentclientprotocol.com) is an open standard for communication between editors and AI coding agents — think LSP but for AI agents. Any agent that supports ACP can be added to Paseo as a custom provider.
ACP agents communicate over JSON-RPC 2.0 on stdio. Paseo spawns the agent process and talks to it through stdin/stdout.
### Adding a generic ACP provider
Set `extends: "acp"` and provide a `command`:
```json
{
"agents": {
"providers": {
"my-agent": {
"extends": "acp",
"label": "My Agent",
"command": ["my-agent-binary", "--acp"],
"env": {
"MY_API_KEY": "..."
}
}
}
}
}
```
Required fields for ACP providers:
- `extends: "acp"`
- `label`
- `command` — the command to spawn the agent process (must support ACP over stdio)
### Example: Google Gemini CLI
[Gemini CLI](https://github.com/google-gemini/gemini-cli) supports ACP via the `--acp` flag.
1. Install: `npm install -g @anthropic-ai/gemini-cli` or see [Gemini CLI docs](https://github.com/google-gemini/gemini-cli)
2. Authenticate with Google (Gemini CLI handles its own auth)
3. Add to config.json:
```json
{
"agents": {
"providers": {
"gemini": {
"extends": "acp",
"label": "Google Gemini",
"command": ["gemini", "--acp"]
}
}
}
}
```
Ref: [Gemini CLI ACP mode docs](https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/acp-mode.md)
### Example: Hermes (Nous Research)
[Hermes](https://github.com/NousResearch/hermes-agent) is an open-source coding agent by Nous Research with persistent memory and multi-provider LLM support. It supports ACP via the `acp` subcommand.
1. Install: `curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash`
2. Install ACP support: `pip install -e '.[acp]'`
3. Configure Hermes credentials in `~/.hermes/`
4. Add to config.json:
```json
{
"agents": {
"providers": {
"hermes": {
"extends": "acp",
"label": "Hermes",
"description": "Nous Research self-improving AI agent",
"command": ["hermes", "acp"]
}
}
}
}
```
Ref: [Hermes ACP docs](https://hermes-agent.nousresearch.com/docs/user-guide/features/acp)
### How ACP providers work in Paseo
When you launch an agent with an ACP provider:
1. Paseo spawns the process using the configured `command`
2. Sends an `initialize` JSON-RPC request over stdin
3. The agent responds with its capabilities, available modes, and models
4. Paseo creates a session and sends prompts through the ACP protocol
5. The agent streams responses, tool calls, and permission requests back over stdout
Models and modes are discovered dynamically at runtime from the agent process. If you want to override the model list (e.g., to curate which models appear in the UI), use the `models` field:
```json
{
"agents": {
"providers": {
"my-agent": {
"extends": "acp",
"label": "My Agent",
"command": ["my-agent", "--acp"],
"models": [
{ "id": "fast-model", "label": "Fast", "isDefault": true },
{ "id": "smart-model", "label": "Smart" }
]
}
}
}
}
```
Profile models (defined in config.json) completely replace runtime-discovered models when present.
---
## Provider override reference
Every entry under `agents.providers` accepts these fields:
| Field | Type | Required | Description |
|---|---|---|---|
| `extends` | `string` | Yes (custom only) | Built-in provider ID to inherit from, or `"acp"` |
| `label` | `string` | Yes (custom only) | Display name in the UI |
| `description` | `string` | No | Short description shown in the UI |
| `command` | `string[]` | Yes (ACP only) | Command to spawn the agent process |
| `env` | `Record<string, string>` | No | Environment variables to set for the agent process |
| `models` | `ProviderProfileModel[]` | No | Static model list (overrides runtime discovery) |
| `enabled` | `boolean` | No | Set to `false` to hide the provider (default: `true`) |
| `order` | `number` | No | Sort order in the provider list |
### Model definition
Each entry in the `models` array:
| Field | Type | Required | Description |
|---|---|---|---|
| `id` | `string` | Yes | Model identifier sent to the provider |
| `label` | `string` | Yes | Display name in the UI |
| `description` | `string` | No | Short description |
| `isDefault` | `boolean` | No | Mark as the default model selection |
| `thinkingOptions` | `ThinkingOption[]` | No | Available thinking/reasoning levels |
### Thinking option
| Field | Type | Required | Description |
|---|---|---|---|
| `id` | `string` | Yes | Thinking option identifier |
| `label` | `string` | Yes | Display name |
| `description` | `string` | No | Short description |
| `isDefault` | `boolean` | No | Mark as the default thinking option |
### Valid `extends` values
Built-in providers: `claude`, `codex`, `copilot`, `opencode`, `pi`
Special value: `acp` — creates a generic ACP provider (requires `command`)
### Full example
A config.json with multiple custom providers:
```json
{
"version": 1,
"agents": {
"providers": {
"copilot": { "enabled": false },
"zai": {
"extends": "claude",
"label": "ZAI",
"env": {
"ANTHROPIC_AUTH_TOKEN": "<zai-api-key>",
"ANTHROPIC_BASE_URL": "https://api.z.ai/api/anthropic",
"API_TIMEOUT_MS": "3000000"
},
"models": [
{ "id": "glm-4.5-air", "label": "GLM 4.5 Air" },
{ "id": "glm-5-turbo", "label": "GLM 5 Turbo", "isDefault": true },
{ "id": "glm-5.1", "label": "GLM 5.1" }
]
},
"qwen": {
"extends": "claude",
"label": "Qwen (Alibaba)",
"env": {
"ANTHROPIC_AUTH_TOKEN": "sk-sp-<coding-plan-key>",
"ANTHROPIC_BASE_URL": "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic"
},
"models": [
{ "id": "qwen3.5-plus", "label": "Qwen 3.5 Plus", "isDefault": true },
{ "id": "qwen3-coder-next", "label": "Qwen 3 Coder Next" }
]
},
"gemini": {
"extends": "acp",
"label": "Google Gemini",
"command": ["gemini", "--acp"]
},
"hermes": {
"extends": "acp",
"label": "Hermes",
"command": ["hermes", "acp"]
}
}
}
}
```
-428
View File
@@ -1,428 +0,0 @@
# Data Model
Paseo uses **file-based JSON persistence** instead of a traditional database. All data is validated at runtime with Zod schemas and written atomically (write to temp file, then rename). There are no migrations — schemas use optional fields with defaults for forward compatibility.
All server-side stores live under `$PASEO_HOME` (defaults to `~/.paseo`).
---
## Directory layout
```
$PASEO_HOME/
├── config.json # Daemon configuration
├── agents/
│ └── {project-dir}/
│ └── {agentId}.json # One file per agent
├── schedules/
│ └── {scheduleId}.json # One file per schedule
├── chat/
│ └── rooms.json # All rooms + messages
├── loops/
│ └── loops.json # All loop records
├── projects/
│ ├── projects.json # Project registry
│ └── workspaces.json # Workspace registry
└── push-tokens.json # Expo push notification tokens
```
---
## 1. Agent Record
**Path:** `$PASEO_HOME/agents/{project-dir}/{agentId}.json`
Each agent is stored as a separate JSON file, grouped by project directory.
| Field | Type | Description |
|---|---|---|
| `id` | `string` | UUID, primary key |
| `provider` | `string` | Agent provider (`"claude"`, `"codex"`, `"opencode"`, etc.) |
| `cwd` | `string` | Working directory the agent operates in |
| `createdAt` | `string` (ISO 8601) | Creation timestamp |
| `updatedAt` | `string` (ISO 8601) | Last update timestamp |
| `lastActivityAt` | `string?` (ISO 8601) | Last activity timestamp |
| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp |
| `title` | `string?` | User-visible title |
| `labels` | `Record<string, string>` | Key-value labels (default `{}`) |
| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"` |
| `lastModeId` | `string?` | Last active mode ID |
| `config` | `SerializableConfig?` | Agent session configuration (see below) |
| `runtimeInfo` | `RuntimeInfo?` | Live runtime state (see below) |
| `features` | `AgentFeature[]?` | Provider-reported features (toggles/selects) |
| `persistence` | `PersistenceHandle?` | Handle for resuming sessions |
| `requiresAttention` | `boolean?` | Whether the agent needs user attention |
| `attentionReason` | `"finished" \| "error" \| "permission"?` | Why attention is needed |
| `attentionTimestamp` | `string?` (ISO 8601) | When attention was flagged |
| `internal` | `boolean?` | Whether this is a system-internal agent (loop workers, etc.) |
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
### Nested: SerializableConfig
| Field | Type | Description |
|---|---|---|
| `title` | `string?` | Configured title |
| `modeId` | `string?` | Configured mode |
| `model` | `string?` | Configured model |
| `thinkingOptionId` | `string?` | Thinking/reasoning level |
| `featureValues` | `Record<string, unknown>?` | Feature preference overrides |
| `extra` | `Record<string, any>?` | Provider-specific config |
| `systemPrompt` | `string?` | Custom system prompt |
| `mcpServers` | `Record<string, any>?` | MCP server configurations |
### Nested: RuntimeInfo
| Field | Type | Description |
|---|---|---|
| `provider` | `string` | Active provider |
| `sessionId` | `string?` | Active session ID |
| `model` | `string?` | Active model |
| `thinkingOptionId` | `string?` | Active thinking option |
| `modeId` | `string?` | Active mode |
| `extra` | `Record<string, unknown>?` | Provider-specific runtime data |
### Nested: PersistenceHandle
| Field | Type | Description |
|---|---|---|
| `provider` | `string` | Provider that owns the session |
| `sessionId` | `string` | Session ID for resumption |
| `nativeHandle` | `any?` | Provider-specific handle (Codex thread ID, Claude resume token, etc.) |
| `metadata` | `Record<string, any>?` | Extra metadata |
### Nested: AgentFeature (discriminated union on `type`)
**Toggle:**
| Field | Type |
|---|---|
| `type` | `"toggle"` |
| `id` | `string` |
| `label` | `string` |
| `description` | `string?` |
| `tooltip` | `string?` |
| `icon` | `string?` |
| `value` | `boolean` |
**Select:**
| Field | Type |
|---|---|
| `type` | `"select"` |
| `id` | `string` |
| `label` | `string` |
| `description` | `string?` |
| `tooltip` | `string?` |
| `icon` | `string?` |
| `value` | `string?` |
| `options` | `AgentSelectOption[]` |
---
## 2. Daemon Configuration
**Path:** `$PASEO_HOME/config.json`
Single file, validated with `PersistedConfigSchema`.
```
{
version: 1,
daemon: {
listen: "127.0.0.1:6767",
allowedHosts: true | string[],
mcp: { enabled: boolean },
cors: { allowedOrigins: string[] },
relay: { enabled: boolean, endpoint: string, publicEndpoint: string }
},
app: {
baseUrl: string
},
providers: {
openai: { apiKey: string },
local: { modelsDir: string }
},
agents: {
providers: {
[provider: string]: {
command: { mode: "default" } | { mode: "append", args: string[] } | { mode: "replace", argv: string[] },
env: Record<string, string>
}
}
},
features: {
dictation: { enabled, stt: { provider, model, confidenceThreshold } },
voiceMode: { enabled, llm, stt, turnDetection, tts: { provider, model, voice, speakerId, speed } }
},
log: {
level, format,
console: { level, format },
file: { level, path, rotate: { maxSize, maxFiles } }
}
}
```
All fields are optional with sensible defaults.
---
## 3. Schedule
**Path:** `$PASEO_HOME/schedules/{id}.json`
One file per schedule. ID is 8 hex characters.
| Field | Type | Description |
|---|---|---|
| `id` | `string` | 8-char hex ID |
| `name` | `string?` | Human-readable name |
| `prompt` | `string` | The prompt to send |
| `cadence` | `ScheduleCadence` | Timing (see below) |
| `target` | `ScheduleTarget` | What to run (see below) |
| `status` | `"active" \| "paused" \| "completed"` | Current state |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | |
| `nextRunAt` | `string?` (ISO 8601) | Next scheduled execution |
| `lastRunAt` | `string?` (ISO 8601) | Last execution time |
| `pausedAt` | `string?` (ISO 8601) | When paused |
| `expiresAt` | `string?` (ISO 8601) | Auto-expire time |
| `maxRuns` | `number?` | Max executions before completing |
| `runs` | `ScheduleRun[]` | Execution history |
### Nested: ScheduleCadence (discriminated union on `type`)
- `{ type: "every", everyMs: number }` — interval in milliseconds
- `{ type: "cron", expression: string }` — cron expression
### Nested: ScheduleTarget (discriminated union on `type`)
- `{ type: "agent", agentId: string }` — send to existing agent
- `{ type: "new-agent", config: { provider, cwd, modeId?, model?, thinkingOptionId?, title?, approvalPolicy?, sandboxMode?, networkAccess?, webSearch?, extra?, systemPrompt?, mcpServers? } }` — create a new agent
### Nested: ScheduleRun
| Field | Type | Description |
|---|---|---|
| `id` | `string` | Run ID |
| `scheduledFor` | `string` (ISO 8601) | Intended execution time |
| `startedAt` | `string` (ISO 8601) | |
| `endedAt` | `string?` (ISO 8601) | |
| `status` | `"running" \| "succeeded" \| "failed"` | |
| `agentId` | `string?` (UUID) | Agent used for this run |
| `output` | `string?` | Agent output text |
| `error` | `string?` | Error message if failed |
---
## 4. Chat
**Path:** `$PASEO_HOME/chat/rooms.json`
Single file containing all rooms and messages.
```json
{
"rooms": [ ... ],
"messages": [ ... ]
}
```
### ChatRoom
| Field | Type | Description |
|---|---|---|
| `id` | `string` (UUID) | |
| `name` | `string` | Unique room name (case-insensitive) |
| `purpose` | `string?` | Room description |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | Updated on each new message |
### ChatMessage
| Field | Type | Description |
|---|---|---|
| `id` | `string` (UUID) | |
| `roomId` | `string` | FK to ChatRoom.id |
| `authorAgentId` | `string` | Agent ID of the author |
| `body` | `string` | Message text (supports `@mentions`) |
| `replyToMessageId` | `string?` | FK to another ChatMessage.id |
| `mentionAgentIds` | `string[]` | Extracted `@mention` agent IDs |
| `createdAt` | `string` (ISO 8601) | |
---
## 5. Loop
**Path:** `$PASEO_HOME/loops/loops.json`
Single file containing an array of all loop records.
| Field | Type | Description |
|---|---|---|
| `id` | `string` | 8-char UUID prefix |
| `name` | `string?` | Human-readable name |
| `prompt` | `string` | Worker prompt |
| `cwd` | `string` | Working directory |
| `provider` | `string` | Default provider |
| `model` | `string?` | Default model |
| `workerProvider` | `string?` | Override provider for workers |
| `workerModel` | `string?` | Override model for workers |
| `verifierProvider` | `string?` | Override provider for verifiers |
| `verifierModel` | `string?` | Override model for verifiers |
| `verifyPrompt` | `string?` | LLM verification prompt |
| `verifyChecks` | `string[]` | Shell commands to run as checks |
| `archive` | `boolean` | Whether to archive worker agents after use |
| `sleepMs` | `number` | Delay between iterations (ms) |
| `maxIterations` | `number?` | Cap on iterations |
| `maxTimeMs` | `number?` | Total time budget (ms) |
| `status` | `"running" \| "succeeded" \| "failed" \| "stopped"` | |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | |
| `startedAt` | `string` (ISO 8601) | |
| `completedAt` | `string?` (ISO 8601) | |
| `stopRequestedAt` | `string?` (ISO 8601) | |
| `iterations` | `LoopIteration[]` | |
| `logs` | `LoopLogEntry[]` | |
| `nextLogSeq` | `number` | Monotonic log sequence counter |
| `activeIteration` | `number?` | Currently executing iteration index |
| `activeWorkerAgentId` | `string?` | Currently running worker agent |
| `activeVerifierAgentId` | `string?` | Currently running verifier agent |
### Nested: LoopIteration
| Field | Type | Description |
|---|---|---|
| `index` | `number` | 1-based iteration index |
| `workerAgentId` | `string?` | Agent ID of the worker |
| `workerStartedAt` | `string` (ISO 8601) | |
| `workerCompletedAt` | `string?` (ISO 8601) | |
| `verifierAgentId` | `string?` | Agent ID of the verifier |
| `status` | `"running" \| "succeeded" \| "failed" \| "stopped"` | |
| `workerOutcome` | `"completed" \| "failed" \| "canceled"?` | |
| `failureReason` | `string?` | |
| `verifyChecks` | `LoopVerifyCheckResult[]` | Shell check results |
| `verifyPrompt` | `LoopVerifyPromptResult?` | LLM verification result |
### Nested: LoopLogEntry
| Field | Type |
|---|---|
| `seq` | `number` (monotonic) |
| `timestamp` | `string` (ISO 8601) |
| `iteration` | `number?` |
| `source` | `"loop" \| "worker" \| "verifier" \| "verify-check"` |
| `level` | `"info" \| "error"` |
| `text` | `string` |
### Nested: LoopVerifyCheckResult
| Field | Type |
|---|---|
| `command` | `string` |
| `exitCode` | `number` |
| `passed` | `boolean` |
| `stdout` | `string` |
| `stderr` | `string` |
| `startedAt` | `string` (ISO 8601) |
| `completedAt` | `string` (ISO 8601) |
### Nested: LoopVerifyPromptResult
| Field | Type |
|---|---|
| `passed` | `boolean` |
| `reason` | `string` |
| `verifierAgentId` | `string?` |
| `startedAt` | `string` (ISO 8601) |
| `completedAt` | `string` (ISO 8601) |
---
## 6. Project Registry
**Path:** `$PASEO_HOME/projects/projects.json`
Array of project records.
| Field | Type | Description |
|---|---|---|
| `projectId` | `string` | Primary key |
| `rootPath` | `string` | Filesystem root of the project |
| `kind` | `"git" \| "non_git"` | |
| `displayName` | `string` | |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | |
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
---
## 7. Workspace Registry
**Path:** `$PASEO_HOME/projects/workspaces.json`
Array of workspace records. A workspace is a specific working directory within a project.
| Field | Type | Description |
|---|---|---|
| `workspaceId` | `string` | Primary key |
| `projectId` | `string` | FK to Project.projectId |
| `cwd` | `string` | Filesystem path |
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | |
| `displayName` | `string` | |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | |
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
---
## 8. Push Token Store
**Path:** `$PASEO_HOME/push-tokens.json`
```json
{
"tokens": ["ExponentPushToken[...]", ...]
}
```
Simple set of Expo push notification tokens. No schema validation — just an array of strings.
---
## Client-side stores (App)
These live in React Native `AsyncStorage` or browser `IndexedDB`, not on the daemon filesystem.
### Draft Store
**AsyncStorage key:** `paseo-drafts` (version 2)
```typescript
{
drafts: Record<draftKey, {
input: { text: string, images: AttachmentMetadata[] },
lifecycle: "active" | "abandoned" | "sent",
updatedAt: number, // epoch ms
version: number // optimistic concurrency
}>,
createModalDraft: DraftRecord | null
}
```
### Attachment Store (Web)
**IndexedDB database:** `paseo-attachment-bytes`, object store: `attachments`
Stores binary attachment blobs keyed by attachment ID.
### AttachmentMetadata
| Field | Type | Description |
|---|---|---|
| `id` | `string` | Unique attachment ID |
| `mimeType` | `string` | MIME type |
| `storageType` | `string` | Storage backend identifier |
| `storageKey` | `string` | Key within the storage backend |
| `createdAt` | `number` | Epoch ms |
| `fileName` | `string?` | Original filename |
| `byteSize` | `number?` | Size in bytes |
-109
View File
@@ -1,109 +0,0 @@
# File Icons
The file explorer uses colored SVG icons from [`material-icon-theme`](https://github.com/material-extensions/vscode-material-icon-theme) (installed as a dev dependency in `packages/app`).
Icons are inlined as SVG strings in:
```
packages/app/src/components/material-file-icons.ts
```
This file is auto-generated. Do not edit it by hand.
## How it works
- `SVG_ICONS` maps icon names (e.g. `"typescript"`) to raw SVG strings
- `EXTENSION_TO_ICON` maps file extensions (e.g. `"ts"`) to icon names
- `getFileIconSvg(fileName)` returns the SVG string for a given filename, falling back to a generic file icon
## Adding a new icon
1. Find the icon name in the material-icon-theme manifest:
```bash
node -e "
const m = require('./node_modules/material-icon-theme/dist/material-icons.json');
console.log('fileExtensions:', m.fileExtensions['YOUR_EXT']);
console.log('languageIds:', m.languageIds['YOUR_LANG']);
"
```
2. Verify the SVG exists:
```bash
cat node_modules/material-icon-theme/icons/ICON_NAME.svg
```
3. Add two things to `material-file-icons.ts`:
- The SVG string in `SVG_ICONS`:
```ts
"icon_name": `<svg ...>...</svg>`,
```
- The extension mapping in `EXTENSION_TO_ICON`:
```ts
"ext": "icon_name",
```
4. Run `npm run typecheck` to verify.
## Currently included icons
53 unique icons covering these extensions:
| Extension(s) | Icon |
|---|---|
| `ts` | typescript |
| `tsx` | react_ts |
| `js` | javascript |
| `jsx` | react |
| `py` | python |
| `go` | go |
| `rs` | rust |
| `rb` | ruby |
| `java` | java |
| `kt` | kotlin |
| `c` | c |
| `cpp` | cpp |
| `h` | h |
| `hpp` | hpp |
| `cs` | csharp |
| `swift` | swift |
| `dart` | dart |
| `ex`, `exs` | elixir |
| `erl` | erlang |
| `hs` | haskell |
| `clj` | clojure |
| `scala` | scala |
| `ml` | ocaml |
| `r` | r |
| `lua` | lua |
| `zig` | zig |
| `nix` | nix |
| `php` | php |
| `html` | html |
| `css` | css |
| `scss` | sass |
| `less` | less |
| `json` | json |
| `yml`, `yaml` | yaml |
| `xml` | xml |
| `toml` | toml |
| `md`, `markdown` | markdown |
| `sql` | database |
| `graphql`, `gql` | graphql |
| `sh`, `bash` | console |
| `tf` | terraform |
| `hcl` | hcl |
| `vue` | vue |
| `svelte` | svelte |
| `astro` | astro |
| `wasm` | webassembly |
| `svg` | svg |
| `png`, `jpg`, `jpeg`, `gif`, `webp`, `ico` | image |
| `txt` | document |
| `conf`, `cfg`, `ini` | settings |
| `lock` | lock |
| `groovy` | groovy |
| `gradle` | gradle |
-206
View File
@@ -1,206 +0,0 @@
# Mobile Testing
## Maestro
Maestro flows live in `packages/app/maestro/`. Reusable sub-flows live in `packages/app/maestro/flows/`.
Run a flow:
```bash
maestro test packages/app/maestro/my-flow.yaml
```
### Screenshots
`takeScreenshot` writes to the **current working directory** — there's no way to configure the output path in the YAML. To keep screenshots out of the checkout, `cd` into a temp directory and use an absolute path for the flow:
```bash
FLOW="$(pwd)/packages/app/maestro/my-flow.yaml"
mkdir -p /tmp/maestro-out
cd /tmp/maestro-out && maestro test "$FLOW"
```
`packages/app/maestro/.gitignore` excludes `*.png` as a safety net.
### Element targeting
Use `testID` or `nativeID` on components, then target with `id:` in flows. Prefer this over text matching — text breaks on copy changes.
```tsx
// Component
<Pressable testID="sidebar-sessions" onPress={onPress}>
```
```yaml
# Flow
- tapOn:
id: "sidebar-sessions"
- assertVisible:
id: "sidebar-sessions"
```
### Conditional steps
Use `runFlow:when:visible` for steps that should only execute when a specific element is on screen:
```yaml
- runFlow:
when:
visible:
id: "sidebar-sessions"
commands:
- swipe:
direction: LEFT
duration: 300
```
This is how `flows/dev-client.yaml` handles Expo dev client screens that only appear in dev builds.
### Don't use launchApp against a running dev app
`launchApp` kills and restarts the app, disrupting Expo dev client state and host connections. For flows that test against an already-running dev app, **omit launchApp entirely** — just interact with whatever is on screen.
Use `launchApp` only in flows that need a clean start (e.g., onboarding tests).
### Swipe gestures
Use `start`/`end` with percentage coordinates for precise control:
```yaml
# Edge swipe from left to open sidebar
- swipe:
start: "5%,50%"
end: "80%,50%"
duration: 300
```
`direction: RIGHT` is simpler but less precise — use it for generic swipes, use coordinates when the start position matters (edge gestures, avoiding specific UI regions).
### Assertions
`assertVisible` checks **actual screen visibility**, not just view tree presence. An element that exists in the tree but is off-screen (e.g., `translateX: -400`) will correctly fail `assertVisible`. This makes it reliable for catching animation bugs where state says "open" but the view is visually hidden.
For async elements, use `extendedWaitUntil`:
```yaml
- extendedWaitUntil:
visible: ".*Online.*"
timeout: 90000
```
### Dev client handling
Two reusable flows handle Expo dev client screens after launch:
- `flows/launch.yaml` — handles dev launcher, dismisses dev menu, asserts "Welcome to Paseo"
- `flows/dev-client.yaml` — same but without asserting a particular app route
## Self-verification loops
Maestro can only interact with the app UI — it can't toggle iOS appearance, change locale, or simulate network conditions. For bugs that depend on system-level state, wrap Maestro in a bash script that handles the system changes between Maestro runs.
This pattern also lets agents self-verify fixes without manual user testing.
### Pattern
1. Run baseline Maestro flow (confirm feature works)
2. Make system-level change via `xcrun simctl` (toggle appearance, etc.)
3. Re-run Maestro flow (confirm feature still works)
4. Repeat N iterations to catch intermittent failures
Scripts run `maestro test` from inside a temp directory so screenshots don't dirty the checkout.
See `packages/app/maestro/test-sidebar-theme.sh` for the canonical example:
```bash
bash packages/app/maestro/test-sidebar-theme.sh 6 1
# Args: iterations=6, wait_seconds=1 between toggle and test
```
Key elements of the script pattern:
```bash
set -euo pipefail
ITERATIONS="${1:-3}"
for i in $(seq 1 "$ITERATIONS"); do
# Toggle system state
xcrun simctl ui booted appearance light
# Wait for change to propagate
sleep 1
# Run Maestro flow and capture result
if maestro test "$FLOW" 2>&1 | tee "$ITER_DIR/test.log"; then
echo "PASS"
else
echo "FAIL"
xcrun simctl io booted screenshot "$ITER_DIR/failure-state.png"
fi
done
```
## Unistyles + Reanimated
### The crash
Applying Unistyles theme-reactive styles (`StyleSheet.create((theme) => ...)`) directly to `Animated.View` causes **"Unable to find node on an unmounted component"** on theme change.
Unistyles wraps styled components in `<UnistylesComponent>` and patches native view properties via C++. Reanimated also manages the same native node for animated transforms. When the theme changes, both systems try to update the node simultaneously and the view crashes.
### The fix
Use plain React Native `StyleSheet.create` for static positioning on `Animated.View`. Pass theme-dependent values as inline styles from `useUnistyles()`:
```tsx
// BAD: Unistyles dynamic style on Animated.View
const styles = StyleSheet.create((theme) => ({
sidebar: {
position: "absolute",
top: 0,
left: 0,
bottom: 0,
backgroundColor: theme.colors.surfaceSidebar, // theme-reactive
overflow: "hidden",
},
}));
<Animated.View style={[styles.sidebar, animatedStyle]} />
```
```tsx
// GOOD: static stylesheet + inline theme values
import { StyleSheet as RNStyleSheet } from "react-native";
const staticStyles = RNStyleSheet.create({
sidebar: {
position: "absolute",
top: 0,
left: 0,
bottom: 0,
overflow: "hidden",
},
});
const { theme } = useUnistyles();
<Animated.View
style={[staticStyles.sidebar, animatedStyle, { backgroundColor: theme.colors.surfaceSidebar }]}
/>
```
Regular `View` components can safely use Unistyles dynamic styles — the conflict is specific to `Animated.View`.
## iOS Simulator
```bash
# Screenshot
xcrun simctl io booted screenshot /tmp/screenshot.png
# Dark/light mode
xcrun simctl ui booted appearance # check current
xcrun simctl ui booted appearance dark # set dark
xcrun simctl ui booted appearance light # set light
```
Expo dev server logs are in the tmux pane running `npm run dev`. Daemon logs are at `$PASEO_HOME/daemon.log` (see [DEVELOPMENT.md](DEVELOPMENT.md)).
-74
View File
@@ -1,74 +0,0 @@
# Product
What Paseo is, who it's for, and where it's going.
## What is Paseo
Paseo is a next-generation development environment built around agents. One interface to run, monitor, and interact with coding agents across desktop, mobile, terminal, and web.
The development workflow is shifting from manually editing files to orchestrating agents that do the editing. Paseo is built for that workflow.
## Core philosophy
Freedom and flexibility. Every design decision follows from this:
- **Multi-provider** — Use any coding agent harness. Pick the right model for each job, switch freely as the landscape shifts. No vendor-lock in.
- **Cross-device** — Desktop, mobile, web, CLI. Start work at your desk, check progress from your phone, script from the terminal.
- **Self-hosted** — The daemon runs on your machine. Your code, your keys, your environment. No inference markup, no cloud dependency.
- **Respectful** - No telemetry, no forced cloud, no forced accounts
- **Open source** — AGPL-3.0. Users can inspect, fork, and contribute.
- **BYOK** — Bring your own keys. Use your subsidized plans and first-party provider pricing. Paseo adds zero cost on top.
## How it works
### Projects and workspaces
Projects are grouped in the sidebar, detected automatically from your filesystem and tagged by git remote when available.
Each project opens as a workspace. For git projects, the default workspace is the main checkout. Users can create additional workspaces, which are isolated copies (git worktrees) where agents work without affecting main.
### Inside a workspace
A workspace is a flexible canvas:
- Launch multiple agents side by side in split panes
- Open terminals alongside agents
- Mix and match providers within the same workspace
### The daemon
Paseo is a client-server system. The daemon (Node.js) runs on your machine, manages agent processes, and streams output in real time over WebSocket. Clients connect to the daemon — locally or remotely.
This architecture means:
- The daemon can run on any machine: laptop, VM, remote server
- Multiple clients can connect simultaneously
- Agents keep running when you close the app
## Target user
Anyone who builds software:
- Care about owning their tools and their data
- Use multiple AI providers and want to switch freely
- Run agents on real tasks across real projects
- Want to work from multiple devices
## What compounds over time
- **Trust** — Showing up daily, shipping in public, being open source. Earned slowly, lost quickly.
- **Community contributions** — Code, packaging, skills, agent configs. Contributors become advocates.
- **Ecosystem** — Skills, integrations, shared configs. Community-built content that makes the platform more valuable.
## Strategic bets
1. **Models commoditize.** Value moves to the orchestration layer. The best model changes monthly — the workflow layer stays.
2. **Multi-provider wins.** No single provider stays on top. Developers want the best model for each task.
3. **The daemon as infrastructure.** Server/client architecture enables deployment anywhere.
4. **Open source outlasts funding.** Open source communities are resilient. Contributors become advocates.
## Current state (March 2026)
- Desktop (Electron), mobile (iOS/Android), web, CLI
- Providers: Claude Code (Agent SDK), Codex (app-server), OpenCode
- Daily releases
- Community contributions starting (packaging, bug fixes)
- Key UX: split panes, keybinding customization, workspace model
-359
View File
@@ -1,359 +0,0 @@
# Adding a New Provider to Paseo
This guide walks through adding a new agent provider end-to-end. There are two integration patterns, and this doc covers both.
## Two Integration Patterns
### ACP (Agent Client Protocol) -- recommended
Extend `ACPAgentClient`. The base class handles process spawning, stdio transport, session lifecycle, streaming, permissions, and model discovery. You provide configuration (command, modes, capabilities) and optionally override `isAvailable()` for auth checks.
Existing ACP providers: `claude-acp`, `copilot`.
### Direct
Implement the `AgentClient` and `AgentSession` interfaces yourself. This gives full control but requires you to handle process management, streaming, permissions, and session persistence from scratch.
Existing direct providers: `claude`, `codex`, `opencode`.
---
## ACP Provider Checklist
### 1. Create the provider class
Create `packages/server/src/server/agent/providers/{name}-agent.ts`.
Define capabilities, modes, and a thin subclass of `ACPAgentClient`:
```ts
import type { Logger } from "pino";
import type { AgentCapabilityFlags, AgentMode } from "../agent-sdk-types.js";
import type { ProviderRuntimeSettings } from "../provider-launch-config.js";
import { ACPAgentClient } from "./acp-agent.js";
const MY_PROVIDER_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
};
const MY_PROVIDER_MODES: AgentMode[] = [
{
id: "default",
label: "Default",
description: "Standard agent mode",
},
// Add more modes as needed
];
type MyProviderClientOptions = {
logger: Logger;
runtimeSettings?: ProviderRuntimeSettings;
};
export class MyProviderACPAgentClient extends ACPAgentClient {
constructor(options: MyProviderClientOptions) {
super({
provider: "my-provider", // Must match the ID used everywhere else
logger: options.logger,
runtimeSettings: options.runtimeSettings,
defaultCommand: ["my-agent-binary", "--acp"], // CLI command to spawn
defaultModes: MY_PROVIDER_MODES,
capabilities: MY_PROVIDER_CAPABILITIES,
});
}
// Override isAvailable() if the provider needs specific auth/env vars
override async isAvailable(): Promise<boolean> {
if (!(await super.isAvailable())) {
return false; // Binary not found
}
return Boolean(process.env["MY_PROVIDER_API_KEY"]);
}
}
```
The `super.isAvailable()` call checks that the binary from `defaultCommand` is on `$PATH`. Override only to add credential checks on top.
For reference, here is how Copilot does it -- no auth override needed because the CLI handles auth itself:
```ts
export class CopilotACPAgentClient extends ACPAgentClient {
constructor(options: CopilotACPAgentClientOptions) {
super({
provider: "copilot",
logger: options.logger,
runtimeSettings: options.runtimeSettings,
defaultCommand: ["copilot", "--acp"],
defaultModes: COPILOT_MODES,
capabilities: COPILOT_CAPABILITIES,
});
}
override async isAvailable(): Promise<boolean> {
return super.isAvailable();
}
}
```
### 2. Add to the provider manifest
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:
```ts
const MY_PROVIDER_MODES: AgentProviderModeDefinition[] = [
{
id: "default",
label: "Default",
description: "Standard agent mode",
icon: "ShieldCheck",
colorTier: "safe",
},
{
id: "autonomous",
label: "Autonomous",
description: "Runs without prompting",
icon: "ShieldOff",
colorTier: "dangerous",
},
];
```
Available `colorTier` values: `"safe"`, `"moderate"`, `"dangerous"`, `"planning"`.
Available `icon` values: `"ShieldCheck"`, `"ShieldAlert"`, `"ShieldOff"`.
Then add to the `AGENT_PROVIDER_DEFINITIONS` array:
```ts
export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
// ... existing providers ...
{
id: "my-provider",
label: "My Provider",
description: "Short description of the provider",
defaultModeId: "default",
modes: MY_PROVIDER_MODES,
// Optional: enable voice
voice: {
enabled: true,
defaultModeId: "default",
defaultModel: "some-model",
},
},
];
```
### 3. Add the factory to the provider registry
In `packages/server/src/server/agent/provider-registry.ts`, import your class and add a factory entry:
```ts
import { MyProviderACPAgentClient } from "./providers/my-provider-agent.js";
const PROVIDER_CLIENT_FACTORIES: Record<string, ProviderClientFactory> = {
// ... existing factories ...
"my-provider": (logger, runtimeSettings) =>
new MyProviderACPAgentClient({
logger,
runtimeSettings: runtimeSettings?.["my-provider"],
}),
};
```
### 4. Add a provider icon (app)
Create `packages/app/src/components/icons/my-provider-icon.tsx` following the pattern from existing icons (e.g., `claude-icon.tsx`):
```tsx
import Svg, { Path } from "react-native-svg";
interface MyProviderIconProps {
size?: number;
color?: string;
}
export function MyProviderIcon({ size = 16, color = "currentColor" }: MyProviderIconProps) {
return (
<Svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
<Path d="..." />
</Svg>
);
}
```
Then register it in `packages/app/src/components/provider-icons.ts`:
```ts
import { MyProviderIcon } from "@/components/icons/my-provider-icon";
const PROVIDER_ICONS: Record<string, typeof Bot> = {
claude: ClaudeIcon as unknown as typeof Bot,
codex: CodexIcon as unknown as typeof Bot,
"my-provider": MyProviderIcon as unknown as typeof Bot,
};
```
If no icon is registered, the app falls back to a generic `Bot` icon from lucide.
### 5. Add E2E test config
In `packages/server/src/server/daemon-e2e/agent-configs.ts`, add your provider:
```ts
export const agentConfigs = {
// ... existing configs ...
"my-provider": {
provider: "my-provider",
model: "default-model-id",
modes: {
full: "autonomous", // Mode with no permission prompts
ask: "default", // Mode that requires permission approval
},
},
} as const satisfies Record<string, AgentTestConfig>;
```
Add an availability check in `isProviderAvailable()`:
```ts
case "my-provider":
return (
isCommandAvailable("my-agent-binary") &&
Boolean(process.env.MY_PROVIDER_API_KEY)
);
```
Add to the `allProviders` array:
```ts
export const allProviders: AgentProvider[] = [
"claude",
"claude-acp",
"codex",
"copilot",
"opencode",
"my-provider",
];
```
### 6. Run typecheck
```bash
npm run typecheck
```
This is required after every change per project rules.
---
## Direct Provider Checklist
If your agent does not speak ACP, implement the interfaces from `agent-sdk-types.ts` directly.
### Interfaces to implement
**`AgentClient`** -- factory for sessions and model listing:
```ts
interface AgentClient {
readonly provider: AgentProvider;
readonly capabilities: AgentCapabilityFlags;
createSession(config: AgentSessionConfig, launchContext?: AgentLaunchContext): Promise<AgentSession>;
resumeSession(handle: AgentPersistenceHandle, overrides?: Partial<AgentSessionConfig>, launchContext?: AgentLaunchContext): Promise<AgentSession>;
listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]>;
isAvailable(): Promise<boolean>;
// Optional:
listPersistedAgents?(options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]>;
}
```
**`AgentSession`** -- a running agent conversation:
```ts
interface AgentSession {
readonly provider: AgentProvider;
readonly id: string | null;
readonly capabilities: AgentCapabilityFlags;
run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
subscribe(callback: (event: AgentStreamEvent) => void): () => void;
streamHistory(): AsyncGenerator<AgentStreamEvent>;
getRuntimeInfo(): Promise<AgentRuntimeInfo>;
getAvailableModes(): Promise<AgentMode[]>;
getCurrentMode(): Promise<string | null>;
setMode(modeId: string): Promise<void>;
getPendingPermissions(): AgentPermissionRequest[];
respondToPermission(requestId: string, response: AgentPermissionResponse): Promise<void>;
describePersistence(): AgentPersistenceHandle | null;
interrupt(): Promise<void>;
close(): Promise<void>;
// Optional:
listCommands?(): Promise<AgentSlashCommand[]>;
setModel?(modelId: string | null): Promise<void>;
setThinkingOption?(thinkingOptionId: string | null): Promise<void>;
}
```
### Steps
1. Create `packages/server/src/server/agent/providers/{name}-agent.ts` implementing both interfaces
2. Add to the provider manifest (same as ACP step 2 above)
3. Add factory to the registry (same as ACP step 3 above)
4. Add icon (same as ACP step 4 above)
5. Add E2E config (same as ACP step 5 above)
6. Run typecheck
---
## Testing
### Manual testing with the CLI
Start the daemon if not already running, then:
```bash
# Launch an agent with your provider
paseo run --provider my-provider
# Launch with a specific model and mode
paseo run --provider my-provider --model some-model --mode default
# List running agents
paseo ls -a -g
# Check if the provider reports models
paseo models --provider my-provider
```
### E2E test patterns
The E2E configs in `agent-configs.ts` expose two helpers:
- `getFullAccessConfig(provider)` -- returns config for a session with no permission prompts
- `getAskModeConfig(provider)` -- returns config for a session that triggers permission requests
Tests use `isProviderAvailable(provider)` to skip when the binary or credentials are missing, so CI will not fail for providers that are not installed.
---
## Gotchas
**Mode IDs can be URIs.** ACP providers like Copilot use full URIs as mode IDs (e.g., `"https://agentclientprotocol.com/protocol/session-modes#agent"`). Never assume mode IDs are simple strings. The manifest `defaultModeId` must match exactly.
**Models and modes are discovered dynamically.** ACP providers report available models and modes at runtime via the protocol. The static definitions in `provider-manifest.ts` are used for UI scaffolding (icons, color tiers) but the runtime values from the agent process are the source of truth.
**`AgentProvider` is always `string`.** The type alias is `type AgentProvider = string`. Provider IDs are validated against the manifest at runtime, not at the type level.
**Auth patterns vary.** Some providers need API keys in env vars (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`), some use OAuth tokens (`CLAUDE_CODE_OAUTH_TOKEN`), some use auth files (`~/.codex/auth.json`), and some handle auth entirely in their CLI binary (Copilot). Your `isAvailable()` method should check whatever is needed.
**The manifest mode list and the agent class mode list are separate.** The manifest in `provider-manifest.ts` includes UI metadata (`icon`, `colorTier`). The agent class defines modes without UI metadata (just `id`, `label`, `description`). Keep them in sync.
**`defaultCommand` is a tuple.** The first element is the binary name, the rest are default arguments. The base class uses this to find the executable and spawn the process.
**Runtime settings can override the command.** Users can configure custom binary paths or environment variables per provider via `ProviderRuntimeSettings`. Your factory in the registry should pass `runtimeSettings?.["your-provider"]` through to the constructor.
+9 -138
View File
@@ -2,21 +2,8 @@
All workspaces share one version and release together.
## Two paths
There are two supported ways to ship from `main`:
1. **Direct stable release**: you are ready to ship the current `main` commit to everyone immediately.
2. **Release candidate flow**: you want public test builds first, but you are not ready for the website, npm, or production mobile release flows to move yet.
## Standard release (patch)
Before running any stable patch release command:
- Make sure the intended release commit is already committed to `main` and the working tree is clean.
- Make sure local `npm run typecheck` passes on that commit.
- Do not use `npm run release:patch` as a substitute for checking whether the current commit is actually ready.
```bash
npm run release:patch
```
@@ -25,153 +12,37 @@ This bumps the version across all workspaces, runs checks, publishes to npm, and
If asked to "release paseo" without specifying major/minor, treat it as a patch release.
Use the direct stable path when the current `main` changes are ready to become the public release immediately.
## Manual step-by-step
```bash
npm run typecheck # Verify the exact commit you intend to release
npm run release:check # Typecheck, build, dry-run pack
npm run version:all:patch # Bump version, create commit + tag
npm run release:check # Validate release
npm run release:publish # Publish to npm
npm run release:push # Push HEAD + tag (triggers CI workflows)
```
## Release candidate flow
## Draft release flow
```bash
npm run release:rc:patch # Bump to X.Y.Z-rc.1, push commit + tag
# ... test desktop and APK prerelease assets from GitHub Releases ...
npm run release:rc:next # Optional: cut X.Y.Z-rc.2, rc.3, ...
npm run release:promote # Promote X.Y.Z-rc.N to stable X.Y.Z
npm run draft-release:patch # Bump, push tag, create draft GitHub Release
npm run release:finalize # Publish npm, promote draft to published
```
- RC tags are published GitHub prereleases like `v0.1.41-rc.1`
- RCs publish desktop assets and APKs for testing, but they do not publish npm packages and do not trigger the production web/mobile release flows
- `release:promote` creates a fresh stable tag like `v0.1.41`; the final release never reuses the RC tag
- Desktop assets now come from the Electron package at `packages/desktop`
- **Do NOT create a changelog entry for RCs.** The changelog remains stable-only. RC release notes are generated automatically so the website stays pinned to the latest published stable release.
Use the RC path when you need to:
- test a build manually in a Linux or Windows VM
- send a build to a user who is hitting a specific problem
- iterate on `rc.1`, `rc.2`, `rc.3`, and so on before deciding to ship broadly
## Website behavior
- The website download page points to GitHub's latest published **stable** release.
- Published RC prereleases are public on GitHub Releases, but they do **not** become the website download target.
- The website only moves when you publish the final stable release tag like `v0.1.41`.
## Fixing a failed release build
**NEVER bump the version to fix a build problem.** New versions are reserved for meaningful product changes (features, fixes, improvements). Build/CI failures are fixed on the current version.
**Do not rely on `workflow_dispatch` for tagged code fixes.** The `workflow_dispatch` trigger runs the workflow file from the default branch but checks out the code at the tag ref (`ref: ${{ inputs.tag }}`). That means fixes committed to `main` won't change the tagged source tree being built. `workflow_dispatch` only helps when the fix lives in the workflow file itself.
To retry a failed workflow, **always push a retry tag** on the commit you want to build. Reusing the same tag name is expected: move it with `git tag -f ...` and push it with `--force` so the workflow rebuilds the commit you actually want.
Prefer a tag push over `workflow_dispatch` whenever you are rebuilding release code or release assets.
The retry tag patterns below still work and remain the supported way to rebuild specific release targets:
```bash
# Desktop (all platforms)
git tag -f desktop-v0.1.28 HEAD && git push origin desktop-v0.1.28 --force
# Desktop (single platform)
git tag -f desktop-macos-v0.1.28 HEAD && git push origin desktop-macos-v0.1.28 --force
git tag -f desktop-linux-v0.1.28 HEAD && git push origin desktop-linux-v0.1.28 --force
git tag -f desktop-windows-v0.1.28 HEAD && git push origin desktop-windows-v0.1.28 --force
# Android APK
git tag -f android-v0.1.28 HEAD && git push origin android-v0.1.28 --force
# RC
git tag -f v0.1.29-rc.2 HEAD && git push origin v0.1.29-rc.2 --force
```
This ensures the checkout ref matches the actual code on `main` with the fix included.
- `vX.Y.Z` or `vX.Y.Z-rc.N` rebuilds the full tagged release
- `desktop-vX.Y.Z` rebuilds desktop for all desktop platforms only
- `desktop-macos-vX.Y.Z`, `desktop-linux-vX.Y.Z`, and `desktop-windows-vX.Y.Z` rebuild only that desktop platform
- `android-vX.Y.Z` rebuilds the Android APK release only
- `draft-release:patch` creates the GitHub Release as a draft so desktop assets, APK uploads, and synced notes attach to it
- `release:finalize` publishes npm and promotes the same draft release
- Use the same semver tag for both; don't cut a second tag
## Notes
- `version:all:*` bumps root + syncs workspace versions and `@getpaseo/*` dependency versions
- `release:prepare` refreshes workspace `node_modules` links to prevent stale types
- `npm run dev:desktop` and `npm run build:desktop` target the Electron desktop package in `packages/desktop`
- If `release:publish` partially fails, re-run it — npm skips already-published versions
- The website uses GitHub's latest published release API for download links, so published RC prereleases do not replace the stable download target.
## Changelog format
Stable release notes depend on the changelog heading format. The heading **must** be strictly followed:
```
## X.Y.Z - YYYY-MM-DD
```
No prefix (`v`), no extra text. The parser matches the first `## X.Y.Z` line to extract the version. A malformed heading will break download links on the homepage.
## Changelog policy
- `CHANGELOG.md` is for **final stable releases only**.
- Do not add or edit changelog entries while iterating on RCs.
- Write the proper changelog entry when you are cutting the final stable release that comes after the RC cycle.
- Between stable releases, keep changelog work out of the repo until the final release is ready.
## Changelog ownership
- **Only Claude should write changelog entries.**
- If you are Codex and a stable release needs a changelog entry, launch a Claude agent with Paseo to draft it, then review and commit the result.
## Changelog voice
The changelog is shown on the Paseo homepage. Write it for **end users**, not developers.
- **Frame everything from the user's perspective.** Describe what changed in the app, not what changed in the code. Users care that "workspaces load instantly" — not that a component no longer remounts.
- **Never mention component names, internal modules, or implementation details.** No `WorkingIndicator`, no `accumulatedUsage`, no `reconcileAndEmitWorkspaceUpdates`.
- **Collapse internal iterations.** If a feature was added and then fixed within the same release, just list the feature as working. Users never saw the broken version.
- **Only list changes relative to the previous stable release.** The diff is `v(previous)..HEAD`. If something was introduced and fixed between those two tags, it never shipped — don't mention the fix.
- **Cut low-signal entries.** "Toolbar buttons have consistent sizing" is too granular. Combine small polish items or drop them.
## Pre-release sanity check
Before cutting any release (RC or stable), run a Codex review of the diff as a last line of defence against shipping bugs.
Load the `paseo` skill and launch a **Codex 5.4** agent with a prompt like:
> Review the diff between the latest release tag and HEAD. Focus on:
>
> 1. **Breaking changes** — especially in the WebSocket protocol, agent lifecycle, and any server↔client contract.
> 2. **Backward compatibility** — the important direction is old app clients talking to newly updated daemons. Users update desktop and daemon first, then keep running the old app for a while. Flag anything that breaks old clients against new daemons or requires both sides to update in lockstep.
> 3. **Regressions** — anything that looks like it could break existing functionality.
>
> Diff: `git diff <latest-release-tag>..HEAD`
The agent's job is a deep sanity check, not a full code review. If it flags anything, investigate before proceeding.
## Changelog scope
The changelog always covers **stable-to-HEAD**:
- **RC release**: the diff and release notes cover `latest stable tag → HEAD`. RC release notes are auto-generated and not added to `CHANGELOG.md`.
- **Stable release**: the diff and changelog entry cover `latest stable tag → HEAD`. Any intermediate RCs are skipped — the changelog captures the full delta from the previous stable release, not just what changed since the last RC.
In other words, RCs are checkpoints along the way; the changelog only records the final jump from one stable version to the next.
- Website Mac download CTA URL derives from `packages/website/package.json` version at build time
## Completion checklist
- [ ] Run the pre-release sanity check (see above) and address any findings
- [ ] Ensure the intended release commit is already committed and the git worktree is clean before running any `release:*` patch/promote command
- [ ] Ensure local `npm run typecheck` passes on that exact commit before running any `release:*` patch/promote command
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors)
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
- [ ] `npm run release:patch` or `npm run release:promote` completes successfully
- [ ] `npm run release:patch` completes successfully
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
- [ ] GitHub `Android APK Release` workflow for the same tag is green
- [ ] EAS `release-mobile.yml` workflow for the same tag is green
-68
View File
@@ -1,68 +0,0 @@
# Plan Approval Normalization
## Goal
Normalize plan approval across providers so the UI renders one consistent plan approval card and action row, while each provider keeps its own execution quirks behind the session permission interface.
## Compatibility Constraints
- Older clients must remain compatible with newer daemons.
- All new wire fields must be optional.
- Existing plan permissions without action metadata must still render and work.
- Existing question permissions must keep their current behavior.
## Design
### Shared abstraction
Add optional permission action definitions to the shared permission request/response types.
- Permission requests may include `actions`.
- Permission responses may include `selectedActionId`.
- `kind: "plan"` remains the normalized concept for plan approval.
- The UI renders actions from the permission request instead of hardcoding provider-specific buttons.
### Claude
Keep Claude's plan permission flow, but enrich it with explicit action definitions.
- Always expose `Reject`.
- Always expose `Implement`.
- If the agent entered plan mode from a more permissive mode like `bypassPermissions`, also expose `Implement with <previous mode>`.
- Resolve the selected action entirely inside `respondToPermission()`.
### Codex
Synthesize a normalized `kind: "plan"` permission after a Codex plan-mode turn completes with a plan result.
- Emit a plan permission with `Reject` and `Implement` actions.
- On `Implement`, disable `plan_mode`, disable `fast_mode`, and automatically start a follow-up implementation turn.
- On `Reject`, resolve without starting a follow-up turn.
- Keep the implementation prompt and state transitions inside the Codex provider.
### Manager and state sync
After permission resolution, refresh provider-derived state so the UI sees internal mode/feature changes without knowing provider quirks.
- Refresh current mode
- Refresh pending permissions
- Refresh runtime info
- Refresh features
- Persist refreshed state
### UI
Render plan permissions through the existing plan card, but generate buttons from normalized permission actions.
- If `actions` are absent, fall back to legacy buttons.
- Plan cards should use `Implement` as the default primary label.
- Do not add provider-specific rendering branches.
## Verification
1. Shared schema/type tests for optional `actions` and `selectedActionId`
2. App tests for generic plan-action rendering
3. Claude tests for third action when resuming from a more permissive mode
4. Codex tests for synthetic plan approval and automatic implementation follow-up
5. Manager tests for post-permission state refresh
6. `npm run typecheck`
Generated
-27
View File
@@ -1,27 +0,0 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1772963539,
"narHash": "sha256-9jVDGZnvCckTGdYT53d/EfznygLskyLQXYwJLKMPsZs=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "9dcb002ca1690658be4a04645215baea8b95f31d",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
-59
View File
@@ -1,59 +0,0 @@
{
description = "Paseo - self-hosted daemon for AI coding agents";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
};
outputs =
{
self,
nixpkgs,
}:
let
supportedSystems = [
"x86_64-linux"
"aarch64-linux"
"x86_64-darwin"
"aarch64-darwin"
];
forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
pkgsFor = system: import nixpkgs { inherit system; };
in
{
packages = forAllSystems (
system:
let
pkgs = pkgsFor system;
paseo = pkgs.callPackage ./nix/package.nix { };
in
{
default = paseo;
paseo = paseo;
}
);
nixosModules.default = self.nixosModules.paseo;
nixosModules.paseo =
{ pkgs, lib, ... }:
{
imports = [ ./nix/module.nix ];
services.paseo.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.default;
};
devShells = forAllSystems (
system:
let
pkgs = pkgsFor system;
in
{
default = pkgs.mkShell {
packages = [
pkgs.nodejs_22
pkgs.python3
];
};
}
);
};
}
-172
View File
@@ -1,172 +0,0 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.paseo;
in
{
options.services.paseo = {
enable = lib.mkEnableOption "Paseo, a self-hosted daemon for AI coding agents";
package = lib.mkPackageOption pkgs "paseo" { };
user = lib.mkOption {
type = lib.types.str;
default = "paseo";
description = "User account under which Paseo runs.";
};
group = lib.mkOption {
type = lib.types.str;
default = "paseo";
description = "Group under which Paseo runs.";
};
dataDir = lib.mkOption {
type = lib.types.str;
default =
if cfg.user == "paseo"
then "/var/lib/paseo"
else "/home/${cfg.user}/.paseo";
defaultText = lib.literalExpression ''
if cfg.user == "paseo"
then "/var/lib/paseo"
else "/home/''${cfg.user}/.paseo"
'';
description = "Directory for Paseo state (PASEO_HOME). Stores agent data, config, and logs.";
};
port = lib.mkOption {
type = lib.types.port;
default = 6767;
description = "Port for the Paseo daemon to listen on.";
};
listenAddress = lib.mkOption {
type = lib.types.str;
default = "127.0.0.1";
description = "Address for the Paseo daemon to bind to.";
};
openFirewall = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Whether to open the firewall for the Paseo daemon port.";
};
allowedHosts = lib.mkOption {
type = lib.types.either (lib.types.enum [ true ]) (lib.types.listOf lib.types.str);
default = [ ];
example = [ ".example.com" "myhost.local" ];
description = ''
Hosts allowed to connect to the Paseo daemon (DNS rebinding protection).
Localhost and IP addresses are always allowed by default.
Use a leading dot to match a domain and all its subdomains
(e.g. `".example.com"` matches `example.com` and `foo.example.com`).
Set to `true` to allow any host (not recommended).
'';
};
relay = {
enable = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Whether to enable the relay connection for remote access via app.paseo.sh.";
};
};
inheritUserEnvironment = lib.mkOption {
type = lib.types.bool;
default = cfg.user != "paseo";
defaultText = lib.literalExpression ''cfg.user != "paseo"'';
description = ''
Whether to include the user's profile PATH in the service environment.
When Paseo runs as a real user (not the default system user), AI agents
need access to the user's tools (git, ssh, etc.). This adds the user's
NixOS profile and system paths so agents can use them without manually
setting PATH.
Enabled by default when `user` is set to a non-default value.
'';
};
environment = lib.mkOption {
type = lib.types.attrsOf lib.types.str;
default = { };
example = lib.literalExpression ''
{
PASEO_RELAY_ENDPOINT = "relay.paseo.sh:443";
}
'';
description = "Extra environment variables for the Paseo daemon.";
};
};
config = lib.mkIf cfg.enable {
users.users.${cfg.user} = lib.mkIf (cfg.user == "paseo") {
isSystemUser = true;
group = cfg.group;
home = cfg.dataDir;
};
users.groups.${cfg.group} = lib.mkIf (cfg.group == "paseo") { };
systemd.tmpfiles.rules = [
"d ${cfg.dataDir} 0700 ${cfg.user} ${cfg.group} - -"
];
systemd.services.paseo = {
description = "Paseo - self-hosted daemon for AI coding agents";
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
environment = {
NODE_ENV = "production";
PASEO_HOME = cfg.dataDir;
PASEO_LISTEN = "${cfg.listenAddress}:${toString cfg.port}";
} // lib.optionalAttrs cfg.inheritUserEnvironment {
# mkForce overrides the default PATH from NixOS's systemd module (which
# only includes store paths for coreutils/grep/sed/systemd). Our PATH
# includes /run/current-system/sw/bin which is a superset of those.
PATH = lib.mkForce (lib.concatStringsSep ":" [
"/etc/profiles/per-user/${cfg.user}/bin"
"/run/current-system/sw/bin"
"/run/wrappers/bin"
"/nix/var/nix/profiles/default/bin"
]);
} // lib.optionalAttrs (cfg.allowedHosts == true) {
PASEO_ALLOWED_HOSTS = "true";
} // lib.optionalAttrs (lib.isList cfg.allowedHosts && cfg.allowedHosts != [ ]) {
PASEO_ALLOWED_HOSTS = lib.concatStringsSep "," cfg.allowedHosts;
} // cfg.environment;
serviceConfig = {
Type = "simple";
User = cfg.user;
Group = cfg.group;
ExecStart =
"${cfg.package}/bin/paseo-server"
+ lib.optionalString (!cfg.relay.enable) " --no-relay";
Restart = "on-failure";
RestartSec = 5;
# Graceful shutdown (server handles SIGTERM with a 10s timeout)
KillSignal = "SIGTERM";
TimeoutStopSec = 15;
};
};
environment.systemPackages = [ cfg.package ];
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ cfg.port ];
};
}
-144
View File
@@ -1,144 +0,0 @@
{
lib,
stdenv,
buildNpmPackage,
nodejs_22,
python3,
makeWrapper,
# node-pty needs libuv headers on Linux
libuv,
}:
buildNpmPackage rec {
pname = "paseo";
version = (builtins.fromJSON (builtins.readFile ../package.json)).version;
src = lib.cleanSourceWith {
src = ./..;
filter = path: type:
let
baseName = builtins.baseNameOf path;
relPath = lib.removePrefix (toString ./..) path;
in
# Exclude non-daemon workspace contents (keep package.json for workspace resolution)
!(lib.hasPrefix "/packages/app/src" relPath)
&& !(lib.hasPrefix "/packages/app/assets" relPath)
&& !(lib.hasPrefix "/packages/app/android" relPath)
&& !(lib.hasPrefix "/packages/app/ios" relPath)
&& !(lib.hasPrefix "/packages/website/src" relPath)
&& !(lib.hasPrefix "/packages/website/public" relPath)
&& !(lib.hasPrefix "/packages/desktop/src" relPath)
&& !(lib.hasPrefix "/packages/desktop/src-tauri" relPath)
# Exclude test fixtures and debug files
&& !(lib.hasSuffix ".test.ts" baseName)
&& !(lib.hasSuffix ".e2e.test.ts" baseName)
&& baseName != "node_modules"
&& baseName != ".git"
&& baseName != ".paseo"
&& baseName != ".DS_Store";
};
nodejs = nodejs_22;
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
npmDepsHash = "sha256-AyhJwa2ISs3wxorZIWblYasH35K3YpesPl4+1f5QTJg=";
# Prevent onnxruntime-node's install script from running during automatic
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
# We manually rebuild only node-pty in buildPhase.
npmRebuildFlags = [ "--ignore-scripts" ];
nativeBuildInputs = [
python3 # for node-gyp (node-pty compilation)
makeWrapper
];
buildInputs = lib.optionals stdenv.hostPlatform.isLinux [
libuv
];
# Don't use the default npm build hook — we need a custom build sequence
dontNpmBuild = true;
buildPhase = ''
runHook preBuild
# Rebuild only node-pty (native addon for terminal emulation).
# Speech-related native modules (sherpa-onnx, onnxruntime-node) are
# intentionally left unbuilt — they're lazily loaded and gracefully
# degrade when unavailable.
npm rebuild node-pty
# Build all daemon packages in dependency order (defined in package.json)
npm run build:daemon
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p $out/lib/paseo
# Copy root package metadata
cp package.json $out/lib/paseo/
# Copy node_modules (preserving workspace symlinks)
cp -a node_modules $out/lib/paseo/
# Auto-detect which @getpaseo/* packages were built by build:daemon
# (they'll have a dist/ directory). Copy those and remove the rest.
for link in $out/lib/paseo/node_modules/@getpaseo/*; do
name=$(basename "$link")
if [ -d "packages/$name/dist" ]; then
mkdir -p "$out/lib/paseo/packages/$name"
cp "packages/$name/package.json" "$out/lib/paseo/packages/$name/"
cp -a "packages/$name/dist" "$out/lib/paseo/packages/$name/"
if [ -d "packages/$name/node_modules" ]; then
cp -a "packages/$name/node_modules" "$out/lib/paseo/packages/$name/"
fi
else
rm -f "$link"
fi
done
# Copy CLI bin entry
mkdir -p $out/lib/paseo/packages/cli/bin
cp packages/cli/bin/paseo $out/lib/paseo/packages/cli/bin/
# Copy extra server files referenced at runtime
for f in agent-prompt.md .env.example; do
if [ -f packages/server/$f ]; then
cp packages/server/$f $out/lib/paseo/packages/server/
fi
done
# Copy server scripts (including supervisor-entrypoint) needed by CLI
if [ -d packages/server/dist/scripts ]; then
mkdir -p $out/lib/paseo/packages/server/dist/scripts
cp -a packages/server/dist/scripts/* $out/lib/paseo/packages/server/dist/scripts/
fi
# Create wrapper for the server entry point (for systemd / direct use)
mkdir -p $out/bin
makeWrapper ${nodejs}/bin/node $out/bin/paseo-server \
--add-flags "$out/lib/paseo/packages/server/dist/server/server/index.js" \
--set NODE_ENV production
# Create wrapper for the CLI
makeWrapper ${nodejs}/bin/node $out/bin/paseo \
--add-flags "$out/lib/paseo/packages/cli/dist/index.js" \
--set NODE_PATH "$out/lib/paseo/node_modules"
runHook postInstall
'';
meta = {
description = "Self-hosted daemon for Claude Code, Codex, and OpenCode";
homepage = "https://github.com/getpaseo/paseo";
license = lib.licenses.agpl3Plus;
mainProgram = "paseo";
platforms = lib.platforms.linux ++ lib.platforms.darwin;
};
}
+443 -4224
View File
File diff suppressed because it is too large Load Diff
+22 -40
View File
@@ -1,10 +1,9 @@
{
"name": "paseo",
"version": "0.1.55",
"version": "0.1.29",
"private": true,
"workspaces": [
"packages/expo-two-way-audio",
"packages/highlight",
"packages/server",
"packages/app",
"packages/relay",
@@ -14,19 +13,17 @@
],
"scripts": {
"dev": "./scripts/dev.sh",
"dev:win": "powershell ./scripts/dev.ps1",
"dev:server": "npm run dev --workspace=@getpaseo/server",
"dev:server": "NODE_ENV=development tsx packages/server/scripts/daemon-runner.ts --dev",
"dev:app": "npm run start --workspace=@getpaseo/app",
"dev:website": "npm run dev --workspace=@getpaseo/website",
"postinstall": "node scripts/postinstall-patches.mjs",
"build": "npm run build --workspaces --if-present",
"build:highlight": "npm run build --workspace=@getpaseo/highlight",
"build:daemon": "npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli",
"build:daemon": "npm run build --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli",
"typecheck": "npm run typecheck --workspaces --if-present",
"typecheck:daemon": "npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli",
"test": "npm run test --workspaces --if-present",
"format": "biome format --write .",
"format:check": "biome format .",
"format": "prettier --write .",
"format:check": "prettier --check .",
"start": "npm run start --workspace=@getpaseo/server",
"android": "npm run android --workspace=@getpaseo/app",
"android:development": "npm run android:development --workspace=@getpaseo/app",
@@ -37,36 +34,30 @@
"ios": "npm run ios --workspace=@getpaseo/app",
"web": "npm run web --workspace=@getpaseo/app",
"dev:desktop": "npm run dev --workspace=@getpaseo/desktop",
"dev:win:desktop": "npm run dev:win --workspace=@getpaseo/desktop",
"build:desktop": "npm run version:sync-internal && npm run build:web --workspace=@getpaseo/app && npm run build --workspace=@getpaseo/desktop",
"cli": "npx tsx packages/cli/src/index.js",
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",
"version:sync-internal": "node scripts/sync-workspace-versions.mjs",
"release:prepare": "npm install --workspaces --include-workspace-root",
"version:all:patch": "node scripts/set-release-version.mjs --mode patch",
"version:all:minor": "node scripts/set-release-version.mjs --mode minor",
"version:all:major": "node scripts/set-release-version.mjs --mode major",
"version:all:rc:patch": "node scripts/set-release-version.mjs --mode rc-patch",
"version:all:rc:minor": "node scripts/set-release-version.mjs --mode rc-minor",
"version:all:rc:major": "node scripts/set-release-version.mjs --mode rc-major",
"version:all:rc:next": "node scripts/set-release-version.mjs --mode rc-next",
"version:all:promote": "node scripts/set-release-version.mjs --mode promote",
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public && npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
"release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
"version:all:patch": "npm version patch --include-workspace-root --message \"chore(release): cut %s\"",
"version:all:minor": "npm version minor --include-workspace-root --message \"chore(release): cut %s\"",
"version:all:major": "npm version major --include-workspace-root --message \"chore(release): cut %s\"",
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
"release:publish": "npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
"release:push": "node scripts/push-current-release-tag.mjs",
"release:rc:patch": "npm run release:check && npm run version:all:rc:patch && npm run release:push",
"release:rc:minor": "npm run release:check && npm run version:all:rc:minor && npm run release:push",
"release:rc:major": "npm run release:check && npm run version:all:rc:major && npm run release:push",
"release:rc:next": "npm run release:check && npm run version:all:rc:next && npm run release:push",
"release:promote": "npm run release:check && npm run version:all:promote && npm run release:publish && npm run release:push",
"release:patch": "npm run release:check && npm run version:all:patch && npm run release:publish && npm run release:push",
"release:minor": "npm run release:check && npm run version:all:minor && npm run release:publish && npm run release:push",
"release:major": "npm run release:check && npm run version:all:major && npm run release:publish && npm run release:push"
"draft-release:push": "node scripts/push-current-release-tag.mjs --draft-release",
"draft-release:patch": "npm run version:all:patch && npm run release:check && npm run draft-release:push",
"draft-release:minor": "npm run version:all:minor && npm run release:check && npm run draft-release:push",
"draft-release:major": "npm run version:all:major && npm run release:check && npm run draft-release:push",
"release:finalize": "node scripts/finalize-current-release.mjs",
"release:patch": "npm run version:all:patch && npm run release:check && npm run release:publish && npm run release:push",
"release:minor": "npm run version:all:minor && npm run release:check && npm run release:publish && npm run release:push",
"release:major": "npm run version:all:major && npm run release:check && npm run release:publish && npm run release:push"
},
"devDependencies": {
"@biomejs/biome": "^2.4.8",
"concurrently": "^9.2.1",
"prettier": "^3.5.3",
"get-port-cli": "^3.0.0",
"knip": "^5.82.1",
"patch-package": "^8.0.1",
@@ -75,7 +66,6 @@
"typescript": "^5.9.3"
},
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
"homepage": "https://paseo.sh",
"keywords": [
"openai",
"realtime",
@@ -84,14 +74,7 @@
"development",
"mcp"
],
"author": {
"name": "Mohamed Boudra",
"email": "hello@moboudra.com"
},
"repository": {
"type": "git",
"url": "https://github.com/getpaseo/paseo.git"
},
"author": "moboudra",
"license": "AGPL-3.0-or-later",
"overrides": {
"lightningcss": "1.30.1",
@@ -99,7 +82,6 @@
"react-dom": "19.1.4"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@modelcontextprotocol/sdk": "^1.27.1"
"@anthropic-ai/claude-agent-sdk": "^0.2.11"
}
}
@@ -4,7 +4,6 @@ on:
push:
tags:
- "v*"
- "!v*-rc.*"
workflow_dispatch: {}
jobs:
+5 -2
View File
@@ -65,7 +65,8 @@ export default {
ios: {
supportsTablet: true,
infoPlist: {
NSMicrophoneUsageDescription: "This app needs access to the microphone for voice commands.",
NSMicrophoneUsageDescription:
"This app needs access to the microphone for voice commands.",
ITSAppUsesNonExemptEncryption: false,
},
bundleIdentifier: variant.packageId,
@@ -91,7 +92,9 @@ export default {
"android.permission.CAMERA",
],
package: variant.packageId,
...(variant.googleServicesFile ? { googleServicesFile: variant.googleServicesFile } : {}),
...(variant.googleServicesFile
? { googleServicesFile: variant.googleServicesFile }
: {}),
},
web: {
output: "single",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

-108
View File
@@ -1,108 +0,0 @@
import { randomUUID } from "node:crypto";
import { test } from "./fixtures";
import { createTempGitRepo } from "./helpers/workspace";
import {
archiveAgentFromDaemon,
archiveAgentFromSessions,
connectArchiveTabDaemonClient,
createIdleAgent,
expectSessionRowVisible,
expectWorkspaceArchiveOutcome,
openSessions,
openWorkspaceWithAgents,
primeAdditionalPage,
resetSeededPageState,
reloadWorkspace,
} from "./helpers/archive-tab";
test.describe("Archive tab reconciliation", () => {
let client: Awaited<ReturnType<typeof connectArchiveTabDaemonClient>>;
let tempRepo: { path: string; cleanup: () => Promise<void> };
test.describe.configure({ timeout: 120_000 });
test.beforeAll(async () => {
tempRepo = await createTempGitRepo("archive-tab-");
client = await connectArchiveTabDaemonClient();
});
test.afterAll(async () => {
await client?.close().catch(() => undefined);
await tempRepo?.cleanup();
});
test("non-UI archive prunes the archived tab across open pages and reload", async ({ page }) => {
const archived = await createIdleAgent(client, {
cwd: tempRepo.path,
title: `cli-archive-${randomUUID().slice(0, 8)}`,
});
const surviving = await createIdleAgent(client, {
cwd: tempRepo.path,
title: `cli-control-${randomUUID().slice(0, 8)}`,
});
const passivePage = await page.context().newPage();
try {
await primeAdditionalPage(passivePage);
await resetSeededPageState(page);
await resetSeededPageState(passivePage);
await openSessions(page);
await expectSessionRowVisible(page, archived.title);
await expectSessionRowVisible(page, surviving.title);
await openSessions(passivePage);
await expectSessionRowVisible(passivePage, archived.title);
await expectSessionRowVisible(passivePage, surviving.title);
await openWorkspaceWithAgents(page, [archived, surviving]);
await openWorkspaceWithAgents(passivePage, [archived, surviving]);
await archiveAgentFromDaemon(client, archived.id);
await expectWorkspaceArchiveOutcome(page, {
archivedAgentId: archived.id,
survivingAgentId: surviving.id,
});
await expectWorkspaceArchiveOutcome(passivePage, {
archivedAgentId: archived.id,
survivingAgentId: surviving.id,
});
await reloadWorkspace(passivePage, tempRepo.path);
await expectWorkspaceArchiveOutcome(passivePage, {
archivedAgentId: archived.id,
survivingAgentId: surviving.id,
});
} finally {
await passivePage.close();
}
});
test("Sessions archive prunes the archived tab across open pages", async ({ page }) => {
const archived = await createIdleAgent(client, {
cwd: tempRepo.path,
title: `ui-archive-${randomUUID().slice(0, 8)}`,
});
const surviving = await createIdleAgent(client, {
cwd: tempRepo.path,
title: `ui-control-${randomUUID().slice(0, 8)}`,
});
const passivePage = await page.context().newPage();
try {
await primeAdditionalPage(passivePage);
await resetSeededPageState(page);
await resetSeededPageState(passivePage);
await openWorkspaceWithAgents(page, [archived, surviving]);
await openWorkspaceWithAgents(passivePage, [archived, surviving]);
await openSessions(page);
await archiveAgentFromSessions(page, { agentId: archived.id, title: archived.title });
await reloadWorkspace(page, tempRepo.path);
await expectWorkspaceArchiveOutcome(page, {
archivedAgentId: archived.id,
survivingAgentId: surviving.id,
});
await expectWorkspaceArchiveOutcome(passivePage, {
archivedAgentId: archived.id,
survivingAgentId: surviving.id,
});
} finally {
await passivePage.close();
}
});
});
+26 -23
View File
@@ -1,12 +1,15 @@
import { test as base, expect, type Page } from "@playwright/test";
import { buildCreateAgentPreferences, buildSeededHost } from "./helpers/daemon-registry";
import { test as base, expect, type Page } from '@playwright/test';
import {
buildCreateAgentPreferences,
buildSeededHost,
} from './helpers/daemon-registry';
// Extend base test to provide dynamic baseURL from global-setup
const test = base.extend({
baseURL: async ({}, use) => {
const metroPort = process.env.E2E_METRO_PORT;
if (!metroPort) {
throw new Error("E2E_METRO_PORT not set - globalSetup must run first");
throw new Error('E2E_METRO_PORT not set - globalSetup must run first');
}
await use(`http://localhost:${metroPort}`);
},
@@ -19,19 +22,19 @@ test.beforeEach(async ({ page }) => {
const metroPort = process.env.E2E_METRO_PORT;
if (!daemonPort) {
throw new Error(
"E2E_DAEMON_PORT is not set. Refusing to run e2e against the default daemon (e.g. localhost:6767). " +
"Ensure Playwright `globalSetup` starts the e2e daemon and exports E2E_DAEMON_PORT.",
'E2E_DAEMON_PORT is not set. Refusing to run e2e against the default daemon (e.g. localhost:6767). ' +
'Ensure Playwright `globalSetup` starts the e2e daemon and exports E2E_DAEMON_PORT.'
);
}
if (daemonPort === "6767") {
if (daemonPort === '6767') {
throw new Error(
"E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon. " +
"Fix Playwright globalSetup to start an isolated test daemon and export its port.",
'E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon. ' +
'Fix Playwright globalSetup to start an isolated test daemon and export its port.'
);
}
if (!metroPort) {
throw new Error(
"E2E_METRO_PORT is not set. Ensure Playwright `globalSetup` starts Metro and exports E2E_METRO_PORT.",
'E2E_METRO_PORT is not set. Ensure Playwright `globalSetup` starts Metro and exports E2E_METRO_PORT.'
);
}
@@ -39,17 +42,17 @@ test.beforeEach(async ({ page }) => {
// This blocks both HTTP and WS attempts to :6767 (before any navigation).
await page.route(/:(6767)\b/, (route) => route.abort());
await page.routeWebSocket(/:(6767)\b/, async (ws) => {
await ws.close({ code: 1008, reason: "Blocked connection to localhost:6767 during e2e." });
await ws.close({ code: 1008, reason: 'Blocked connection to localhost:6767 during e2e.' });
});
const entries: string[] = [];
consoleEntries.set(page, entries);
page.on("console", (message) => {
page.on('console', (message) => {
entries.push(`[console:${message.type()}] ${message.text()}`);
});
page.on("pageerror", (error) => {
page.on('pageerror', (error) => {
entries.push(`[pageerror] ${error.message}`);
});
@@ -57,7 +60,7 @@ test.beforeEach(async ({ page }) => {
const seedNonce = Math.random().toString(36).slice(2);
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set - expected from Playwright globalSetup.");
throw new Error('E2E_SERVER_ID is not set - expected from Playwright globalSetup.');
}
const testDaemon = buildSeededHost({
serverId,
@@ -71,7 +74,7 @@ test.beforeEach(async ({ page }) => {
// `addInitScript` runs on every navigation (including reloads). Some tests intentionally
// override storage and reload; they can opt out of seeding for the *next* navigation by
// setting this flag before the reload.
const disableOnceKey = "@paseo:e2e-disable-default-seed-once";
const disableOnceKey = '@paseo:e2e-disable-default-seed-once';
const disableValue = localStorage.getItem(disableOnceKey);
if (disableValue) {
localStorage.removeItem(disableOnceKey);
@@ -80,15 +83,15 @@ test.beforeEach(async ({ page }) => {
}
}
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:e2e-seed-nonce", seedNonce);
localStorage.setItem('@paseo:e2e', '1');
localStorage.setItem('@paseo:e2e-seed-nonce', seedNonce);
// Hard-reset anything that could point to a developer's real daemon.
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
localStorage.removeItem("@paseo:settings");
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
localStorage.removeItem('@paseo:settings');
localStorage.setItem('@paseo:create-agent-preferences', JSON.stringify(preferences));
},
{ daemon: testDaemon, preferences: createAgentPreferences, seedNonce },
{ daemon: testDaemon, preferences: createAgentPreferences, seedNonce }
);
});
@@ -102,9 +105,9 @@ test.afterEach(async ({ page }, testInfo) => {
return;
}
await testInfo.attach("browser-console", {
body: entries.join("\n"),
contentType: "text/plain",
await testInfo.attach('browser-console', {
body: entries.join('\n'),
contentType: 'text/plain',
});
});
+140 -178
View File
@@ -1,11 +1,11 @@
import { spawn, type ChildProcess, execFileSync, execSync } from "node:child_process";
import { existsSync } from "node:fs";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import net from "node:net";
import { Buffer } from "node:buffer";
import dotenv from "dotenv";
import { spawn, type ChildProcess, execSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import net from 'node:net';
import { Buffer } from 'node:buffer';
import dotenv from 'dotenv';
type WaitForServerOptions = {
host?: string;
@@ -18,11 +18,11 @@ type WaitForServerOptions = {
async function getAvailablePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.once("error", reject);
server.once('error', reject);
server.listen(0, () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close(() => reject(new Error("Failed to acquire port")));
if (!address || typeof address === 'string') {
server.close(() => reject(new Error('Failed to acquire port')));
return;
}
server.close(() => resolve(address.port));
@@ -40,18 +40,18 @@ function createLineBuffer(maxLines = 120): { add: (line: string) => void; dump:
}
},
dump() {
return lines.join("\n");
return lines.join('\n');
},
};
}
function formatRecentOutput(getRecentOutput?: () => string): string {
if (!getRecentOutput) {
return "";
return '';
}
const output = getRecentOutput().trim();
if (!output) {
return "";
return '';
}
return `\nRecent output:\n${output}`;
}
@@ -61,15 +61,21 @@ function sleep(ms: number): Promise<void> {
}
async function waitForServer(port: number, options: WaitForServerOptions): Promise<void> {
const { host = "127.0.0.1", timeoutMs = 15000, label, childProcess, getRecentOutput } = options;
const {
host = '127.0.0.1',
timeoutMs = 15000,
label,
childProcess,
getRecentOutput,
} = options;
const start = Date.now();
let lastConnectionError: unknown = null;
while (Date.now() - start < timeoutMs) {
if (childProcess && childProcess.exitCode !== null) {
const signal = childProcess.signalCode ? `, signal ${childProcess.signalCode}` : "";
const signal = childProcess.signalCode ? `, signal ${childProcess.signalCode}` : '';
throw new Error(
`${label} exited before listening on ${host}:${port} (exit code ${childProcess.exitCode}${signal}).${formatRecentOutput(getRecentOutput)}`,
`${label} exited before listening on ${host}:${port} (exit code ${childProcess.exitCode}${signal}).${formatRecentOutput(getRecentOutput)}`
);
}
@@ -83,7 +89,7 @@ async function waitForServer(port: number, options: WaitForServerOptions): Promi
socket.destroy();
reject(new Error(`Connection timed out to ${host}:${port}`));
});
socket.on("error", reject);
socket.on('error', reject);
});
return;
} catch (error) {
@@ -93,11 +99,9 @@ async function waitForServer(port: number, options: WaitForServerOptions): Promi
}
const reason =
lastConnectionError instanceof Error
? ` Last connection error: ${lastConnectionError.message}`
: "";
lastConnectionError instanceof Error ? ` Last connection error: ${lastConnectionError.message}` : '';
throw new Error(
`${label} did not start on ${host}:${port} within ${timeoutMs}ms.${reason}${formatRecentOutput(getRecentOutput)}`,
`${label} did not start on ${host}:${port} within ${timeoutMs}ms.${reason}${formatRecentOutput(getRecentOutput)}`
);
}
@@ -122,15 +126,15 @@ async function stopProcess(child: ChildProcess | null): Promise<void> {
if (child.exitCode !== null || child.signalCode !== null) {
return;
}
child.kill("SIGTERM");
child.kill('SIGTERM');
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
if (child.exitCode === null && child.signalCode === null) {
child.kill("SIGKILL");
child.kill('SIGKILL');
}
resolve();
}, 5000);
child.once("exit", () => {
child.once('exit', () => {
clearTimeout(timeout);
resolve();
});
@@ -140,7 +144,7 @@ async function stopProcess(child: ChildProcess | null): Promise<void> {
function summarizeOpenAiErrorBody(body: string): string {
const trimmed = body.trim();
if (!trimmed) {
return "empty response body";
return 'empty response body';
}
if (trimmed.length <= 240) {
return trimmed;
@@ -155,8 +159,8 @@ async function isOpenAiApiKeyUsable(apiKey: string | undefined): Promise<boolean
}
try {
const response = await fetch("https://api.openai.com/v1/models?limit=1", {
method: "GET",
const response = await fetch('https://api.openai.com/v1/models?limit=1', {
method: 'GET',
headers: {
Authorization: `Bearer ${key}`,
},
@@ -166,14 +170,14 @@ async function isOpenAiApiKeyUsable(apiKey: string | undefined): Promise<boolean
}
const body = await response.text();
console.warn(
`[e2e] OPENAI_API_KEY probe failed (${response.status}): ${summarizeOpenAiErrorBody(body)}`,
`[e2e] OPENAI_API_KEY probe failed (${response.status}): ${summarizeOpenAiErrorBody(body)}`
);
return false;
} catch (error) {
console.warn(
`[e2e] OPENAI_API_KEY probe request failed: ${
error instanceof Error ? error.message : String(error)
}`,
}`
);
return false;
}
@@ -192,87 +196,42 @@ type OfferPayload = {
};
function stripAnsi(input: string): string {
return input.replace(/\u001b\[[0-9;]*m/g, "");
return input.replace(/\u001b\[[0-9;]*m/g, '');
}
function ensureRelayBuildArtifact(repoRoot: string): void {
const relayDistEntry = path.join(repoRoot, "packages/relay/dist/e2ee.js");
const relayDistEntry = path.join(repoRoot, 'packages/relay/dist/e2ee.js');
if (existsSync(relayDistEntry)) {
return;
}
console.log("[e2e] Building @getpaseo/relay for daemon startup");
execSync("npm run build --workspace=@getpaseo/relay", {
console.log('[e2e] Building @getpaseo/relay for daemon startup');
execSync('npm run build --workspace=@getpaseo/relay', {
cwd: repoRoot,
stdio: "inherit",
stdio: 'inherit',
});
}
function decodeOfferFromFragmentUrl(url: string): OfferPayload {
const marker = "#offer=";
const marker = '#offer=';
const idx = url.indexOf(marker);
if (idx === -1) {
throw new Error(`missing ${marker} fragment: ${url}`);
}
const encoded = url.slice(idx + marker.length);
const json = Buffer.from(encoded, "base64url").toString("utf8");
const json = Buffer.from(encoded, 'base64url').toString('utf8');
const offer = JSON.parse(json) as Partial<OfferPayload>;
if (offer.v !== 2) throw new Error("offer.v missing/invalid");
if (!offer.serverId) throw new Error("offer.serverId missing");
if (!offer.daemonPublicKeyB64) throw new Error("offer.daemonPublicKeyB64 missing");
if (!offer.relay?.endpoint) throw new Error("offer.relay.endpoint missing");
if (offer.v !== 2) throw new Error('offer.v missing/invalid');
if (!offer.serverId) throw new Error('offer.serverId missing');
if (!offer.daemonPublicKeyB64) throw new Error('offer.daemonPublicKeyB64 missing');
if (!offer.relay?.endpoint) throw new Error('offer.relay.endpoint missing');
return offer as OfferPayload;
}
function loadPairingOfferFromCli(repoRoot: string, paseoHomePath: string): OfferPayload {
const stdout = execFileSync(
process.execPath,
["--import", "tsx", "packages/cli/src/index.ts", "daemon", "pair", "--json"],
{
cwd: repoRoot,
env: {
...process.env,
PASEO_HOME: paseoHomePath,
},
encoding: "utf8",
},
);
const payload = JSON.parse(stdout) as { relayEnabled?: boolean; url?: string | null };
if (payload.relayEnabled !== true || typeof payload.url !== "string") {
throw new Error(`Unexpected daemon pair response: ${stdout}`);
}
return decodeOfferFromFragmentUrl(payload.url);
}
async function waitForPairingOfferFromCli(args: {
repoRoot: string;
paseoHome: string;
timeoutMs?: number;
}): Promise<OfferPayload> {
const timeoutMs = args.timeoutMs ?? 15000;
const start = Date.now();
let lastError: unknown = null;
while (Date.now() - start < timeoutMs) {
try {
return loadPairingOfferFromCli(args.repoRoot, args.paseoHome);
} catch (error) {
lastError = error;
await sleep(100);
}
}
throw new Error(
`Timed out waiting for \`paseo daemon pair --json\` to produce a pairing offer: ${
lastError instanceof Error ? lastError.message : String(lastError)
}`,
);
}
export default async function globalSetup() {
const repoRoot = path.resolve(__dirname, "../../..");
const repoRoot = path.resolve(__dirname, '../../..');
ensureRelayBuildArtifact(repoRoot);
const envTestPath = path.join(repoRoot, ".env.test");
const envTestPath = path.join(repoRoot, '.env.test');
if (existsSync(envTestPath)) {
dotenv.config({ path: envTestPath });
}
@@ -280,17 +239,13 @@ export default async function globalSetup() {
const port = await getAvailablePort();
let relayPort = 0;
const metroPort = await getAvailablePort();
paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-e2e-home-"));
paseoHome = await mkdtemp(path.join(tmpdir(), 'paseo-e2e-home-'));
let relayLineBuffer = createLineBuffer();
const metroLineBuffer = createLineBuffer();
const daemonLineBuffer = createLineBuffer();
const cleanup = async () => {
await Promise.all([
stopProcess(daemonProcess),
stopProcess(metroProcess),
stopProcess(relayProcess),
]);
await Promise.all([stopProcess(daemonProcess), stopProcess(metroProcess), stopProcess(relayProcess)]);
daemonProcess = null;
metroProcess = null;
relayProcess = null;
@@ -301,30 +256,24 @@ export default async function globalSetup() {
};
const openAiUsable = await isOpenAiApiKeyUsable(process.env.OPENAI_API_KEY);
const defaultLocalModelsDir = path.join(
process.env.HOME ?? "",
".paseo",
"models",
"local-speech",
);
const hasDefaultLocalModelsDir =
defaultLocalModelsDir.trim().length > 0 && existsSync(defaultLocalModelsDir);
const dictationProvider = openAiUsable ? "openai" : "local";
const defaultLocalModelsDir = path.join(process.env.HOME ?? '', '.paseo', 'models', 'local-speech');
const hasDefaultLocalModelsDir = defaultLocalModelsDir.trim().length > 0 && existsSync(defaultLocalModelsDir);
const dictationProvider = openAiUsable ? 'openai' : 'local';
if (dictationProvider === "local" && !hasDefaultLocalModelsDir) {
if (dictationProvider === 'local' && !hasDefaultLocalModelsDir) {
throw new Error(
"OpenAI key is not usable and local speech models are unavailable at ~/.paseo/models/local-speech. " +
"Either provide a valid OPENAI_API_KEY or install local speech models before running app e2e tests.",
'OpenAI key is not usable and local speech models are unavailable at ~/.paseo/models/local-speech. ' +
'Either provide a valid OPENAI_API_KEY or install local speech models before running app e2e tests.'
);
}
const localModelsDir = dictationProvider === "local" ? defaultLocalModelsDir : null;
const localModelsDir = dictationProvider === 'local' ? defaultLocalModelsDir : null;
console.log(
`[e2e] Dictation STT provider: ${dictationProvider}${openAiUsable ? "" : " (OpenAI probe failed)"}`,
`[e2e] Dictation STT provider: ${dictationProvider}${openAiUsable ? '' : ' (OpenAI probe failed)'}`
);
try {
const relayDir = path.resolve(__dirname, "..", "..", "relay");
const relayDir = path.resolve(__dirname, '..', '..', 'relay');
const maxRelayStartupAttempts = 5;
let relayStarted = false;
let lastRelayStartupError: unknown = null;
@@ -336,21 +285,18 @@ export default async function globalSetup() {
let relayReadyForSelectedPort = false;
relayProcess = spawn(
"npx",
["wrangler", "dev", "--local", "--ip", "127.0.0.1", "--port", String(relayPort)],
'npx',
['wrangler', 'dev', '--local', '--ip', '127.0.0.1', '--port', String(relayPort)],
{
cwd: relayDir,
env: { ...process.env },
stdio: ["ignore", "pipe", "pipe"],
stdio: ['ignore', 'pipe', 'pipe'],
detached: false,
},
}
);
relayProcess.stdout?.on("data", (data: Buffer) => {
const lines = data
.toString()
.split("\n")
.filter((line) => line.trim());
relayProcess.stdout?.on('data', (data: Buffer) => {
const lines = data.toString().split('\n').filter((line) => line.trim());
for (const line of lines) {
relayLineBuffer.add(`[stdout] ${line}`);
const failure = parseRelayStartupFailure(line);
@@ -365,11 +311,8 @@ export default async function globalSetup() {
console.log(`[relay] ${line}`);
}
});
relayProcess.stderr?.on("data", (data: Buffer) => {
const lines = data
.toString()
.split("\n")
.filter((line) => line.trim());
relayProcess.stderr?.on('data', (data: Buffer) => {
const lines = data.toString().split('\n').filter((line) => line.trim());
for (const line of lines) {
relayLineBuffer.add(`[stderr] ${line}`);
const failure = parseRelayStartupFailure(line);
@@ -387,7 +330,7 @@ export default async function globalSetup() {
try {
await waitForServer(relayPort, {
label: "Relay dev server",
label: 'Relay dev server',
timeoutMs: 30000,
childProcess: relayProcess,
getRecentOutput: relayLineBuffer.dump,
@@ -410,15 +353,15 @@ export default async function globalSetup() {
if (!relayReadyForSelectedPort) {
throw new Error(
`Relay process did not report ready for selected port ${relayPort}.${formatRecentOutput(
relayLineBuffer.dump,
)}`,
relayLineBuffer.dump
)}`
);
}
if (relayProcess.exitCode !== null || relayProcess.signalCode !== null) {
throw new Error(
`Relay process exited before startup completed (exit code ${relayProcess.exitCode}, signal ${relayProcess.signalCode}).${formatRecentOutput(
relayLineBuffer.dump,
)}`,
relayLineBuffer.dump
)}`
);
}
@@ -437,90 +380,104 @@ export default async function globalSetup() {
? lastRelayStartupError.message
: String(lastRelayStartupError);
throw new Error(
`Failed to start relay dev server after ${maxRelayStartupAttempts} attempts. ${message}`,
`Failed to start relay dev server after ${maxRelayStartupAttempts} attempts. ${message}`
);
}
// Start Metro bundler on dynamic port
const appDir = path.resolve(__dirname, "..");
metroProcess = spawn("npx", ["expo", "start", "--web", "--port", String(metroPort)], {
const appDir = path.resolve(__dirname, '..');
metroProcess = spawn('npx', ['expo', 'start', '--web', '--port', String(metroPort)], {
cwd: appDir,
env: {
...process.env,
BROWSER: "none", // Don't auto-open browser
BROWSER: 'none', // Don't auto-open browser
},
stdio: ["ignore", "pipe", "pipe"],
stdio: ['ignore', 'pipe', 'pipe'],
detached: false,
});
metroProcess.stdout?.on("data", (data: Buffer) => {
const lines = data
.toString()
.split("\n")
.filter((line) => line.trim());
metroProcess.stdout?.on('data', (data: Buffer) => {
const lines = data.toString().split('\n').filter((line) => line.trim());
for (const line of lines) {
metroLineBuffer.add(`[stdout] ${line}`);
console.log(`[metro] ${line}`);
}
});
metroProcess.stderr?.on("data", (data: Buffer) => {
const lines = data
.toString()
.split("\n")
.filter((line) => line.trim());
metroProcess.stderr?.on('data', (data: Buffer) => {
const lines = data.toString().split('\n').filter((line) => line.trim());
for (const line of lines) {
metroLineBuffer.add(`[stderr] ${line}`);
console.error(`[metro] ${line}`);
}
});
const serverDir = path.resolve(__dirname, "../../..", "packages/server");
const tsxBin = execSync("which tsx").toString().trim();
const serverDir = path.resolve(__dirname, '../../..', 'packages/server');
const tsxBin = execSync('which tsx').toString().trim();
daemonProcess = spawn(tsxBin, ["src/server/index.ts"], {
let offerPayload: OfferPayload | null = null;
let offerResolve: (() => void) | null = null;
const offerPromise = new Promise<void>((resolve) => {
offerResolve = resolve;
});
daemonProcess = spawn(tsxBin, ['src/server/index.ts'], {
cwd: serverDir,
env: {
...process.env,
PASEO_HOME: paseoHome,
PASEO_SERVER_ID: "srv_e2e_test_daemon",
PASEO_SERVER_ID: 'srv_e2e_test_daemon',
PASEO_LISTEN: `0.0.0.0:${port}`,
PASEO_RELAY_ENDPOINT: `127.0.0.1:${relayPort}`,
PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`,
PASEO_DICTATION_ENABLED: openAiUsable ? "1" : "0",
PASEO_VOICE_MODE_ENABLED: openAiUsable ? "1" : "0",
...(openAiUsable
? {
PASEO_DICTATION_STT_PROVIDER: "openai",
PASEO_VOICE_STT_PROVIDER: "openai",
PASEO_VOICE_TTS_PROVIDER: "openai",
}
: {}),
// Use OpenAI speech providers in e2e to avoid local model bootstrapping delays.
PASEO_DICTATION_ENABLED: '1',
PASEO_VOICE_MODE_ENABLED: '1',
PASEO_DICTATION_STT_PROVIDER: dictationProvider,
PASEO_VOICE_STT_PROVIDER: 'openai',
PASEO_VOICE_TTS_PROVIDER: 'openai',
...(localModelsDir ? { PASEO_LOCAL_MODELS_DIR: localModelsDir } : {}),
NODE_ENV: "development",
NODE_ENV: 'development',
},
stdio: ["ignore", "pipe", "pipe"],
stdio: ['ignore', 'pipe', 'pipe'],
detached: false,
});
let stdoutBuffer = "";
daemonProcess.stdout?.on("data", (data: Buffer) => {
stdoutBuffer += data.toString("utf8");
const lines = stdoutBuffer.split("\n");
stdoutBuffer = lines.pop() ?? "";
let stdoutBuffer = '';
daemonProcess.stdout?.on('data', (data: Buffer) => {
stdoutBuffer += data.toString('utf8');
const lines = stdoutBuffer.split('\n');
stdoutBuffer = lines.pop() ?? '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
daemonLineBuffer.add(`[stdout] ${trimmed}`);
if (!offerPayload) {
const clean = stripAnsi(trimmed);
try {
const obj = JSON.parse(clean) as { msg?: string; url?: string };
if (obj.msg === 'pairing_offer' && typeof obj.url === 'string') {
offerPayload = decodeOfferFromFragmentUrl(obj.url);
offerResolve?.();
}
} catch {
const match = clean.match(/https?:\/\/[^\s"]+#offer=[A-Za-z0-9_-]+/);
if (match && clean.includes('pairing_offer')) {
try {
offerPayload = decodeOfferFromFragmentUrl(match[0]);
offerResolve?.();
} catch {
// ignore parsing failures
}
}
}
}
console.log(`[daemon] ${trimmed}`);
}
});
daemonProcess.stderr?.on("data", (data: Buffer) => {
const lines = data
.toString()
.split("\n")
.filter((line) => line.trim());
daemonProcess.stderr?.on('data', (data: Buffer) => {
const lines = data.toString().split('\n').filter((line) => line.trim());
for (const line of lines) {
daemonLineBuffer.add(`[stderr] ${line}`);
console.error(`[daemon] ${line}`);
@@ -530,35 +487,40 @@ export default async function globalSetup() {
// Wait for both daemon and Metro to be ready
await Promise.all([
waitForServer(port, {
label: "Paseo daemon",
label: 'Paseo daemon',
childProcess: daemonProcess,
getRecentOutput: daemonLineBuffer.dump,
}),
waitForServer(metroPort, {
label: "Metro web server",
label: 'Metro web server',
timeoutMs: 120000, // Metro can take longer to start
childProcess: metroProcess,
getRecentOutput: metroLineBuffer.dump,
}),
]);
const offer = await waitForPairingOfferFromCli({
repoRoot,
paseoHome,
});
// Wait for daemon to emit a pairing offer (includes relay session ID).
await Promise.race([
offerPromise,
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timed out waiting for pairing_offer log')), 15000)
),
]);
if (!offerPayload) {
throw new Error('pairing_offer was not parsed from daemon logs');
}
const offer = offerPayload as OfferPayload;
process.env.E2E_DAEMON_PORT = String(port);
process.env.E2E_RELAY_PORT = String(relayPort);
process.env.E2E_SERVER_ID = offer.serverId;
process.env.E2E_RELAY_DAEMON_PUBLIC_KEY = offer.daemonPublicKeyB64;
process.env.E2E_METRO_PORT = String(metroPort);
console.log(
`[e2e] Test daemon started on port ${port}, Metro on port ${metroPort}, home: ${paseoHome}`,
);
console.log(`[e2e] Test daemon started on port ${port}, Metro on port ${metroPort}, home: ${paseoHome}`);
return async () => {
await cleanup();
console.log("[e2e] Test daemon stopped");
console.log('[e2e] Test daemon stopped');
};
} catch (error) {
await cleanup();
+34 -29
View File
@@ -2,8 +2,10 @@ import { expect, type Page } from "@playwright/test";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { randomUUID } from "node:crypto";
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
import {
buildHostWorkspaceAgentRoute,
buildHostWorkspaceRoute,
} from "../../src/utils/host-routes";
const NEAR_BOTTOM_THRESHOLD_PX = 72;
@@ -35,7 +37,10 @@ export type DaemonClientInstance = {
initialPrompt: string;
}): Promise<{ id: string }>;
sendAgentMessage(agentId: string, text: string): Promise<void>;
waitForFinish(agentId: string, timeout?: number): Promise<{ status: string }>;
waitForFinish(
agentId: string,
timeout?: number
): Promise<{ status: string }>;
};
function getDaemonWsUrl(): string {
@@ -84,36 +89,31 @@ export function createReplyTurn(label: string): {
};
}
type DaemonClientConfig = {
async function loadDaemonClientConstructor(): Promise<new (config: {
url: string;
clientId: string;
clientType: "cli";
webSocketFactory?: NodeWebSocketFactory;
};
async function loadDaemonClientConstructor(): Promise<
new (
config: DaemonClientConfig,
) => DaemonClientInstance
> {
const repoRoot = path.resolve(__dirname, "../../../../");
}) => DaemonClientInstance> {
const repoRoot = path.resolve(process.cwd(), "../..");
const moduleUrl = pathToFileURL(
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
path.join(repoRoot, "packages/server/dist/server/server/exports.js")
).href;
const mod = (await import(moduleUrl)) as {
DaemonClient: new (config: DaemonClientConfig) => DaemonClientInstance;
DaemonClient: new (config: {
url: string;
clientId: string;
clientType: "cli";
}) => DaemonClientInstance;
};
return mod.DaemonClient;
}
export async function connectDaemonClient(): Promise<DaemonClientInstance> {
const DaemonClient = await loadDaemonClientConstructor();
const webSocketFactory = createNodeWebSocketFactory();
const client = new DaemonClient({
url: getDaemonWsUrl(),
clientId: `app-e2e-${randomUUID()}`,
clientType: "cli",
webSocketFactory,
});
await client.connect();
return client;
@@ -131,7 +131,7 @@ export async function seedBottomAnchorAgent(input: {
const lineCount = Math.max(14, input.lineCount ?? 14);
const created = await input.client.createAgent({
provider: "codex",
model: "gpt-5.4-mini",
model: "gpt-5.1-codex-mini",
thinkingOptionId: "low",
modeId: "full-access",
cwd: input.cwd,
@@ -141,7 +141,7 @@ export async function seedBottomAnchorAgent(input: {
const initialFinish = await input.client.waitForFinish(created.id, 120000);
if (initialFinish.status !== "idle") {
throw new Error(
`Expected seeded agent ${created.id} to become idle after initial prompt, got ${initialFinish.status}.`,
`Expected seeded agent ${created.id} to become idle after initial prompt, got ${initialFinish.status}.`
);
}
@@ -153,7 +153,7 @@ export async function seedBottomAnchorAgent(input: {
const finish = await input.client.waitForFinish(created.id, 120000);
if (finish.status !== "idle") {
throw new Error(
`Expected seeded agent ${created.id} to become idle after turn ${index}, got ${finish.status}.`,
`Expected seeded agent ${created.id} to become idle after turn ${index}, got ${finish.status}.`
);
}
}
@@ -162,7 +162,7 @@ export async function seedBottomAnchorAgent(input: {
id: created.id,
title,
expectedTailText,
url: `${buildHostWorkspaceRoute(getServerId(), input.cwd)}?open=${encodeURIComponent(`agent:${created.id}`)}`,
url: buildHostWorkspaceAgentRoute(getServerId(), input.cwd, created.id),
workspaceUrl: buildHostWorkspaceRoute(getServerId(), input.cwd),
};
}
@@ -186,13 +186,18 @@ export async function readScrollMetrics(page: Page): Promise<ScrollMetrics> {
const scrollElement =
candidates.sort(
(left, right) =>
right.scrollHeight - right.clientHeight - (left.scrollHeight - left.clientHeight),
right.scrollHeight -
right.clientHeight -
(left.scrollHeight - left.clientHeight)
)[0] ?? (root as HTMLElement);
const offsetY = Math.max(0, scrollElement.scrollTop);
const contentHeight = Math.max(0, scrollElement.scrollHeight);
const viewportHeight = Math.max(0, scrollElement.clientHeight);
const distanceFromBottom = Math.max(0, contentHeight - (offsetY + viewportHeight));
const distanceFromBottom = Math.max(
0,
contentHeight - (offsetY + viewportHeight)
);
return {
offsetY,
@@ -216,7 +221,7 @@ export async function scrollUpFromBottom(page: Page, pixels: number): Promise<vo
deltaY: -step,
bubbles: true,
cancelable: true,
}),
})
);
scrollContainer.scrollTop = Math.max(0, scrollContainer.scrollTop - step);
scrollContainer.dispatchEvent(new Event("scroll", { bubbles: true }));
@@ -265,7 +270,7 @@ export async function expectDetachedFromBottom(page: Page): Promise<void> {
export async function waitForContentGrowth(
page: Page,
previousContentHeight: number,
previousContentHeight: number
): Promise<ScrollMetrics> {
await expect
.poll(async () => {
@@ -278,8 +283,8 @@ export async function waitForContentGrowth(
export async function getChatContainerKey(page: Page): Promise<string | null> {
return getVisibleChatScroll(page).evaluate((element) => {
const nativeId = (element as HTMLElement).id;
const prefix = "agent-chat-scroll-";
return nativeId.startsWith(prefix) ? nativeId.slice(prefix.length) : null;
});
const nativeId = (element as HTMLElement).id;
const prefix = "agent-chat-scroll-";
return nativeId.startsWith(prefix) ? nativeId.slice(prefix.length) : null;
});
}
+148 -185
View File
@@ -1,19 +1,20 @@
import { expect, type Page } from "@playwright/test";
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
import { expect, type Page } from '@playwright/test';
import {
buildCreateAgentPreferences,
buildSeededHost,
} from './daemon-registry';
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function getE2EDaemonPort(): string {
const port = process.env.E2E_DAEMON_PORT;
if (!port) {
throw new Error("E2E_DAEMON_PORT is not set (expected from Playwright globalSetup).");
throw new Error('E2E_DAEMON_PORT is not set (expected from Playwright globalSetup).');
}
if (port === "6767") {
throw new Error(
"E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon.",
);
if (port === '6767') {
throw new Error('E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon.');
}
return port;
}
@@ -23,38 +24,25 @@ async function ensureE2EStorageSeeded(page: Page): Promise<void> {
const expectedEndpoint = `127.0.0.1:${port}`;
const expectedServerId = process.env.E2E_SERVER_ID;
if (!expectedServerId) {
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
throw new Error('E2E_SERVER_ID is not set (expected from Playwright globalSetup).');
}
const needsReset = await page.evaluate(
({ expectedEndpoint, expectedServerId }) => {
const raw = localStorage.getItem("@paseo:daemon-registry");
if (!raw) return true;
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed) || parsed.length !== 1) return true;
const entry = parsed[0] as any;
if (entry?.serverId !== expectedServerId) return true;
const connections = entry?.connections;
if (!Array.isArray(connections)) return true;
if (
connections.some(
(c: any) =>
c?.type === "directTcp" &&
typeof c?.endpoint === "string" &&
/:6767\b/.test(c.endpoint),
)
)
return true;
return !connections.some(
(c: any) => c?.type === "directTcp" && c?.endpoint === expectedEndpoint,
);
} catch {
return true;
}
},
{ expectedEndpoint, expectedServerId },
);
const needsReset = await page.evaluate(({ expectedEndpoint, expectedServerId }) => {
const raw = localStorage.getItem('@paseo:daemon-registry');
if (!raw) return true;
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed) || parsed.length !== 1) return true;
const entry = parsed[0] as any;
if (entry?.serverId !== expectedServerId) return true;
const connections = entry?.connections;
if (!Array.isArray(connections)) return true;
if (connections.some((c: any) => c?.type === 'directTcp' && typeof c?.endpoint === 'string' && /:6767\b/.test(c.endpoint))) return true;
return !connections.some((c: any) => c?.type === 'directTcp' && c?.endpoint === expectedEndpoint);
} catch {
return true;
}
}, { expectedEndpoint, expectedServerId });
if (!needsReset) {
return;
@@ -69,12 +57,12 @@ async function ensureE2EStorageSeeded(page: Page): Promise<void> {
const preferences = buildCreateAgentPreferences(expectedServerId);
await page.evaluate(
({ daemon, preferences }) => {
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
localStorage.removeItem("@paseo:settings");
localStorage.setItem('@paseo:e2e', '1');
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
localStorage.setItem('@paseo:create-agent-preferences', JSON.stringify(preferences));
localStorage.removeItem('@paseo:settings');
},
{ daemon, preferences },
{ daemon, preferences }
);
await page.reload();
@@ -85,98 +73,81 @@ async function assertE2EUsesSeededTestDaemon(page: Page): Promise<void> {
const expectedEndpoint = `127.0.0.1:${port}`;
const expectedServerId = process.env.E2E_SERVER_ID;
if (!expectedServerId) {
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
throw new Error('E2E_SERVER_ID is not set (expected from Playwright globalSetup).');
}
const snapshot = await page.evaluate(() => {
const registryRaw = localStorage.getItem("@paseo:daemon-registry");
const prefsRaw = localStorage.getItem("@paseo:create-agent-preferences");
const registryRaw = localStorage.getItem('@paseo:daemon-registry');
const prefsRaw = localStorage.getItem('@paseo:create-agent-preferences');
return { registryRaw, prefsRaw };
});
if (!snapshot.registryRaw) {
throw new Error("E2E expected @paseo:daemon-registry to be set before app load.");
throw new Error('E2E expected @paseo:daemon-registry to be set before app load.');
}
let registry: any;
try {
registry = JSON.parse(snapshot.registryRaw);
} catch {
throw new Error("E2E expected @paseo:daemon-registry to be valid JSON.");
throw new Error('E2E expected @paseo:daemon-registry to be valid JSON.');
}
if (!Array.isArray(registry) || registry.length !== 1) {
throw new Error(
`E2E expected @paseo:daemon-registry to contain exactly 1 daemon (got ${Array.isArray(registry) ? registry.length : "non-array"}).`,
`E2E expected @paseo:daemon-registry to contain exactly 1 daemon (got ${Array.isArray(registry) ? registry.length : 'non-array'}).`
);
}
const daemon = registry[0];
if (typeof daemon?.serverId !== "string" || daemon.serverId.length === 0) {
throw new Error(
`E2E expected seeded daemon to have a string serverId (got ${String(daemon?.serverId)}).`,
);
if (typeof daemon?.serverId !== 'string' || daemon.serverId.length === 0) {
throw new Error(`E2E expected seeded daemon to have a string serverId (got ${String(daemon?.serverId)}).`);
}
if (daemon.serverId !== expectedServerId) {
throw new Error(
`E2E expected seeded daemon serverId to be ${expectedServerId} (got ${daemon.serverId}).`,
);
throw new Error(`E2E expected seeded daemon serverId to be ${expectedServerId} (got ${daemon.serverId}).`);
}
const connections: unknown = daemon?.connections;
if (
!Array.isArray(connections) ||
!connections.some((c: any) => c?.type === "directTcp" && c?.endpoint === expectedEndpoint)
!connections.some((c: any) => c?.type === 'directTcp' && c?.endpoint === expectedEndpoint)
) {
throw new Error(
`E2E expected seeded daemon connections to include directTcp ${expectedEndpoint} (got ${JSON.stringify(connections)}).`,
`E2E expected seeded daemon connections to include directTcp ${expectedEndpoint} (got ${JSON.stringify(connections)}).`
);
}
if (
Array.isArray(connections) &&
connections.some(
(c: any) =>
c?.type === "directTcp" && typeof c?.endpoint === "string" && /:6767\b/.test(c.endpoint),
)
) {
throw new Error(
`E2E detected a daemon endpoint pointing at :6767 (${JSON.stringify(connections)}).`,
);
if (Array.isArray(connections) && connections.some((c: any) => c?.type === 'directTcp' && typeof c?.endpoint === 'string' && /:6767\b/.test(c.endpoint))) {
throw new Error(`E2E detected a daemon endpoint pointing at :6767 (${JSON.stringify(connections)}).`);
}
if (!snapshot.prefsRaw) {
throw new Error("E2E expected @paseo:create-agent-preferences to be set before app load.");
throw new Error('E2E expected @paseo:create-agent-preferences to be set before app load.');
}
try {
const prefs = JSON.parse(snapshot.prefsRaw) as any;
if (prefs?.serverId !== daemon.serverId) {
throw new Error(
`E2E expected create-agent-preferences.serverId to match seeded daemon serverId (${daemon.serverId}) (got ${String(prefs?.serverId)}).`,
`E2E expected create-agent-preferences.serverId to match seeded daemon serverId (${daemon.serverId}) (got ${String(prefs?.serverId)}).`
);
}
} catch (error) {
if (error instanceof Error) throw error;
throw new Error("E2E expected @paseo:create-agent-preferences to be valid JSON.");
throw new Error('E2E expected @paseo:create-agent-preferences to be valid JSON.');
}
}
export const gotoAppShell = async (page: Page) => {
await page.goto("/");
await page.goto('/');
await ensureE2EStorageSeeded(page);
};
export const gotoHome = async (page: Page) => {
await gotoAppShell(page);
const composer = page.getByRole("textbox", { name: "Message agent..." });
if (
!(await composer
.first()
.isVisible()
.catch(() => false))
) {
const addProjectCta = page.getByText("Add a project", { exact: true }).first();
const addProjectSidebar = page.getByText("Add project", { exact: true }).first();
const newAgentButton = page.getByText("New agent", { exact: true }).first();
const composer = page.getByRole('textbox', { name: 'Message agent...' });
if (!(await composer.first().isVisible().catch(() => false))) {
const addProjectCta = page.getByText('Add a project', { exact: true }).first();
const addProjectSidebar = page.getByText('Add project', { exact: true }).first();
const newAgentButton = page.getByText('New agent', { exact: true }).first();
await expect
.poll(
@@ -184,7 +155,7 @@ export const gotoHome = async (page: Page) => {
(await addProjectCta.isVisible().catch(() => false)) ||
(await addProjectSidebar.isVisible().catch(() => false)) ||
(await newAgentButton.isVisible().catch(() => false)),
{ timeout: 10000 },
{ timeout: 10000 }
)
.toBe(true);
@@ -202,7 +173,7 @@ export const gotoHome = async (page: Page) => {
export const openSettings = async (page: Page) => {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
throw new Error('E2E_SERVER_ID is not set (expected from Playwright globalSetup).');
}
// Navigate through the real app control so route changes stay aligned with UI behavior.
@@ -218,19 +189,23 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
.first();
await expect(workingDirectorySelect).toBeVisible({ timeout: 30000 });
const legacyInput = page.getByRole("textbox", { name: "/path/to/project" }).first();
const directorySearchInput = page.getByRole("textbox", { name: /search directories/i }).first();
const worktreePicker = page.getByTestId("worktree-attach-picker");
const worktreeSheetTitle = page.getByText("Select worktree", { exact: true }).first();
const legacyInput = page.getByRole('textbox', { name: '/path/to/project' }).first();
const directorySearchInput = page.getByRole('textbox', { name: /search directories/i }).first();
const worktreePicker = page.getByTestId('worktree-attach-picker');
const worktreeSheetTitle = page.getByText('Select worktree', { exact: true }).first();
const closeBottomSheet = async () => {
const bottomSheetBackdrop = page.getByRole("button", { name: "Bottom sheet backdrop" }).first();
const bottomSheetHandle = page.getByRole("slider", { name: "Bottom sheet handle" }).first();
const bottomSheetBackdrop = page
.getByRole('button', { name: 'Bottom sheet backdrop' })
.first();
const bottomSheetHandle = page
.getByRole('slider', { name: 'Bottom sheet handle' })
.first();
for (let attempt = 0; attempt < 3; attempt += 1) {
if (!(await bottomSheetBackdrop.isVisible())) {
return;
}
await bottomSheetBackdrop.click({ force: true });
await page.keyboard.press("Escape").catch(() => undefined);
await page.keyboard.press('Escape').catch(() => undefined);
await page.waitForTimeout(200);
}
if (await bottomSheetBackdrop.isVisible()) {
@@ -250,7 +225,7 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
if (!(await worktreeSheetTitle.isVisible()) && !(await worktreePicker.isVisible())) {
return;
}
const attachToggle = page.getByTestId("worktree-attach-toggle");
const attachToggle = page.getByTestId('worktree-attach-toggle');
if (await attachToggle.isVisible()) {
await attachToggle.click({ force: true });
await page.waitForTimeout(200);
@@ -271,23 +246,26 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
await closeBottomSheet();
await workingDirectorySelect.click({ force: true });
}
await expect.poll(async () => pickerInputVisible(), { timeout: 10000 }).toBe(true);
await expect
.poll(async () => pickerInputVisible(), { timeout: 10000 })
.toBe(true);
}
const trimmedDirectory = directory.replace(/\/+$/, "");
const activeInput = (await directorySearchInput.isVisible().catch(() => false))
? directorySearchInput
: legacyInput;
const trimmedDirectory = directory.replace(/\/+$/, '');
const activeInput =
(await directorySearchInput.isVisible().catch(() => false))
? directorySearchInput
: legacyInput;
await activeInput.fill(trimmedDirectory);
if (activeInput === directorySearchInput) {
// Combobox custom rows can be either plain path labels or prefixed labels.
const plainOption = page
.getByText(new RegExp(`^${escapeRegex(trimmedDirectory)}$`, "i"))
.getByText(new RegExp(`^${escapeRegex(trimmedDirectory)}$`, 'i'))
.first();
const prefixedUseOption = page
.getByText(new RegExp(`^Use "${escapeRegex(trimmedDirectory)}"$`, "i"))
.getByText(new RegExp(`^Use "${escapeRegex(trimmedDirectory)}"$`, 'i'))
.first();
if (await plainOption.isVisible().catch(() => false)) {
@@ -296,38 +274,33 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
await prefixedUseOption.click({ force: true });
} else {
// Fallback: accept highlighted option (directory suggestion).
await activeInput.press("Enter");
await activeInput.press('Enter');
}
} else {
// Legacy path picker fallback.
await activeInput.press("Enter");
await activeInput.press('Enter');
}
// Wait for picker to close.
await expect(activeInput).not.toBeVisible({ timeout: 10000 });
const directoryCandidates = new Set<string>([trimmedDirectory]);
if (trimmedDirectory.startsWith("/var/")) {
if (trimmedDirectory.startsWith('/var/')) {
directoryCandidates.add(`/private${trimmedDirectory}`);
}
if (trimmedDirectory.startsWith("/private/var/")) {
directoryCandidates.add(trimmedDirectory.replace(/^\/private/, ""));
if (trimmedDirectory.startsWith('/private/var/')) {
directoryCandidates.add(trimmedDirectory.replace(/^\/private/, ''));
}
const basename = trimmedDirectory.split("/").filter(Boolean).pop() ?? trimmedDirectory;
const basename = trimmedDirectory.split('/').filter(Boolean).pop() ?? trimmedDirectory;
await expect
.poll(
async () => {
const text = await workingDirectorySelect.innerText().catch(() => "");
if (text.includes(basename)) return true;
for (const candidate of directoryCandidates) {
if (text.includes(candidate)) return true;
}
return false;
},
{ timeout: 30000 },
)
.toBe(true);
await expect.poll(async () => {
const text = await workingDirectorySelect.innerText().catch(() => '');
if (text.includes(basename)) return true;
for (const candidate of directoryCandidates) {
if (text.includes(candidate)) return true;
}
return false;
}, { timeout: 30000 }).toBe(true);
};
export const ensureHostSelected = async (page: Page) => {
@@ -345,21 +318,19 @@ export const ensureHostSelected = async (page: Page) => {
}
const fix = await page.evaluate(() => {
const registryRaw = localStorage.getItem("@paseo:daemon-registry");
const prefsRaw = localStorage.getItem("@paseo:create-agent-preferences");
if (!registryRaw || !prefsRaw) return { ok: false, reason: "missing storage" } as const;
const registryRaw = localStorage.getItem('@paseo:daemon-registry');
const prefsRaw = localStorage.getItem('@paseo:create-agent-preferences');
if (!registryRaw || !prefsRaw) return { ok: false, reason: 'missing storage' } as const;
const registry = JSON.parse(registryRaw) as any[];
const prefs = JSON.parse(prefsRaw) as any;
if (!Array.isArray(registry) || registry.length !== 1)
return { ok: false, reason: "registry shape" } as const;
if (!Array.isArray(registry) || registry.length !== 1) return { ok: false, reason: 'registry shape' } as const;
const serverId = registry[0]?.serverId;
if (typeof serverId !== "string" || serverId.length === 0)
return { ok: false, reason: "missing serverId" } as const;
if (typeof serverId !== 'string' || serverId.length === 0) return { ok: false, reason: 'missing serverId' } as const;
prefs.serverId = serverId;
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(prefs));
localStorage.setItem('@paseo:create-agent-preferences', JSON.stringify(prefs));
// Prevent the fixture's init-script from overwriting the corrected prefs on reload.
const nonce = localStorage.getItem("@paseo:e2e-seed-nonce") ?? "1";
localStorage.setItem("@paseo:e2e-disable-default-seed-once", nonce);
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
return { ok: true } as const;
});
@@ -371,22 +342,20 @@ export const ensureHostSelected = async (page: Page) => {
await assertE2EUsesSeededTestDaemon(page);
}
const input = page.getByRole("textbox", { name: "Message agent..." });
const input = page.getByRole('textbox', { name: 'Message agent...' });
await expect(input).toBeVisible();
if (await input.isEditable()) {
return;
}
const selectHostLabel = page.getByText("Select host", { exact: true });
const selectHostLabel = page.getByText('Select host', { exact: true });
if (await selectHostLabel.isVisible()) {
await selectHostLabel.click();
// E2E safety: we enforce a single seeded daemon, so the option should be unambiguous.
const localhostOption = page.getByText("localhost", { exact: true }).first();
const daemonIdOption = page
.getByText(process.env.E2E_SERVER_ID ?? "srv_e2e_test_daemon", { exact: true })
.first();
const localhostOption = page.getByText('localhost', { exact: true }).first();
const daemonIdOption = page.getByText(process.env.E2E_SERVER_ID ?? 'srv_e2e_test_daemon', { exact: true }).first();
if (await localhostOption.isVisible()) {
await localhostOption.click();
@@ -400,11 +369,11 @@ export const ensureHostSelected = async (page: Page) => {
};
export const createAgent = async (page: Page, message: string) => {
const input = page.getByRole("textbox", { name: "Message agent..." });
const input = page.getByRole('textbox', { name: 'Message agent...' });
await expect(input).toBeEditable();
await preferFastThinkingOption(page);
await input.fill(message);
await input.press("Enter");
await input.press('Enter');
// The composer may remain on the draft screen briefly while the initial run starts,
// so assert the user-visible result instead of forcing one route shape here.
@@ -416,23 +385,21 @@ export const createAgent = async (page: Page, message: string) => {
async function preferFastThinkingOption(page: Page): Promise<void> {
const providerTrigger = page
.locator(
'[data-testid="agent-provider-selector"]:visible, [data-testid="draft-provider-select"]:visible',
)
.locator('[data-testid="agent-provider-selector"]:visible, [data-testid="draft-provider-select"]:visible')
.first();
if (await providerTrigger.isVisible().catch(() => false)) {
const providerText = ((await providerTrigger.innerText().catch(() => "")) ?? "").trim();
const providerText = ((await providerTrigger.innerText().catch(() => '')) ?? '').trim();
if (!/codex/i.test(providerText)) {
return;
}
}
const thinkingTrigger = page.getByTestId("agent-thinking-selector").first();
const thinkingTrigger = page.getByTestId('agent-thinking-selector').first();
if (!(await thinkingTrigger.isVisible().catch(() => false))) {
return;
}
const currentThinkingLabel = ((await thinkingTrigger.innerText().catch(() => "")) ?? "")
const currentThinkingLabel = ((await thinkingTrigger.innerText().catch(() => '')) ?? '')
.trim()
.toLowerCase();
if (/\b(low|minimal|off)\b/.test(currentThinkingLabel)) {
@@ -440,16 +407,16 @@ async function preferFastThinkingOption(page: Page): Promise<void> {
}
await thinkingTrigger.click();
const menu = page.getByTestId("agent-thinking-menu").first();
const menu = page.getByTestId('agent-thinking-menu').first();
if (!(await menu.isVisible().catch(() => false))) {
return;
}
const preferredLabels = ["low", "minimal", "off", "medium"];
const preferredLabels = ['low', 'minimal', 'off', 'medium'];
let selected = false;
for (const label of preferredLabels) {
const option = menu
.getByRole("button", { name: new RegExp(`^${escapeRegex(label)}$`, "i") })
.getByRole('button', { name: new RegExp(`^${escapeRegex(label)}$`, 'i') })
.first();
if (await option.isVisible().catch(() => false)) {
await option.click({ force: true });
@@ -459,11 +426,11 @@ async function preferFastThinkingOption(page: Page): Promise<void> {
}
if (!selected) {
const options = menu.getByRole("button");
const options = menu.getByRole('button');
const count = await options.count();
for (let index = 0; index < count; index += 1) {
const option = options.nth(index);
const label = ((await option.innerText().catch(() => "")) ?? "").trim();
const label = ((await option.innerText().catch(() => '')) ?? '').trim();
if (!label) {
continue;
}
@@ -477,7 +444,7 @@ async function preferFastThinkingOption(page: Page): Promise<void> {
}
if (!selected) {
await page.keyboard.press("Escape").catch(() => undefined);
await page.keyboard.press('Escape').catch(() => undefined);
return;
}
@@ -495,17 +462,15 @@ export interface AgentConfig {
export const selectProvider = async (page: Page, provider: string) => {
const normalizedProvider = provider.trim();
if (!normalizedProvider) {
throw new Error("Provider must be a non-empty string.");
throw new Error('Provider must be a non-empty string.');
}
const providerTrigger = page
.locator(
'[data-testid="agent-provider-selector"]:visible, [data-testid="draft-provider-select"]:visible',
)
.locator('[data-testid="agent-provider-selector"]:visible, [data-testid="draft-provider-select"]:visible')
.first();
if (
await providerTrigger
.getByText(new RegExp(`^${escapeRegex(normalizedProvider)}$`, "i"))
.getByText(new RegExp(`^${escapeRegex(normalizedProvider)}$`, 'i'))
.first()
.isVisible()
.catch(() => false)
@@ -516,18 +481,20 @@ export const selectProvider = async (page: Page, provider: string) => {
if (await providerTrigger.isVisible().catch(() => false)) {
await providerTrigger.click();
} else {
const providerLabel = page.getByText("PROVIDER", { exact: true }).first();
const providerLabel = page.getByText('PROVIDER', { exact: true }).first();
await expect(providerLabel).toBeVisible();
await providerLabel.click();
}
const dialog = page.getByRole("dialog").last();
const searchInput = dialog.getByRole("textbox", { name: /search provider/i }).first();
const dialog = page.getByRole('dialog').last();
const searchInput = dialog.getByRole('textbox', { name: /search provider/i }).first();
if (await searchInput.isVisible().catch(() => false)) {
await searchInput.fill(normalizedProvider);
}
const option = dialog.getByText(new RegExp(`^${escapeRegex(normalizedProvider)}$`, "i")).first();
const option = dialog
.getByText(new RegExp(`^${escapeRegex(normalizedProvider)}$`, 'i'))
.first();
await expect(option).toBeVisible();
await option.click();
};
@@ -535,17 +502,15 @@ export const selectProvider = async (page: Page, provider: string) => {
export const selectModel = async (page: Page, model: string) => {
const normalizedModel = model.trim();
if (!normalizedModel) {
throw new Error("Model must be a non-empty string.");
throw new Error('Model must be a non-empty string.');
}
const modelTrigger = page
.locator(
'[data-testid="agent-model-selector"]:visible, [data-testid="draft-model-select"]:visible',
)
.locator('[data-testid="agent-model-selector"]:visible, [data-testid="draft-model-select"]:visible')
.first();
if (
await modelTrigger
.getByText(new RegExp(`^${escapeRegex(normalizedModel)}$`, "i"))
.getByText(new RegExp(`^${escapeRegex(normalizedModel)}$`, 'i'))
.first()
.isVisible()
.catch(() => false)
@@ -556,21 +521,21 @@ export const selectModel = async (page: Page, model: string) => {
if (await modelTrigger.isVisible().catch(() => false)) {
await modelTrigger.click();
} else {
const modelLabel = page.getByText("MODEL", { exact: true }).first();
const modelLabel = page.getByText('MODEL', { exact: true }).first();
await expect(modelLabel).toBeVisible();
await modelLabel.click();
}
// Wait for the model dropdown to open
const searchInput = page.getByRole("textbox", { name: /search model/i });
const searchInput = page.getByRole('textbox', { name: /search model/i });
await expect(searchInput).toBeVisible({ timeout: 10000 });
// Type to search/filter models
await searchInput.fill(normalizedModel);
const dialog = page.getByRole("dialog");
const dialog = page.getByRole('dialog');
const exactOption = dialog
.getByText(new RegExp(`^${escapeRegex(normalizedModel)}$`, "i"))
.getByText(new RegExp(`^${escapeRegex(normalizedModel)}$`, 'i'))
.first();
const exactVisible = await exactOption.isVisible().catch(() => false);
if (exactVisible) {
@@ -578,39 +543,39 @@ export const selectModel = async (page: Page, model: string) => {
} else {
// Modern labels include version suffixes (for example "Haiku 4.5"), so
// select the first filtered result using keyboard confirm.
await searchInput.press("Enter");
await searchInput.press('Enter');
}
// Wait for dropdown to close
if (await searchInput.isVisible().catch(() => false)) {
await page.keyboard.press("Escape").catch(() => undefined);
await page.keyboard.press('Escape').catch(() => undefined);
}
await expect(searchInput).not.toBeVisible({ timeout: 5000 });
};
export const selectMode = async (page: Page, mode: string) => {
const modeTrigger = page
.locator(
'[data-testid="agent-mode-selector"]:visible, [data-testid="draft-mode-select"]:visible',
)
.locator('[data-testid="agent-mode-selector"]:visible, [data-testid="draft-mode-select"]:visible')
.first();
if (await modeTrigger.isVisible().catch(() => false)) {
await modeTrigger.click();
} else {
const modeLabel = page.getByText("MODE", { exact: true }).first();
const modeLabel = page.getByText('MODE', { exact: true }).first();
await expect(modeLabel).toBeVisible();
await modeLabel.click();
}
// Wait for the mode dropdown to open
const searchInput = page.getByRole("textbox", { name: /search mode/i });
const searchInput = page.getByRole('textbox', { name: /search mode/i });
await expect(searchInput).toBeVisible({ timeout: 10000 });
// Type to filter modes
await searchInput.fill(mode);
const dialog = page.getByRole("dialog");
const option = dialog.getByText(new RegExp(`^${escapeRegex(mode)}$`, "i")).first();
const dialog = page.getByRole('dialog');
const option = dialog
.getByText(new RegExp(`^${escapeRegex(mode)}$`, 'i'))
.first();
await expect(option).toBeVisible();
await option.click({ force: true });
@@ -640,7 +605,7 @@ export const createAgentWithConfig = async (page: Page, config: AgentConfig) =>
export const createAgentInRepo = async (
page: Page,
config: Pick<AgentConfig, "directory" | "prompt">,
config: Pick<AgentConfig, 'directory' | 'prompt'>
) => {
await gotoHome(page);
await ensureHostSelected(page);
@@ -649,25 +614,25 @@ export const createAgentInRepo = async (
};
export const waitForPermissionPrompt = async (page: Page, timeout = 30000) => {
const promptText = page.getByTestId("permission-request-question").first();
const promptText = page.getByTestId('permission-request-question').first();
await expect(promptText).toBeVisible({ timeout });
};
export const allowPermission = async (page: Page) => {
const acceptButton = page.getByTestId("permission-request-accept").first();
const acceptButton = page.getByTestId('permission-request-accept').first();
await expect(acceptButton).toBeVisible({ timeout: 5000 });
await acceptButton.click();
};
export const denyPermission = async (page: Page) => {
const denyButton = page.getByTestId("permission-request-deny").first();
const denyButton = page.getByTestId('permission-request-deny').first();
await expect(denyButton).toBeVisible({ timeout: 5000 });
await denyButton.click();
};
export async function waitForAgentFinishUI(page: Page, timeout = 30000) {
// Wait for the stop button to disappear
const stopButton = page.getByRole("button", { name: /stop|cancel/i });
const stopButton = page.getByRole('button', { name: /stop|cancel/i });
// First, let's debug what's happening - wait a bit to see the state
await page.waitForTimeout(2000);
@@ -684,11 +649,9 @@ export async function waitForAgentFinishUI(page: Page, timeout = 30000) {
const toolCallResult = page.getByText(/permission.*denied|denied|blocked/i);
// Wait for the tool call result to appear
await expect(toolCallResult)
.toBeVisible({ timeout: 10000 })
.catch(() => {
// If no specific message, just wait for the button to disappear
});
await expect(toolCallResult).toBeVisible({ timeout: 10000 }).catch(() => {
// If no specific message, just wait for the button to disappear
});
// Now wait for the stop button to disappear
await expect(stopButton).not.toBeVisible({ timeout });
-266
View File
@@ -1,266 +0,0 @@
import { randomUUID } from "node:crypto";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { expect, type Page } from "@playwright/test";
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
import { waitForWorkspaceTabsVisible } from "./workspace-tabs";
import {
buildHostAgentDetailRoute,
buildHostSessionsRoute,
buildHostWorkspaceRoute,
} from "@/utils/host-routes";
export type ArchiveTabAgent = {
id: string;
title: string;
cwd: string;
};
type ArchiveTabDaemonClient = {
connect(): Promise<void>;
close(): Promise<void>;
createAgent(options: {
provider: string;
model: string;
thinkingOptionId?: string;
modeId: string;
cwd: string;
title: string;
initialPrompt: string;
}): Promise<{ id: string }>;
archiveAgent(agentId: string): Promise<{ archivedAt: string }>;
waitForFinish(agentId: string, timeout?: number): Promise<{ status: string }>;
};
function getDaemonPort(): string {
const daemonPort = process.env.E2E_DAEMON_PORT;
if (!daemonPort) {
throw new Error("E2E_DAEMON_PORT is not set.");
}
if (daemonPort === "6767") {
throw new Error("E2E_DAEMON_PORT must not point at the developer daemon.");
}
return daemonPort;
}
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
function getDaemonWsUrl(): string {
return `ws://127.0.0.1:${getDaemonPort()}/ws`;
}
function buildSeededStoragePayload() {
const nowIso = new Date().toISOString();
return {
daemon: buildSeededHost({
serverId: getServerId(),
endpoint: `127.0.0.1:${getDaemonPort()}`,
nowIso,
}),
preferences: buildCreateAgentPreferences(getServerId()),
};
}
type ArchiveTabDaemonClientConfig = {
url: string;
clientId: string;
clientType: "cli";
webSocketFactory?: NodeWebSocketFactory;
};
async function loadDaemonClientConstructor(): Promise<
new (
config: ArchiveTabDaemonClientConfig,
) => ArchiveTabDaemonClient
> {
const repoRoot = path.resolve(__dirname, "../../../../");
const moduleUrl = pathToFileURL(
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
).href;
const mod = (await import(moduleUrl)) as {
DaemonClient: new (config: ArchiveTabDaemonClientConfig) => ArchiveTabDaemonClient;
};
return mod.DaemonClient;
}
export async function connectArchiveTabDaemonClient(): Promise<ArchiveTabDaemonClient> {
const DaemonClient = await loadDaemonClientConstructor();
const webSocketFactory = createNodeWebSocketFactory();
const client = new DaemonClient({
url: getDaemonWsUrl(),
clientId: `app-e2e-archive-tab-${randomUUID()}`,
clientType: "cli",
webSocketFactory,
});
await client.connect();
return client;
}
export async function createIdleAgent(
client: ArchiveTabDaemonClient,
input: { cwd: string; title: string },
): Promise<ArchiveTabAgent> {
const created = await client.createAgent({
provider: "opencode",
model: "opencode/gpt-5-nano",
modeId: "default",
cwd: input.cwd,
title: input.title,
initialPrompt: "Reply with exactly READY.",
});
const finished = await client.waitForFinish(created.id, 120_000);
if (finished.status !== "idle") {
throw new Error(
`Expected agent ${created.id} to become idle, got ${finished.status}. Error: ${JSON.stringify((finished as Record<string, unknown>).error ?? "unknown")}`,
);
}
return {
id: created.id,
title: input.title,
cwd: input.cwd,
};
}
export async function archiveAgentFromDaemon(
client: ArchiveTabDaemonClient,
agentId: string,
): Promise<void> {
await client.archiveAgent(agentId);
}
export async function primeAdditionalPage(page: Page): Promise<void> {
const seedNonce = randomUUID();
const { daemon, preferences } = buildSeededStoragePayload();
await page.route(/:(6767)\b/, (route) => route.abort());
await page.routeWebSocket(/:(6767)\b/, async (ws) => {
await ws.close({ code: 1008, reason: "Blocked connection to localhost:6767 during e2e." });
});
await page.addInitScript(
({ daemon, preferences, seedNonce }) => {
const disableOnceKey = "@paseo:e2e-disable-default-seed-once";
const disableValue = localStorage.getItem(disableOnceKey);
if (disableValue) {
localStorage.removeItem(disableOnceKey);
if (disableValue === seedNonce) {
return;
}
}
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:e2e-seed-nonce", seedNonce);
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
localStorage.removeItem("@paseo:settings");
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
},
{ daemon, preferences, seedNonce },
);
await page.goto("/");
}
export async function resetSeededPageState(page: Page): Promise<void> {
const { daemon, preferences } = buildSeededStoragePayload();
await page.goto("/");
await page.evaluate(
({ daemon, preferences }) => {
localStorage.clear();
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
localStorage.removeItem("@paseo:settings");
},
{ daemon, preferences },
);
await page.goto("/");
}
export async function openWorkspaceWithAgents(
page: Page,
agents: [ArchiveTabAgent, ArchiveTabAgent],
): Promise<void> {
const serverId = getServerId();
for (const agent of agents) {
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.cwd));
await waitForWorkspaceTabsVisible(page);
await expectWorkspaceTabVisible(page, agent.id);
}
}
export async function expectWorkspaceTabVisible(page: Page, agentId: string): Promise<void> {
await expect(page.getByTestId(`workspace-tab-agent_${agentId}`).first()).toBeVisible({
timeout: 30_000,
});
}
export async function expectWorkspaceTabHidden(page: Page, agentId: string): Promise<void> {
await expect(page.getByTestId(`workspace-tab-agent_${agentId}`)).toHaveCount(0, {
timeout: 30_000,
});
}
export async function expectWorkspaceArchiveOutcome(
page: Page,
input: { archivedAgentId: string; survivingAgentId: string },
): Promise<void> {
await expectWorkspaceTabHidden(page, input.archivedAgentId);
await expectWorkspaceTabVisible(page, input.survivingAgentId);
}
export async function reloadWorkspace(page: Page, workspaceId: string): Promise<void> {
const serverId = getServerId();
await page.goto(buildHostWorkspaceRoute(serverId, workspaceId));
await waitForWorkspaceTabsVisible(page);
}
export async function openSessions(page: Page): Promise<void> {
const sessionsButton = page.getByTestId("sidebar-sessions");
await expect(sessionsButton).toBeVisible({ timeout: 30_000 });
await sessionsButton.click();
await expect(page).toHaveURL(new RegExp(`${buildHostSessionsRoute(getServerId())}$`), {
timeout: 30_000,
});
await expect(page.getByText("Sessions", { exact: true }).last()).toBeVisible({
timeout: 30_000,
});
}
function getSessionRowByTitle(page: Page, title: string) {
return page.locator('[data-testid^="agent-row-"]').filter({ hasText: title }).first();
}
export async function expectSessionRowVisible(page: Page, title: string): Promise<void> {
await expect(getSessionRowByTitle(page, title)).toBeVisible({ timeout: 30_000 });
}
export async function expectSessionRowArchived(page: Page, title: string): Promise<void> {
await expect(getSessionRowByTitle(page, title)).toContainText("Archived", { timeout: 30_000 });
}
export async function archiveAgentFromSessions(
page: Page,
input: { agentId: string; title: string },
): Promise<void> {
const row = getSessionRowByTitle(page, input.title);
await expect(row).toBeVisible({ timeout: 30_000 });
const box = await row.boundingBox();
if (!box) {
throw new Error(`Could not read bounding box for session row ${input.agentId}.`);
}
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.waitForTimeout(900);
await page.mouse.up();
const archiveButton = page.getByTestId("agent-action-archive").first();
await expect(archiveButton).toBeVisible({ timeout: 10_000 });
await archiveButton.click();
await expectSessionRowArchived(page, input.title);
}
+1 -1
View File
@@ -2,7 +2,7 @@ export const TEST_HOST_LABEL = "localhost";
export const TEST_PROVIDER_PREFERENCES = {
claude: { model: "haiku" },
codex: { model: "gpt-5.4-mini", thinkingOptionId: "low" },
codex: { model: "gpt-5.1-codex-mini", thinkingOptionId: "low" },
} as const;
export function buildDirectTcpConnection(endpoint: string) {
-231
View File
@@ -1,231 +0,0 @@
import { randomUUID } from "node:crypto";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { expect, type Page } from "@playwright/test";
import type { DaemonClient as ServerDaemonClient } from "@server/client/daemon-client";
import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes";
import { expectWorkspaceHeader, workspaceLabelFromPath } from "./workspace-ui";
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
type NewWorkspaceDaemonClient = Pick<
ServerDaemonClient,
| "archivePaseoWorktree"
| "archiveWorkspace"
| "close"
| "connect"
| "createPaseoWorktree"
| "openProject"
>;
type NewWorkspaceDaemonClientConfig = {
url: string;
clientId: string;
clientType: "cli";
webSocketFactory?: NodeWebSocketFactory;
};
type OpenProjectPayload = Awaited<ReturnType<NewWorkspaceDaemonClient["openProject"]>>;
export type OpenedProject = {
workspaceId: string;
projectKey: string;
projectDisplayName: string;
workspaceName: string;
};
function getDaemonPort(): string {
const daemonPort = process.env.E2E_DAEMON_PORT;
if (!daemonPort) {
throw new Error("E2E_DAEMON_PORT is not set.");
}
if (daemonPort === "6767") {
throw new Error("E2E_DAEMON_PORT must not point at the developer daemon.");
}
return daemonPort;
}
function getDaemonWsUrl(): string {
return `ws://127.0.0.1:${getDaemonPort()}/ws`;
}
async function loadDaemonClientConstructor(): Promise<
new (
config: NewWorkspaceDaemonClientConfig,
) => NewWorkspaceDaemonClient
> {
const repoRoot = path.resolve(__dirname, "../../../../");
const moduleUrl = pathToFileURL(
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
).href;
const mod = (await import(moduleUrl)) as {
DaemonClient: new (config: NewWorkspaceDaemonClientConfig) => NewWorkspaceDaemonClient;
};
return mod.DaemonClient;
}
function requireWorkspace(payload: OpenProjectPayload) {
if (payload.error) {
throw new Error(payload.error);
}
if (!payload.workspace) {
throw new Error("openProject returned no workspace.");
}
return payload.workspace;
}
function parseWorkspaceIdFromPageUrl(page: Page, serverId: string): string | null {
const pathname = new URL(page.url()).pathname;
const match = pathname.match(
new RegExp(`^/h/${serverId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/workspace/([^/?#]+)`),
);
if (!match?.[1]) {
return null;
}
return decodeWorkspaceIdFromPathSegment(match[1]);
}
function parseWorkspaceIdFromSidebarRowTestId(
testId: string,
input: { serverId: string; previousWorkspaceId: string },
): string | null {
const prefix = `sidebar-workspace-row-${input.serverId}:`;
if (!testId.startsWith(prefix)) {
return null;
}
const workspaceId = testId.slice(prefix.length).trim();
if (!workspaceId || workspaceId === input.previousWorkspaceId) {
return null;
}
return workspaceId;
}
export async function connectNewWorkspaceDaemonClient(): Promise<NewWorkspaceDaemonClient> {
const DaemonClient = await loadDaemonClientConstructor();
const webSocketFactory = createNodeWebSocketFactory();
const client = new DaemonClient({
url: getDaemonWsUrl(),
clientId: `app-e2e-new-workspace-${randomUUID()}`,
clientType: "cli",
webSocketFactory,
});
await client.connect();
return client;
}
export async function openProjectViaDaemon(
client: NewWorkspaceDaemonClient,
repoPath: string,
): Promise<OpenedProject> {
const workspace = requireWorkspace(await client.openProject(repoPath));
return {
workspaceId: workspace.id,
projectKey: workspace.projectId,
projectDisplayName: workspace.projectDisplayName,
workspaceName: workspace.name,
};
}
export async function archiveWorkspaceFromDaemon(
client: NewWorkspaceDaemonClient,
workspaceId: string,
): Promise<void> {
const payload = await client.archivePaseoWorktree({ worktreePath: workspaceId });
if (payload.error) {
throw new Error(payload.error.message);
}
if (!payload.success) {
throw new Error(`Failed to archive workspace: ${workspaceId}`);
}
}
export async function archiveLocalWorkspaceFromDaemon(
client: NewWorkspaceDaemonClient,
workspaceId: string,
): Promise<void> {
const payload = await client.archiveWorkspace(workspaceId);
if (payload.error) {
throw new Error(payload.error);
}
if (!payload.archivedAt) {
throw new Error(`Failed to archive workspace: ${workspaceId}`);
}
}
export async function createWorktreeViaDaemon(
client: NewWorkspaceDaemonClient,
input: { cwd: string; slug: string },
): Promise<OpenedProject> {
const payload = await client.createPaseoWorktree({
cwd: input.cwd,
worktreeSlug: input.slug,
});
const workspace = requireWorkspace(payload);
return {
workspaceId: workspace.id,
projectKey: workspace.projectId,
projectDisplayName: workspace.projectDisplayName,
workspaceName: workspace.name,
};
}
export async function clickNewWorkspaceButton(
page: Page,
input: { projectKey: string; projectDisplayName: string },
): Promise<void> {
const projectRow = page.getByTestId(`sidebar-project-row-${input.projectKey}`).first();
await expect(projectRow).toBeVisible({ timeout: 30_000 });
await projectRow.hover();
const button = page.getByTestId(`sidebar-project-new-worktree-${input.projectKey}`).first();
await expect(button).toBeVisible({ timeout: 30_000 });
await button.click();
}
export async function assertNewWorkspaceSidebarAndHeader(
page: Page,
input: { serverId: string; previousWorkspaceId: string; projectDisplayName: string },
): Promise<{ workspaceId: string }> {
let workspaceId: string | null = null;
const sidebarWorkspaceRows = page.locator(
`[data-testid^="sidebar-workspace-row-${input.serverId}:"]`,
);
const deadline = Date.now() + 30_000;
while (Date.now() < deadline) {
const sidebarRowTestIds = await sidebarWorkspaceRows.evaluateAll((elements) =>
elements.map((element) => element.getAttribute("data-testid") ?? ""),
);
workspaceId =
sidebarRowTestIds
.map((testId) => parseWorkspaceIdFromSidebarRowTestId(testId, input))
.find((id) => id !== null) ?? null;
if (workspaceId) {
break;
}
await page.waitForTimeout(250);
}
if (!workspaceId || workspaceId === input.previousWorkspaceId) {
const sidebarWorkspaceRowIds = await sidebarWorkspaceRows.evaluateAll((elements) =>
elements.map((element) => element.getAttribute("data-testid") ?? "<missing-testid>"),
);
throw new Error(
[
"Expected a newly created workspace to load.",
`Current URL: ${page.url()}`,
`Sidebar rows: ${sidebarWorkspaceRowIds.join(", ") || "<none>"}`,
].join("\n"),
);
}
const createdWorkspaceRow = page.getByTestId(
`sidebar-workspace-row-${input.serverId}:${workspaceId}`,
);
await expect(createdWorkspaceRow.first()).toBeVisible({ timeout: 30_000 });
await expectWorkspaceHeader(page, {
title: workspaceLabelFromPath(workspaceId),
subtitle: input.projectDisplayName,
});
return { workspaceId };
}
@@ -1,27 +0,0 @@
import WebSocket from "ws";
type WebSocketLike = {
readyState: number;
send: (data: string | Uint8Array | ArrayBuffer) => void;
close: (code?: number, reason?: string) => void;
binaryType?: string;
on?: (event: string, listener: (...args: any[]) => void) => void;
off?: (event: string, listener: (...args: any[]) => void) => void;
removeListener?: (event: string, listener: (...args: any[]) => void) => void;
addEventListener?: (event: string, listener: (event: any) => void) => void;
removeEventListener?: (event: string, listener: (event: any) => void) => void;
onopen?: ((event: any) => void) | null;
onclose?: ((event: any) => void) | null;
onerror?: ((event: any) => void) | null;
onmessage?: ((event: any) => void) | null;
};
export type NodeWebSocketFactory = (
url: string,
options?: { headers?: Record<string, string> },
) => WebSocketLike;
export function createNodeWebSocketFactory(): NodeWebSocketFactory {
return (url: string, options?: { headers?: Record<string, string> }) =>
new WebSocket(url, { headers: options?.headers }) as unknown as WebSocketLike;
}
-237
View File
@@ -1,237 +0,0 @@
import type { Page } from "@playwright/test";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { randomUUID } from "node:crypto";
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
export type TerminalPerfDaemonClient = {
connect(): Promise<void>;
close(): Promise<void>;
createTerminal(
cwd: string,
name?: string,
): Promise<{
terminal: { id: string; name: string; cwd: string } | null;
error: string | null;
}>;
subscribeTerminal(
terminalId: string,
): Promise<{ terminalId: string; slot: number; error: null } | { error: string }>;
sendTerminalInput(
terminalId: string,
message: { type: "input"; data: string } | { type: "resize"; rows: number; cols: number },
): void;
onTerminalStreamEvent(
handler: (event: { terminalId: string; type: string; data?: Uint8Array }) => void,
): () => void;
killTerminal(terminalId: string): Promise<{ error: string | null }>;
};
function getDaemonWsUrl(): string {
const daemonPort = process.env.E2E_DAEMON_PORT;
if (!daemonPort) {
throw new Error("E2E_DAEMON_PORT is not set.");
}
return `ws://127.0.0.1:${daemonPort}/ws`;
}
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
type TerminalPerfDaemonClientConfig = {
url: string;
clientId: string;
clientType: "cli";
webSocketFactory?: NodeWebSocketFactory;
};
async function loadDaemonClientConstructor(): Promise<
new (
config: TerminalPerfDaemonClientConfig,
) => TerminalPerfDaemonClient
> {
const repoRoot = path.resolve(__dirname, "../../../../");
const moduleUrl = pathToFileURL(
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
).href;
const mod = (await import(moduleUrl)) as {
DaemonClient: new (config: TerminalPerfDaemonClientConfig) => TerminalPerfDaemonClient;
};
return mod.DaemonClient;
}
export async function connectTerminalClient(): Promise<TerminalPerfDaemonClient> {
const DaemonClient = await loadDaemonClientConstructor();
const webSocketFactory = createNodeWebSocketFactory();
const client = new DaemonClient({
url: getDaemonWsUrl(),
clientId: `terminal-perf-${randomUUID()}`,
clientType: "cli",
webSocketFactory,
});
await client.connect();
return client;
}
export function buildTerminalWorkspaceUrl(cwd: string, terminalId: string): string {
const serverId = getServerId();
const route = buildHostWorkspaceRoute(serverId, cwd);
return `${route}?open=${encodeURIComponent(`terminal:${terminalId}`)}`;
}
function buildWorkspaceUrl(cwd: string): string {
return buildHostWorkspaceRoute(getServerId(), cwd);
}
export async function getTerminalBufferText(page: Page): Promise<string> {
return page.evaluate(() => {
const term = (window as any).__paseoTerminal;
if (!term) {
return "";
}
const buf = term.buffer.active;
const lines: string[] = [];
for (let i = 0; i < buf.length; i++) {
const line = buf.getLine(i);
if (line) {
lines.push(line.translateToString(true));
}
}
return lines.join("\n");
});
}
export async function waitForTerminalContent(
page: Page,
predicate: (text: string) => boolean,
timeout: number,
): Promise<void> {
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
const text = await getTerminalBufferText(page);
if (predicate(text)) {
return;
}
await page.waitForTimeout(50);
}
throw new Error(`Terminal content did not match predicate within ${timeout}ms`);
}
export async function navigateToTerminal(
page: Page,
input: { cwd: string; terminalId: string },
): Promise<void> {
// Boot the app at the workspace route directly.
// The fixtures.ts beforeEach addInitScript seeds localStorage on every navigation,
// so the daemon registry is already configured when the app starts.
const workspaceRoute = buildTerminalWorkspaceUrl(input.cwd, input.terminalId);
await page.goto(workspaceRoute);
// The workspace layout consumes `?open=...`, returns null during the effect,
// then replaces the URL with the clean workspace route after preparing the tab.
const cleanWorkspaceRoute = buildWorkspaceUrl(input.cwd);
await page.waitForURL(
(url) => url.pathname === cleanWorkspaceRoute && !url.searchParams.has("open"),
{ timeout: 15_000 },
);
// Wait for daemon connection (sidebar shows host label)
await page
.getByText("localhost", { exact: true })
.first()
.waitFor({ state: "visible", timeout: 15_000 });
// The open intent should have prepared and focused the exact pre-created terminal tab.
const terminalTab = page.locator(`[data-testid="workspace-tab-terminal_${input.terminalId}"]`);
await terminalTab.waitFor({ state: "visible", timeout: 15_000 });
await terminalTab.click();
const terminalSurface = page.locator('[data-testid="terminal-surface"]');
await terminalSurface.waitFor({ state: "visible", timeout: 15_000 });
// Wait for loading overlay to disappear (terminal attached)
await page
.locator('[data-testid="terminal-attach-loading"]')
.waitFor({ state: "hidden", timeout: 10_000 })
.catch(() => {
// overlay may never appear if attachment is instant
});
await terminalSurface.scrollIntoViewIfNeeded();
await terminalSurface.click();
}
export async function setupDeterministicPrompt(page: Page, sentinel?: string): Promise<void> {
const tag = sentinel ?? `READY_${Date.now()}`;
const terminal = page.locator('[data-testid="terminal-surface"]');
await terminal.pressSequentially(`echo ${tag}\n`, { delay: 0 });
await waitForTerminalContent(page, (text) => text.includes(tag), 10_000);
await terminal.pressSequentially("export PS1='$ '\n", { delay: 0 });
await page.waitForTimeout(300);
}
export type LatencySample = {
char: string;
latencyMs: number;
};
/**
* Measures keystroke echo round-trip latency.
*
* Starts a high-resolution timer on the browser keydown event (capture phase)
* and stops it when xterm.js finishes parsing the echoed write. This measures
* the full path: keydown → WebSocket → daemon PTY echo → WebSocket → xterm render.
*/
export async function measureKeystrokeLatency(page: Page, char: string): Promise<number> {
await page.evaluate(() => {
const term = (window as any).__paseoTerminal;
if (!term) {
throw new Error("__paseoTerminal not available");
}
const state = ((window as any).__perfKeystroke = {
promise: null as Promise<number> | null,
});
state.promise = new Promise<number>((resolve, reject) => {
const timeout = setTimeout(() => {
document.removeEventListener("keydown", onKeyDown, true);
reject(new Error("keystroke echo timeout (5s)"));
}, 5000);
function onKeyDown() {
document.removeEventListener("keydown", onKeyDown, true);
const start = performance.now();
const disposable = term.onWriteParsed(() => {
clearTimeout(timeout);
disposable.dispose();
resolve(performance.now() - start);
});
}
document.addEventListener("keydown", onKeyDown, true);
});
});
await page.keyboard.press(char);
return page.evaluate(() => (window as any).__perfKeystroke.promise);
}
export function computePercentile(samples: number[], p: number): number {
const sorted = [...samples].sort((a, b) => a - b);
const index = Math.ceil((p / 100) * sorted.length) - 1;
return sorted[Math.max(0, index)];
}
export function round2(value: number): number {
return Math.round(value * 100) / 100;
}
+1 -1
View File
@@ -38,7 +38,7 @@ export async function ensureWorkspaceAgentPaneVisible(page: Page): Promise<void>
export async function sampleWorkspaceTabIds(
page: Page,
options: { durationMs?: number; intervalMs?: number } = {},
options: { durationMs?: number; intervalMs?: number } = {}
): Promise<string[][]> {
const durationMs = options.durationMs ?? 2_500;
const intervalMs = options.intervalMs ?? 50;
+17 -17
View File
@@ -1,38 +1,38 @@
import { expect, type Page } from "@playwright/test";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { gotoHome } from "./app";
import { expect, type Page } from '@playwright/test';
import { buildHostWorkspaceRoute } from '@/utils/host-routes';
import { gotoHome } from './app';
export async function openNewAgentComposer(page: Page): Promise<void> {
await gotoHome(page);
}
export function workspaceLabelFromPath(value: string): string {
const normalized = value.replace(/\\/g, "/").replace(/\/+$/, "");
const parts = normalized.split("/").filter(Boolean);
const normalized = value.replace(/\\/g, '/').replace(/\/+$/, '');
const parts = normalized.split('/').filter(Boolean);
return parts[parts.length - 1] ?? normalized;
}
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function candidateWorkspaceIds(inputPath: string): string[] {
const trimmed = inputPath.replace(/\/+$/, "");
const trimmed = inputPath.replace(/\/+$/, '');
const candidates = new Set<string>([trimmed]);
if (trimmed.startsWith("/var/")) {
if (trimmed.startsWith('/var/')) {
candidates.add(`/private${trimmed}`);
}
if (trimmed.startsWith("/private/var/")) {
candidates.add(trimmed.replace(/^\/private/, ""));
if (trimmed.startsWith('/private/var/')) {
candidates.add(trimmed.replace(/^\/private/, ''));
}
return Array.from(candidates);
}
function workspaceRowLocator(page: Page, serverId: string, workspacePath: string) {
const ids = candidateWorkspaceIds(workspacePath).map(
(id) => `[data-testid="sidebar-workspace-row-${serverId}:${id}"]`,
(id) => `[data-testid="sidebar-workspace-row-${serverId}:${id}"]`
);
return page.locator(ids.join(",")).first();
return page.locator(ids.join(',')).first();
}
export async function switchWorkspaceViaSidebar(input: {
@@ -52,10 +52,10 @@ export async function switchWorkspaceViaSidebar(input: {
export async function expectWorkspaceHeader(
page: Page,
input: { title: string; subtitle: string },
input: { title: string; subtitle: string }
): Promise<void> {
const titleLocator = page.getByTestId("workspace-header-title");
const subtitleLocator = page.getByTestId("workspace-header-subtitle");
const titleLocator = page.getByTestId('workspace-header-title');
const subtitleLocator = page.getByTestId('workspace-header-subtitle');
await expect(titleLocator.first()).toHaveText(input.title, {
timeout: 30_000,
@@ -66,9 +66,9 @@ export async function expectWorkspaceHeader(
}
export async function seedWorkspaceActivity(page: Page, marker: string): Promise<void> {
const input = page.getByRole("textbox", { name: "Message agent..." });
const input = page.getByRole('textbox', { name: 'Message agent...' });
await expect(input).toBeEditable({ timeout: 30_000 });
await input.fill(marker);
await input.press("Enter");
await input.press('Enter');
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
}
+18 -18
View File
@@ -1,7 +1,7 @@
import { execSync } from "node:child_process";
import { mkdtemp, writeFile, rm, mkdir } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { execSync } from 'node:child_process';
import { mkdtemp, writeFile, rm, mkdir } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
type TempRepo = {
path: string;
@@ -9,29 +9,29 @@ type TempRepo = {
};
export const createTempGitRepo = async (
prefix = "paseo-e2e-",
options?: { withRemote?: boolean },
prefix = 'paseo-e2e-',
options?: { withRemote?: boolean }
): Promise<TempRepo> => {
// Keep E2E repo paths short so terminal prompt + typed commands stay visible without zsh clipping.
const tempRoot = process.platform === "win32" ? tmpdir() : "/tmp";
const tempRoot = process.platform === 'win32' ? tmpdir() : '/tmp';
const repoPath = await mkdtemp(path.join(tempRoot, prefix));
const withRemote = options?.withRemote ?? false;
execSync("git init -b main", { cwd: repoPath, stdio: "ignore" });
execSync('git config user.email "e2e@paseo.test"', { cwd: repoPath, stdio: "ignore" });
execSync('git config user.name "Paseo E2E"', { cwd: repoPath, stdio: "ignore" });
execSync("git config commit.gpgsign false", { cwd: repoPath, stdio: "ignore" });
await writeFile(path.join(repoPath, "README.md"), "# Temp Repo\n");
execSync("git add README.md", { cwd: repoPath, stdio: "ignore" });
execSync('git commit -m "Initial commit"', { cwd: repoPath, stdio: "ignore" });
execSync('git init -b main', { cwd: repoPath, stdio: 'ignore' });
execSync('git config user.email "e2e@paseo.test"', { cwd: repoPath, stdio: 'ignore' });
execSync('git config user.name "Paseo E2E"', { cwd: repoPath, stdio: 'ignore' });
execSync('git config commit.gpgsign false', { cwd: repoPath, stdio: 'ignore' });
await writeFile(path.join(repoPath, 'README.md'), '# Temp Repo\n');
execSync('git add README.md', { cwd: repoPath, stdio: 'ignore' });
execSync('git commit -m "Initial commit"', { cwd: repoPath, stdio: 'ignore' });
if (withRemote) {
// Deterministic local remote to avoid relying on external auth/network in e2e.
const remoteDir = path.join(repoPath, "remote.git");
const remoteDir = path.join(repoPath, 'remote.git');
await mkdir(remoteDir, { recursive: true });
execSync(`git init --bare -b main ${remoteDir}`, { cwd: repoPath, stdio: "ignore" });
execSync(`git remote add origin ${remoteDir}`, { cwd: repoPath, stdio: "ignore" });
execSync("git push -u origin main", { cwd: repoPath, stdio: "ignore" });
execSync(`git init --bare -b main ${remoteDir}`, { cwd: repoPath, stdio: 'ignore' });
execSync(`git remote add origin ${remoteDir}`, { cwd: repoPath, stdio: 'ignore' });
execSync('git push -u origin main', { cwd: repoPath, stdio: 'ignore' });
}
return {
-153
View File
@@ -1,153 +0,0 @@
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { expect, test } from "./fixtures";
import {
archiveWorkspaceFromDaemon,
archiveLocalWorkspaceFromDaemon,
assertNewWorkspaceSidebarAndHeader,
clickNewWorkspaceButton,
connectNewWorkspaceDaemonClient,
createWorktreeViaDaemon,
openProjectViaDaemon,
} from "./helpers/new-workspace";
import { createTempGitRepo } from "./helpers/workspace";
import {
expectWorkspaceHeader,
switchWorkspaceViaSidebar,
workspaceLabelFromPath,
} from "./helpers/workspace-ui";
test.describe("New workspace flow", () => {
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
const localWorkspaceIds = new Set<string>();
const createdWorktreeIds = new Set<string>();
test.describe.configure({ timeout: 120_000 });
test.beforeEach(async () => {
client = await connectNewWorkspaceDaemonClient();
});
test.afterEach(async () => {
if (client) {
for (const workspaceId of createdWorktreeIds) {
await archiveWorkspaceFromDaemon(client, workspaceId).catch(() => undefined);
}
for (const workspaceId of localWorkspaceIds) {
await archiveLocalWorkspaceFromDaemon(client, workspaceId).catch(() => undefined);
}
}
createdWorktreeIds.clear();
localWorkspaceIds.clear();
await client?.close().catch(() => undefined);
});
test("sidebar workspace navigation updates URL and header", async ({ page }) => {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
const firstRepo = await createTempGitRepo("workspace-nav-a-");
const secondRepo = await createTempGitRepo("workspace-nav-b-");
try {
const firstWorkspace = await openProjectViaDaemon(client, firstRepo.path);
const secondWorkspace = await openProjectViaDaemon(client, secondRepo.path);
localWorkspaceIds.add(firstWorkspace.workspaceId);
localWorkspaceIds.add(secondWorkspace.workspaceId);
await page.goto(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId));
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId));
await expectWorkspaceHeader(page, {
title: firstWorkspace.workspaceName,
subtitle: workspaceLabelFromPath(firstRepo.path),
});
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: secondWorkspace.workspaceId,
});
await expectWorkspaceHeader(page, {
title: secondWorkspace.workspaceName,
subtitle: workspaceLabelFromPath(secondRepo.path),
});
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: firstWorkspace.workspaceId,
});
await expectWorkspaceHeader(page, {
title: firstWorkspace.workspaceName,
subtitle: workspaceLabelFromPath(firstRepo.path),
});
} finally {
await secondRepo.cleanup();
await firstRepo.cleanup();
}
});
test("clicking new workspace redirects, renders header, shows sidebar row, and keeps one draft tab", async ({
page,
}) => {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
const tempRepo = await createTempGitRepo("new-workspace-");
try {
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
localWorkspaceIds.add(openedProject.workspaceId);
await page.goto(buildHostWorkspaceRoute(serverId, openedProject.workspaceId));
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, openedProject.workspaceId));
await expectWorkspaceHeader(page, {
title: openedProject.workspaceName,
subtitle: workspaceLabelFromPath(tempRepo.path),
});
await clickNewWorkspaceButton(page, {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
serverId,
previousWorkspaceId: openedProject.workspaceId,
projectDisplayName: openedProject.projectDisplayName,
});
createdWorktreeIds.add(createdWorkspace.workspaceId);
expect(createdWorkspace.workspaceId).not.toBe(openedProject.workspaceId);
await expect(page).toHaveURL(
buildHostWorkspaceRoute(serverId, createdWorkspace.workspaceId),
{
timeout: 30_000,
},
);
const createdWorkspaceRow = page.getByTestId(
`sidebar-workspace-row-${serverId}:${createdWorkspace.workspaceId}`,
);
await expect(createdWorkspaceRow).toBeVisible({ timeout: 30_000 });
await expectWorkspaceHeader(page, {
title: workspaceLabelFromPath(createdWorkspace.workspaceId),
subtitle: openedProject.projectDisplayName,
});
const draftTabs = page.locator('[data-testid^="workspace-tab-"]').filter({
has: page.getByText("New Agent", { exact: true }),
});
await expect(draftTabs).toHaveCount(1, { timeout: 30_000 });
const composer = page.getByRole("textbox", { name: "Message agent..." });
await expect(composer).toBeEditable({ timeout: 30_000 });
} finally {
await tempRepo.cleanup();
}
});
});
@@ -1,165 +0,0 @@
import { test, expect } from "./fixtures";
import { createTempGitRepo } from "./helpers/workspace";
import {
connectTerminalClient,
navigateToTerminal,
setupDeterministicPrompt,
waitForTerminalContent,
measureKeystrokeLatency,
computePercentile,
round2,
type TerminalPerfDaemonClient,
type LatencySample,
} from "./helpers/terminal-perf";
const LINE_COUNT = 50_000;
const THROUGHPUT_BUDGET_MS = 30_000;
const KEYSTROKE_SAMPLE_COUNT = 20;
const KEYSTROKE_P95_BUDGET_MS = 150;
test.describe("Terminal wire performance", () => {
let client: TerminalPerfDaemonClient;
let tempRepo: { path: string; cleanup: () => Promise<void> };
test.beforeAll(async () => {
tempRepo = await createTempGitRepo("perf-");
client = await connectTerminalClient();
});
test.afterAll(async () => {
if (client) {
await client.close();
}
if (tempRepo) {
await tempRepo.cleanup();
}
});
test("throughput: bulk terminal output renders within budget", async ({ page }, testInfo) => {
test.setTimeout(90_000);
const result = await client.createTerminal(tempRepo.path, "throughput");
if (!result.terminal) {
throw new Error(`Failed to create terminal: ${result.error}`);
}
const terminalId = result.terminal.id;
try {
await navigateToTerminal(page, { cwd: tempRepo.path, terminalId });
await setupDeterministicPrompt(page);
const sentinel = `PERF_DONE_${Date.now()}`;
const terminal = page.locator('[data-testid="terminal-surface"]');
const startMs = Date.now();
await terminal.pressSequentially(`seq 1 ${LINE_COUNT}; echo ${sentinel}\n`, { delay: 0 });
await waitForTerminalContent(
page,
(text) => text.includes(sentinel),
THROUGHPUT_BUDGET_MS + 15_000,
);
const elapsedMs = Date.now() - startMs;
// seq 1 N outputs each number on its own line
const estimatedBytes = Array.from(
{ length: LINE_COUNT },
(_, i) => String(i + 1).length + 1,
).reduce((a, b) => a + b, 0);
const throughputMBps = estimatedBytes / (1024 * 1024) / (elapsedMs / 1000);
const report = {
lineCount: LINE_COUNT,
estimatedBytes,
elapsedMs,
throughputMBps: round2(throughputMBps),
};
await testInfo.attach("throughput-report", {
body: JSON.stringify(report, null, 2),
contentType: "application/json",
});
console.log(
`[perf] Throughput: ${report.throughputMBps} MB/s — ${LINE_COUNT} lines in ${elapsedMs}ms`,
);
expect(
elapsedMs,
`${LINE_COUNT} lines should render within ${THROUGHPUT_BUDGET_MS}ms`,
).toBeLessThan(THROUGHPUT_BUDGET_MS);
} finally {
await client.killTerminal(terminalId).catch(() => {});
}
});
test("keystroke latency: echo round-trip under budget", async ({ page }, testInfo) => {
test.setTimeout(60_000);
const result = await client.createTerminal(tempRepo.path, "latency");
if (!result.terminal) {
throw new Error(`Failed to create terminal: ${result.error}`);
}
const terminalId = result.terminal.id;
try {
await navigateToTerminal(page, { cwd: tempRepo.path, terminalId });
await setupDeterministicPrompt(page);
// Ensure clean prompt state
const terminal = page.locator('[data-testid="terminal-surface"]');
await terminal.press("Control+c");
await page.waitForTimeout(200);
const samples: LatencySample[] = [];
const chars = "abcdefghijklmnopqrst";
for (let i = 0; i < KEYSTROKE_SAMPLE_COUNT; i++) {
const char = chars[i % chars.length];
const latencyMs = await measureKeystrokeLatency(page, char);
samples.push({ char, latencyMs });
await page.waitForTimeout(50);
}
// Clean up typed characters
await terminal.press("Control+c");
const latencies = samples.map((s) => s.latencyMs);
const p50 = computePercentile(latencies, 50);
const p95 = computePercentile(latencies, 95);
const max = Math.max(...latencies);
const min = Math.min(...latencies);
const avg = latencies.reduce((a, b) => a + b, 0) / latencies.length;
const report = {
sampleCount: KEYSTROKE_SAMPLE_COUNT,
p50Ms: round2(p50),
p95Ms: round2(p95),
maxMs: round2(max),
minMs: round2(min),
avgMs: round2(avg),
samples: samples.map((s) => ({
char: s.char,
latencyMs: round2(s.latencyMs),
})),
};
await testInfo.attach("latency-report", {
body: JSON.stringify(report, null, 2),
contentType: "application/json",
});
console.log(
`[perf] Keystroke latency — p50: ${report.p50Ms}ms, p95: ${report.p95Ms}ms, max: ${report.maxMs}ms`,
);
expect(
p95,
`Keystroke p95 latency should be under ${KEYSTROKE_P95_BUDGET_MS}ms`,
).toBeLessThan(KEYSTROKE_P95_BUDGET_MS);
} finally {
await client.killTerminal(terminalId).catch(() => {});
}
});
});
+3 -3
View File
@@ -1,10 +1,10 @@
// https://docs.expo.dev/guides/using-eslint/
const { defineConfig } = require("eslint/config");
const expoConfig = require("eslint-config-expo/flat");
const { defineConfig } = require('eslint/config');
const expoConfig = require('eslint-config-expo/flat');
module.exports = defineConfig([
expoConfig,
{
ignores: ["dist/*"],
ignores: ['dist/*'],
},
]);
+8 -1
View File
@@ -2,10 +2,17 @@
import { polyfillCrypto } from "./src/polyfills/crypto";
polyfillCrypto();
// Polyfill screen.orientation for WebKitGTK desktop runtimes that lack the API.
// Polyfill screen.orientation for WebKitGTK (Tauri Linux) which lacks the API
import { polyfillScreenOrientation } from "./src/polyfills/screen-orientation";
polyfillScreenOrientation();
// Bridge console.log/warn/error to Tauri's log plugin so JS output appears in app.log
if ((globalThis as { __TAURI__?: unknown }).__TAURI__) {
import("@tauri-apps/plugin-log").then(({ attachConsole }) => {
attachConsole();
});
}
// Configure Unistyles before Expo Router pulls in any components using StyleSheet.
import "./src/styles/unistyles";
import "expo-router/entry";
-2
View File
@@ -1,2 +0,0 @@
# Maestro takeScreenshot artifacts
*.png
@@ -1,31 +0,0 @@
appId: sh.paseo
---
# Ensure sidebar is closed: if sidebar-sessions is visible, close it via swipe left
- runFlow:
when:
visible:
id: "sidebar-sessions"
commands:
- swipe:
direction: LEFT
duration: 300
# Small pause for close animation
- takeScreenshot: 00-sidebar-closed
# Open sidebar via swipe right gesture (the actual path that triggers the bug)
- swipe:
start: "5%,50%"
end: "80%,50%"
duration: 300
# Verify sidebar opened
- assertVisible:
id: "sidebar-sessions"
- takeScreenshot: 01-sidebar-opened
# Close sidebar via swipe left
- swipe:
direction: LEFT
duration: 300
- takeScreenshot: 02-sidebar-closed-again
@@ -1,62 +0,0 @@
#!/usr/bin/env bash
# Verification loop for sidebar theme bug.
#
# Maestro can't toggle iOS appearance, so this script bridges the gap:
# toggle appearance via xcrun simctl, then run Maestro to verify the sidebar
# still works. Runs N iterations to catch intermittent failures.
#
# Usage:
# bash packages/app/maestro/test-sidebar-theme.sh [iterations] [wait_seconds]
# bash packages/app/maestro/test-sidebar-theme.sh 6 1
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
FLOW="$REPO_ROOT/packages/app/maestro/sidebar-theme-repro.yaml"
OUT_DIR="/tmp/sidebar-theme-test-$(date +%s)"
ITERATIONS="${1:-3}"
WAIT_SECS="${2:-1}"
mkdir -p "$OUT_DIR"
echo "=== Sidebar Theme Bug Verification ==="
echo "Output dir: $OUT_DIR"
echo "Iterations: $ITERATIONS, wait after toggle: ${WAIT_SECS}s"
FAILURES=0
for i in $(seq 1 "$ITERATIONS"); do
echo ""
echo "========== Iteration $i / $ITERATIONS =========="
CURRENT=$(xcrun simctl ui booted appearance 2>&1 | tr -d '[:space:]')
echo "Current appearance: $CURRENT"
if [ "$CURRENT" = "dark" ]; then
xcrun simctl ui booted appearance light
echo "Switched to light mode"
else
xcrun simctl ui booted appearance dark
echo "Switched to dark mode"
fi
echo "Waiting ${WAIT_SECS}s..."
sleep "$WAIT_SECS"
ITER_DIR="$OUT_DIR/iter-$i"
mkdir -p "$ITER_DIR"
# Run maestro from the output dir so takeScreenshot artifacts land there
if (cd "$ITER_DIR" && maestro test "$FLOW") 2>&1 | tee "$ITER_DIR/test.log"; then
echo " -> PASS (iteration $i)"
else
echo " -> FAIL (iteration $i) — bug reproduced!"
FAILURES=$((FAILURES + 1))
xcrun simctl io booted screenshot "$ITER_DIR/failure-state.png" 2>/dev/null || true
fi
done
# Restore to dark mode
xcrun simctl ui booted appearance dark
echo ""
echo "=== Summary: $FAILURES failures out of $ITERATIONS iterations ==="
echo "Output: $OUT_DIR"
+18 -16
View File
@@ -1,13 +1,11 @@
{
"name": "@getpaseo/app",
"main": "index.ts",
"version": "0.1.55",
"version": "0.1.29",
"private": true,
"scripts": {
"start": "expo start",
"reset-project": "node ./scripts/reset-project.js",
"build:workspace-deps": "npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/expo-two-way-audio",
"eas-build-post-install": "npm run build:workspace-deps",
"android": "npm run android:development",
"android:development": "APP_VARIANT=development expo prebuild --platform android --non-interactive && APP_VARIANT=development expo run:android --variant=debug",
"android:production": "APP_VARIANT=production expo prebuild --platform android --non-interactive && APP_VARIANT=production expo run:android --variant=release",
@@ -16,26 +14,35 @@
"ios": "expo run:ios",
"ios:release": "expo run:ios --configuration Release",
"web": "expo start --web",
"web:tauri": "PASEO_WEB_PLATFORM=tauri expo start --web",
"lint": "expo lint",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"build": "npm run build:web",
"build:web": "npm run build:workspace-deps && expo export --platform web",
"build:web": "expo export --platform web",
"build:web:tauri": "PASEO_WEB_PLATFORM=tauri expo export --platform web",
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main"
},
"dependencies": {
"@getpaseo/expo-two-way-audio": "0.1.29",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.55",
"@getpaseo/highlight": "0.1.55",
"@getpaseo/server": "0.1.55",
"@getpaseo/server": "0.1.29",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@lezer/common": "^1.5.0",
"@lezer/css": "^1.3.0",
"@lezer/highlight": "^1.2.3",
"@lezer/html": "^1.3.13",
"@lezer/javascript": "^1.5.4",
"@lezer/json": "^1.0.3",
"@lezer/markdown": "^1.6.2",
"@lezer/python": "^1.1.18",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-masked-view/masked-view": "^0.3.2",
"@react-native/normalize-colors": "^0.81.5",
@@ -44,13 +51,10 @@
"@react-navigation/native": "^7.1.8",
"@tanstack/react-query": "^5.90.11",
"@tanstack/react-virtual": "^3.13.21",
"@xterm/addon-clipboard": "^0.2.0",
"@tauri-apps/api": "^2.9.1",
"@tauri-apps/plugin-log": "^2.8.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-image": "^0.9.0",
"@xterm/addon-ligatures": "^0.10.0",
"@xterm/addon-search": "^0.16.0",
"@xterm/addon-unicode11": "^0.9.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^6.0.0",
"base64-js": "^1.5.1",
@@ -79,6 +83,7 @@
"expo-system-ui": "~6.0.7",
"expo-updates": "~29.0.12",
"expo-web-browser": "~15.0.8",
"lezer-elixir": "^1.1.2",
"lucide-react-native": "^0.546.0",
"mnemonic-id": "^3.2.7",
"react": "19.1.4",
@@ -108,15 +113,12 @@
"devDependencies": {
"@playwright/test": "^1.56.1",
"@types/react": "~19.2.0",
"@types/ws": "^8.18.1",
"eas-cli": "^16.24.1",
"eslint": "^9.25.0",
"eslint-config-expo": "~10.0.0",
"material-icon-theme": "^5.32.0",
"playwright": "^1.56.1",
"typescript": "~5.9.2",
"vitest": "^3.2.4",
"wrangler": "^4.59.1",
"ws": "^8.20.0"
"wrangler": "^4.59.1"
}
}
+10 -11
View File
@@ -1,13 +1,12 @@
import { defineConfig, devices } from "@playwright/test";
import { defineConfig, devices } from '@playwright/test';
// E2E_METRO_PORT is set dynamically by global-setup.ts after finding a free port
// This allows multiple test runs in parallel across different worktrees
const baseURL =
process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`;
const baseURL = process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? '8081'}`;
export default defineConfig({
testDir: "./e2e",
globalSetup: "./e2e/global-setup.ts",
testDir: './e2e',
globalSetup: './e2e/global-setup.ts',
timeout: 60_000,
expect: {
timeout: 10_000,
@@ -17,17 +16,17 @@ export default defineConfig({
fullyParallel: false,
workers: 1,
retries: process.env.CI ? 1 : 0,
reporter: [["list"]],
reporter: [['list']],
use: {
baseURL,
trace: "retain-on-failure",
screenshot: "only-on-failure",
video: "retain-on-failure",
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{
name: "Desktop Chrome",
use: { ...devices["Desktop Chrome"] },
name: 'Desktop Chrome',
use: { ...devices['Desktop Chrome'] },
},
],
// Note: Metro is started by global-setup.ts on a dynamic port to allow parallel test runs
+2 -1
View File
@@ -1,7 +1,8 @@
import { defineConfig, devices } from "@playwright/test";
const baseURL =
process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`;
process.env.E2E_BASE_URL ??
`http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`;
export default defineConfig({
testDir: "./e2e",
+2 -1
View File
@@ -1,7 +1,8 @@
import { defineConfig, devices } from "@playwright/test";
const baseURL =
process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`;
process.env.E2E_BASE_URL ??
`http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`;
export default defineConfig({
testDir: "./e2e",
-60
View File
@@ -1,60 +0,0 @@
<!DOCTYPE html>
<html lang="%LANG_ISO_CODE%">
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, shrink-to-fit=no, viewport-fit=cover"
/>
<title>%WEB_TITLE%</title>
<!-- The `react-native-web` recommended style reset: https://necolas.github.io/react-native-web/docs/setup/#root-element -->
<style id="expo-reset">
/* These styles make the body full-height */
html,
body {
height: 100%;
}
/* These styles disable body scrolling if you are using <ScrollView> */
body {
overflow: hidden;
}
/* Prevent white flash before React mounts */
@media (prefers-color-scheme: dark) {
html, body {
background-color: #181B1A;
}
}
/* These styles make the root element full-height */
#root {
display: flex;
height: 100%;
flex: 1;
}
</style>
<style>
button,
a,
input,
textarea,
select,
[role='button'],
[role='link'],
[role='textbox'],
[role='combobox'],
[role='tab'],
[role='switch'],
[role='checkbox'],
[role='slider'],
[role='menuitem'],
[tabindex],
[contenteditable='true'] {
-webkit-app-region: no-drag !important;
}
</style>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>
+2 -2
View File
@@ -91,7 +91,7 @@ const moveDirectories = async (userInput) => {
userInput === "y"
? `\n3. Delete the /${exampleDir} directory when you're done referencing it.`
: ""
}`,
}`
);
} catch (error) {
console.error(`❌ Error during script execution: ${error.message}`);
@@ -108,5 +108,5 @@ rl.question(
console.log("❌ Invalid input. Please enter 'Y' or 'N'.");
rl.close();
}
},
}
);
@@ -0,0 +1,36 @@
export const WELCOME_ROUTE = '/welcome'
export function shouldWaitOnStartupRace(input: {
onlineServerId: string | null
hasTimedOut: boolean
isDesktopStartupRace: boolean
daemonCount: number
pathname: string
}): boolean {
if (input.onlineServerId) {
return false
}
if (input.pathname === WELCOME_ROUTE) {
return false
}
if (input.hasTimedOut) {
return false
}
return input.isDesktopStartupRace || input.daemonCount > 0
}
export function shouldRedirectToWelcome(input: {
onlineServerId: string | null
hasTimedOut: boolean
pathname: string
isDesktopStartupRace: boolean
daemonCount: number
}): boolean {
if (input.onlineServerId || !input.hasTimedOut) {
return false
}
if (input.pathname !== '/' && input.pathname !== '') {
return false
}
return input.isDesktopStartupRace || input.daemonCount > 0
}
+200 -472
View File
@@ -1,12 +1,6 @@
import "@/styles/unistyles";
import { polyfillCrypto } from "@/polyfills/crypto";
import {
Stack,
useGlobalSearchParams,
useNavigationContainerRef,
usePathname,
useRouter,
} from "expo-router";
import { Stack, useGlobalSearchParams, usePathname, useRouter } from "expo-router";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { KeyboardProvider } from "react-native-keyboard-controller";
import { GestureHandlerRootView, Gesture, GestureDetector } from "react-native-gesture-handler";
@@ -14,26 +8,21 @@ import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import { PortalProvider } from "@gorhom/portal";
import { VoiceProvider } from "@/contexts/voice-context";
import { useAppSettings } from "@/hooks/use-settings";
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
import { useFaviconStatus } from "@/hooks/use-favicon-status";
import { View, Text } from "react-native";
import { View, ActivityIndicator, Text } from "react-native";
import { UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { darkTheme } from "@/styles/theme";
import { QueryClientProvider } from "@tanstack/react-query";
import {
getHostRuntimeStore,
useHosts,
useHostMutations,
useHostRuntimeClient,
useHostRuntimeSession,
} from "@/runtime/host-runtime";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { loadSettingsFromStorage } from "@/hooks/use-settings";
import { useColorScheme } from "@/hooks/use-color-scheme";
import { useOpenProject } from "@/hooks/use-open-project";
import { SessionProvider } from "@/contexts/session-context";
import type { HostProfile } from "@/types/host-connection";
import {
createContext,
useCallback,
useContext,
useState,
useEffect,
@@ -46,7 +35,6 @@ import * as Linking from "expo-linking";
import * as Notifications from "expo-notifications";
import { LeftSidebar } from "@/components/left-sidebar";
import { DownloadToast } from "@/components/download-toast";
import { UpdateBanner } from "@/desktop/updates/update-banner";
import { ToastProvider } from "@/contexts/toast-context";
import { usePanelStore } from "@/stores/panel-store";
import { runOnJS, interpolate, Extrapolation, useSharedValue } from "react-native-reanimated";
@@ -58,10 +46,11 @@ import {
HorizontalScrollProvider,
useHorizontalScrollOptional,
} from "@/contexts/horizontal-scroll-context";
import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout";
import { getIsTauri } from "@/constants/layout";
import { CommandCenter } from "@/components/command-center";
import { ProjectPickerModal } from "@/components/project-picker-modal";
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
import { listenToDesktopNotificationClicks } from "@/desktop/notifications/desktop-notifications";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
import { queryClient } from "@/query/query-client";
import {
@@ -69,87 +58,53 @@ import {
type WebNotificationClickDetail,
ensureOsNotificationPermission,
} from "@/utils/os-notifications";
import { listenToDesktopEvent } from "@/desktop/electron/events";
import { getDesktopHost } from "@/desktop/host";
import { updateDesktopWindowControls } from "@/desktop/electron/window";
import { buildNotificationRoute } from "@/utils/notification-routing";
import {
buildHostRootRoute,
mapPathnameToServer,
parseServerIdFromPathname,
parseHostAgentRouteFromPathname,
parseWorkspaceOpenIntent,
} from "@/utils/host-routes";
import { syncNavigationActiveWorkspace } from "@/stores/navigation-active-workspace-store";
import { isWeb, isNative } from "@/constants/platform";
import { getTauri } from "@/utils/tauri";
import { attachConsole } from "@/utils/tauri-attach-console";
polyfillCrypto();
export type HostRuntimeBootstrapState = {
phase: "starting-daemon" | "connecting" | "online" | "error";
error: string | null;
retry: () => void;
};
function getRouteParamValue(value: string | string[] | undefined): string | undefined {
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
if (Array.isArray(value)) {
const firstValue = value[0];
if (typeof firstValue !== "string") {
return undefined;
}
const trimmed = firstValue.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
return undefined;
}
const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
phase: "starting-daemon",
error: null,
retry: () => {},
});
attachConsole();
const HostRuntimeBootstrapContext = createContext(false);
function PushNotificationRouter() {
const router = useRouter();
const lastHandledIdRef = useRef<string | null>(null);
useEffect(() => {
if (isWeb) {
let removeDesktopNotificationListener: (() => void) | null = null;
let cancelled = false;
if (getIsElectronRuntime()) {
if (Platform.OS === "web") {
if (getTauri()) {
void ensureOsNotificationPermission();
const unlistenResult = getDesktopHost()?.events?.on?.(
"notification-click",
(payload: unknown) => {
const data =
typeof payload === "object" &&
payload !== null &&
"data" in payload &&
typeof (payload as { data?: unknown }).data === "object" &&
(payload as { data?: unknown }).data !== null
? (payload as { data: Record<string, unknown> }).data
: undefined;
router.push(buildNotificationRoute(data) as any);
},
);
let disposed = false;
let unlisten: (() => void) | null = null;
void Promise.resolve(unlistenResult).then((unlisten) => {
if (typeof unlisten !== "function") {
return;
}
if (cancelled) {
unlisten();
return;
}
removeDesktopNotificationListener = unlisten;
});
void listenToDesktopNotificationClicks((payload) => {
router.push(buildNotificationRoute(payload.data) as any);
})
.then((cleanup) => {
if (disposed) {
cleanup();
return;
}
unlisten = cleanup;
})
.catch((error) => {
console.error(
"[OSNotifications][Desktop] Failed to register notification click listener",
error
);
});
return () => {
disposed = true;
unlisten?.();
};
}
const target = globalThis as unknown as EventTarget;
@@ -159,12 +114,15 @@ function PushNotificationRouter() {
router.push(buildNotificationRoute(customEvent.detail?.data) as any);
};
target.addEventListener(WEB_NOTIFICATION_CLICK_EVENT, openFromWebClick as EventListener);
target.addEventListener(
WEB_NOTIFICATION_CLICK_EVENT,
openFromWebClick as EventListener
);
return () => {
cancelled = true;
removeDesktopNotificationListener?.();
target.removeEventListener(WEB_NOTIFICATION_CLICK_EVENT, openFromWebClick as EventListener);
target.removeEventListener(
WEB_NOTIFICATION_CLICK_EVENT,
openFromWebClick as EventListener
);
};
}
@@ -192,7 +150,8 @@ function PushNotificationRouter() {
router.push(buildNotificationRoute(data) as any);
};
const subscription = Notifications.addNotificationResponseReceivedListener(openFromResponse);
const subscription =
Notifications.addNotificationResponseReceivedListener(openFromResponse);
void Notifications.getLastNotificationResponseAsync().then((response) => {
if (response) {
@@ -209,14 +168,18 @@ function PushNotificationRouter() {
}
function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
const client = useHostRuntimeClient(daemon.serverId);
const { client } = useHostRuntimeSession(daemon.serverId);
if (!client) {
return null;
}
return (
<SessionProvider key={daemon.serverId} serverId={daemon.serverId} client={client}>
<SessionProvider
key={daemon.serverId}
serverId={daemon.serverId}
client={client}
>
{null}
</SessionProvider>
);
@@ -239,126 +202,41 @@ function HostSessionManager() {
}
function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
const [phase, setPhase] = useState<HostRuntimeBootstrapState["phase"]>("starting-daemon");
const [error, setError] = useState<string | null>(null);
const [retryToken, setRetryToken] = useState(0);
const retry = useCallback(() => {
setPhase("starting-daemon");
setError(null);
setRetryToken((current) => current + 1);
}, []);
const [ready, setReady] = useState(false);
useEffect(() => {
let cancelled = false;
let cancelAnyOnline: (() => void) | null = null;
const shouldManageDesktop = shouldUseDesktopDaemon();
const store = getHostRuntimeStore();
const init = async () => {
const settings = await loadSettingsFromStorage();
const isDesktopManaged = shouldManageDesktop && settings.manageBuiltInDaemon;
await store.loadFromStorage();
if (isDesktopManaged) {
setPhase("starting-daemon");
setError(null);
let raceSettled = false;
const anyOnline = store.waitForAnyConnectionOnline();
cancelAnyOnline = anyOnline.cancel;
const bootstrapPromise = (async (): Promise<
{ type: "online" } | { type: "error"; error: string }
> => {
try {
const bootstrapResult = await store.bootstrapDesktop();
if (!bootstrapResult.ok) {
return { type: "error", error: bootstrapResult.error };
}
if (!cancelled && !raceSettled) {
setPhase("connecting");
}
await store.addConnectionFromListenAndWaitForOnline({
listenAddress: bootstrapResult.listenAddress,
serverId: bootstrapResult.serverId,
hostname: bootstrapResult.hostname,
});
return { type: "online" };
} catch (err) {
return {
type: "error",
error: err instanceof Error ? err.message : String(err),
};
}
})();
const result = await Promise.race([
anyOnline.promise.then((): { type: "online" } => ({ type: "online" })),
bootstrapPromise,
]);
raceSettled = true;
anyOnline.cancel();
if (!cancelled) {
if (result.type === "online") {
setPhase("online");
setError(null);
} else {
setPhase("error");
setError(result.error);
}
void store
.loadFromStorage()
.then(() => {
if (cancelled) {
return;
}
} else {
void store.bootstrap({ manageBuiltInDaemon: settings.manageBuiltInDaemon });
setReady(true);
void store.bootstrap();
})
.catch((error) => {
console.error("[HostRuntime] Failed to initialize store", error);
if (!cancelled) {
setPhase("online");
setError(null);
setReady(true);
}
}
};
void init().catch((bootstrapError) => {
console.error("[HostRuntime] Failed to initialize store", bootstrapError);
if (cancelled) {
return;
}
if (shouldManageDesktop) {
setPhase("error");
setError(bootstrapError instanceof Error ? bootstrapError.message : String(bootstrapError));
return;
}
setPhase("online");
setError(null);
});
});
return () => {
cancelled = true;
cancelAnyOnline?.();
};
}, [retryToken]);
const state = useMemo<HostRuntimeBootstrapState>(
() => ({
phase,
error,
retry,
}),
[error, phase, retry],
);
}, []);
return (
<HostRuntimeBootstrapContext.Provider value={state}>
<HostRuntimeBootstrapContext.Provider value={ready}>
{children}
</HostRuntimeBootstrapContext.Provider>
);
}
export function useStoreReady(): boolean {
return useContext(HostRuntimeBootstrapContext).phase === "online";
}
export function useHostRuntimeBootstrapState(): HostRuntimeBootstrapState {
function useStoreReady(): boolean {
return useContext(HostRuntimeBootstrapContext);
}
@@ -366,17 +244,12 @@ function QueryProvider({ children }: { children: ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
const rowStyle = { flex: 1, flexDirection: "row" } as const;
const flexStyle = { flex: 1 } as const;
interface AppContainerProps {
children: ReactNode;
selectedAgentId?: string;
chromeEnabled?: boolean;
}
const THEME_CYCLE_ORDER: ThemeName[] = ["dark", "zinc", "midnight", "claude", "ghostty", "light"];
function AppContainer({
children,
selectedAgentId,
@@ -384,103 +257,31 @@ function AppContainer({
}: AppContainerProps) {
const { theme } = useUnistyles();
const daemons = useHosts();
const { settings, updateSettings } = useAppSettings();
const mobileView = usePanelStore((state) => state.mobileView);
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const openAgentList = usePanelStore((state) => state.openAgentList);
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);
const toggleBothSidebars = usePanelStore((state) => state.toggleBothSidebars);
const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode);
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
const agentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
const horizontalScroll = useHorizontalScrollOptional();
const cycleTheme = useCallback(() => {
const currentIndex = THEME_CYCLE_ORDER.indexOf(settings.theme as ThemeName);
const nextIndex = (currentIndex + 1) % THEME_CYCLE_ORDER.length;
void updateSettings({ theme: THEME_CYCLE_ORDER[nextIndex]! });
}, [settings.theme, updateSettings]);
const isCompactLayout = useIsCompactFormFactor();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
useEffect(() => {
const bp = UnistylesRuntime.breakpoint;
const screenW = UnistylesRuntime.screen.width;
const screenH = UnistylesRuntime.screen.height;
const isElectron = getIsElectronRuntime();
const windowW = isWeb ? window.innerWidth : undefined;
const windowH = isWeb ? window.innerHeight : undefined;
const dpr = isWeb ? window.devicePixelRatio : undefined;
const ua = isWeb ? navigator.userAgent : undefined;
console.log(
"[layout-debug]",
JSON.stringify({
breakpoint: bp,
isCompactLayout,
isElectron,
chromeEnabled,
isFocusModeEnabled,
agentListOpen,
sidebarWidth,
sidebarRenderedInRow: !isCompactLayout && chromeEnabled && !isFocusModeEnabled,
unistylesScreen: { w: screenW, h: screenH },
window: { w: windowW, h: windowH },
devicePixelRatio: dpr,
userAgent: ua,
}),
);
}, [isCompactLayout, chromeEnabled, isFocusModeEnabled, agentListOpen, sidebarWidth]);
const isOpen = chromeEnabled
? isMobile
? mobileView === "agent-list"
: desktopAgentListOpen
: false;
const openGestureEnabled =
chromeEnabled && isMobile && mobileView === "agent";
useKeyboardShortcuts({
enabled: chromeEnabled,
isMobile: isCompactLayout,
isMobile,
toggleAgentList,
selectedAgentId,
toggleFileExplorer,
toggleBothSidebars,
toggleFocusMode,
cycleTheme,
});
const containerStyle = useMemo(
() => ({ flex: 1 as const, backgroundColor: theme.colors.surface0 }),
[theme.colors.surface0],
);
const content = (
<View style={containerStyle}>
<View style={rowStyle}>
{!isCompactLayout && chromeEnabled && !isFocusModeEnabled && (
<LeftSidebar selectedAgentId={selectedAgentId} />
)}
<View style={flexStyle}>{children}</View>
</View>
{isCompactLayout && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
<DownloadToast />
<UpdateBanner />
<CommandCenter />
<ProjectPickerModal />
<KeyboardShortcutsDialog />
</View>
);
if (!isCompactLayout) {
return content;
}
return <MobileGestureWrapper chromeEnabled={chromeEnabled}>{content}</MobileGestureWrapper>;
}
function MobileGestureWrapper({
children,
chromeEnabled,
}: {
children: ReactNode;
chromeEnabled: boolean;
}) {
const mobileView = usePanelStore((state) => state.mobileView);
const openAgentList = usePanelStore((state) => state.openAgentList);
const horizontalScroll = useHorizontalScrollOptional();
const {
translateX,
backdropOpacity,
@@ -488,23 +289,19 @@ function MobileGestureWrapper({
animateToOpen,
animateToClose,
isGesturing,
gestureAnimatingRef,
openGestureRef,
} = useSidebarAnimation();
// Track initial touch position for manual activation
const touchStartX = useSharedValue(0);
const openGestureEnabled = chromeEnabled && mobileView === "agent";
const handleGestureOpen = useCallback(() => {
gestureAnimatingRef.current = true;
openAgentList();
}, [openAgentList, gestureAnimatingRef]);
// Open gesture: swipe right from anywhere to open sidebar (interactive drag)
// If any horizontal scroll is scrolled right, let the scroll view handle the gesture first
const openGesture = useMemo(
() =>
Gesture.Pan()
.withRef(openGestureRef)
.enabled(openGestureEnabled)
.manualActivation(true)
// Fail if 10px vertical movement happens first (allow vertical scroll)
.failOffsetY([-10, 10])
.onTouchesDown((event) => {
const touch = event.changedTouches[0];
@@ -518,11 +315,13 @@ function MobileGestureWrapper({
const deltaX = touch.absoluteX - touchStartX.value;
// If horizontal scroll is scrolled right, fail so ScrollView handles it
if (horizontalScroll?.isAnyScrolledRight.value) {
stateManager.fail();
return;
}
// Activate after 15px rightward movement
if (deltaX > 15) {
stateManager.activate();
}
@@ -531,21 +330,23 @@ function MobileGestureWrapper({
isGesturing.value = true;
})
.onUpdate((event) => {
// Start from closed position (-windowWidth) and move towards 0
const newTranslateX = Math.min(0, -windowWidth + event.translationX);
translateX.value = newTranslateX;
backdropOpacity.value = interpolate(
newTranslateX,
[-windowWidth, 0],
[0, 1],
Extrapolation.CLAMP,
Extrapolation.CLAMP
);
})
.onEnd((event) => {
isGesturing.value = false;
// Open if dragged more than 1/3 of sidebar or fast swipe
const shouldOpen = event.translationX > windowWidth / 3 || event.velocityX > 500;
if (shouldOpen) {
animateToOpen();
runOnJS(handleGestureOpen)();
runOnJS(openAgentList)();
} else {
animateToClose();
}
@@ -560,57 +361,66 @@ function MobileGestureWrapper({
backdropOpacity,
animateToOpen,
animateToClose,
handleGestureOpen,
openAgentList,
mobileView,
isGesturing,
openGestureRef,
horizontalScroll?.isAnyScrolledRight,
touchStartX,
],
]
);
const content = (
<View style={{ flex: 1, backgroundColor: theme.colors.surface0 }}>
<View style={{ flex: 1, flexDirection: "row" }}>
{!isMobile && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
<View style={{ flex: 1 }}>
{children}
</View>
</View>
{isMobile && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
<DownloadToast />
<CommandCenter />
<ProjectPickerModal />
<KeyboardShortcutsDialog />
</View>
);
if (!isMobile) {
return content;
}
return (
<GestureDetector gesture={openGesture} touchAction="pan-y">
{children}
{content}
</GestureDetector>
);
}
function ProvidersWrapper({ children }: { children: ReactNode }) {
const { settings, isLoading: settingsLoading } = useAppSettings();
const storeReady = useStoreReady();
const { upsertConnectionFromOfferUrl } = useHostMutations();
const systemColorScheme = useColorScheme();
const { theme } = useUnistyles();
const resolvedTheme = settings.theme === "auto" ? (systemColorScheme ?? "light") : settings.theme;
const isLoading = settingsLoading || !storeReady;
// Apply theme setting on mount and when it changes
useEffect(() => {
if (settingsLoading) return;
if (isLoading) return;
if (settings.theme === "auto") {
UnistylesRuntime.setAdaptiveThemes(true);
} else {
UnistylesRuntime.setAdaptiveThemes(false);
UnistylesRuntime.setTheme(THEME_TO_UNISTYLES[settings.theme]);
UnistylesRuntime.setTheme(settings.theme);
}
}, [settingsLoading, settings.theme]);
}, [isLoading, settings.theme]);
useEffect(() => {
if (settingsLoading || isNative) {
return;
}
void updateDesktopWindowControls({
backgroundColor: theme.colors.surface0,
foregroundColor: theme.colors.foreground,
}).catch((error) => {
console.warn("[DesktopWindow] Failed to update window controls overlay", error);
});
}, [settingsLoading, resolvedTheme, theme.colors.foreground, theme.colors.surface0]);
if (isLoading) {
return <LoadingView />;
}
return (
<VoiceProvider>
<OfferLinkListener upsertDaemonFromOfferUrl={upsertConnectionFromOfferUrl} />
<HostSessionManager />
<FaviconStatusSync />
{children}
</VoiceProvider>
);
@@ -633,7 +443,7 @@ function OfferLinkListener({
if (cancelled) return;
const serverId = (profile as any)?.serverId;
if (typeof serverId !== "string" || !serverId) return;
router.replace(buildHostRootRoute(serverId));
router.replace(buildHostRootRoute(serverId) as any);
})
.catch((error) => {
if (cancelled) return;
@@ -641,9 +451,7 @@ function OfferLinkListener({
});
};
void Linking.getInitialURL()
.then(handleUrl)
.catch(() => undefined);
void Linking.getInitialURL().then(handleUrl).catch(() => undefined);
const subscription = Linking.addEventListener("url", (event) => {
handleUrl(event.url);
@@ -658,102 +466,13 @@ function OfferLinkListener({
return null;
}
interface OpenProjectEventPayload {
path?: unknown;
}
function OpenProjectListener() {
const hosts = useHosts();
const serverId = hosts[0]?.serverId ?? null;
const client = useHostRuntimeClient(serverId ?? "");
const openProject = useOpenProject(serverId);
const pendingPathRef = useRef<string | null>(null);
useEffect(() => {
let disposed = false;
let unlisten: (() => void) | null = null;
const maybeOpenProject = (inputPath: string) => {
const nextPath = inputPath.trim();
if (!nextPath) {
return;
}
pendingPathRef.current = nextPath;
if (!serverId || !client) {
return;
}
const pathToOpen = pendingPathRef.current;
pendingPathRef.current = null;
if (!pathToOpen) {
return;
}
void openProject(pathToOpen).catch(() => undefined);
};
// Pull any path that was passed on cold start (before the listener existed).
// Store in the ref even if this effect instance is disposed — the next
// effect run picks it up via maybeOpenProject(pendingPathRef.current).
void getDesktopHost()
?.getPendingOpenProject?.()
?.then((pending) => {
if (pending) {
pendingPathRef.current = pending;
}
if (!disposed && pending) {
maybeOpenProject(pending);
}
})
.catch(() => undefined);
// Listen for hot-start paths relayed via the second-instance event.
void listenToDesktopEvent<OpenProjectEventPayload>("open-project", (payload) => {
if (disposed) {
return;
}
const nextPath = typeof payload?.path === "string" ? payload.path.trim() : "";
maybeOpenProject(nextPath);
})
.then((dispose) => {
if (disposed) {
dispose();
return;
}
unlisten = dispose;
})
.catch(() => undefined);
maybeOpenProject(pendingPathRef.current ?? "");
return () => {
disposed = true;
unlisten?.();
};
}, [client, openProject, serverId]);
return null;
}
function AppWithSidebar({ children }: { children: ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const params = useGlobalSearchParams<{ open?: string | string[] }>();
const hosts = useHosts();
useFaviconStatus();
const activeServerId = useMemo(() => parseServerIdFromPathname(pathname), [pathname]);
const shouldShowAppChrome = activeServerId !== null;
useEffect(() => {
if (!activeServerId || hosts.length === 0) {
return;
}
if (hosts.some((host) => host.serverId === activeServerId)) {
return;
}
router.replace(mapPathnameToServer(pathname, hosts[0]!.serverId));
}, [activeServerId, hosts, pathname, router]);
// Parse selectedAgentKey directly from pathname
// useLocalSearchParams doesn't update when navigating between same-pattern routes
const selectedAgentKey = useMemo(() => {
@@ -780,96 +499,105 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
);
}
function FaviconStatusSync() {
useFaviconStatus();
return null;
}
function RootStack() {
const storeReady = useStoreReady();
const { theme } = useUnistyles();
function LoadingView({ message }: { message?: string } = {}) {
return (
<Stack
screenOptions={{
headerShown: false,
animation: "none",
contentStyle: {
backgroundColor: theme.colors.surface0,
},
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: darkTheme.colors.surface0,
}}
>
<Stack.Protected guard={storeReady}>
<Stack.Screen name="welcome" />
<Stack.Screen name="settings" />
<Stack.Screen name="pair-scan" />
</Stack.Protected>
<Stack.Screen
name="h/[serverId]/workspace/[workspaceId]"
getId={({ params }) => {
const serverId = getRouteParamValue(params?.serverId);
const workspaceId = getRouteParamValue(params?.workspaceId);
return serverId && workspaceId ? `${serverId}:${workspaceId}` : undefined;
}}
/>
<Stack.Screen name="h/[serverId]/agent/[agentId]" options={{ gestureEnabled: false }} />
<Stack.Screen name="h/[serverId]/index" />
<Stack.Screen name="h/[serverId]/sessions" />
<Stack.Screen name="h/[serverId]/open-project" />
<Stack.Screen name="h/[serverId]/settings" />
<Stack.Screen name="index" />
</Stack>
<ActivityIndicator size="large" color={darkTheme.colors.foreground} />
{message ? (
<Text
style={{
color: darkTheme.colors.foregroundMuted,
marginTop: 16,
fontSize: 14,
}}
>
{message}
</Text>
) : null}
</View>
);
}
function NavigationActiveWorkspaceObserver() {
const navigationRef = useNavigationContainerRef();
useEffect(() => {
syncNavigationActiveWorkspace(navigationRef);
const unsubscribeState = navigationRef.addListener("state", () => {
syncNavigationActiveWorkspace(navigationRef);
});
const unsubscribeReady = navigationRef.addListener("ready" as never, () => {
syncNavigationActiveWorkspace(navigationRef);
});
return () => {
unsubscribeState();
unsubscribeReady();
};
}, [navigationRef]);
return null;
function MissingDaemonView() {
return (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
padding: 24,
backgroundColor: darkTheme.colors.surface0,
}}
>
<ActivityIndicator size="small" color={darkTheme.colors.foreground} />
<Text
style={{
color: darkTheme.colors.foreground,
marginTop: 16,
textAlign: "center",
}}
>
No host configured. Open Settings to add a server URL.
</Text>
</View>
);
}
export default function RootLayout() {
const { theme } = useUnistyles();
return (
<GestureHandlerRootView style={{ flex: 1, backgroundColor: theme.colors.surface0 }}>
<NavigationActiveWorkspaceObserver />
<GestureHandlerRootView
style={{ flex: 1, backgroundColor: darkTheme.colors.surface0 }}
>
<PortalProvider>
<SafeAreaProvider>
<KeyboardProvider>
<QueryProvider>
<BottomSheetModalProvider>
<BottomSheetModalProvider>
<QueryProvider>
<HostRuntimeBootstrapProvider>
<PushNotificationRouter />
<ProvidersWrapper>
<SidebarAnimationProvider>
<HorizontalScrollProvider>
<ToastProvider>
<OpenProjectListener />
<AppWithSidebar>
<RootStack />
<Stack
screenOptions={{
headerShown: false,
animation: "none",
contentStyle: {
backgroundColor: darkTheme.colors.surface0,
},
}}
>
<Stack.Screen name="index" />
<Stack.Screen name="settings" />
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]" />
<Stack.Screen
name="h/[serverId]/agent/[agentId]"
options={{ gestureEnabled: false }}
/>
<Stack.Screen name="h/[serverId]/index" />
<Stack.Screen name="h/[serverId]/agents" />
<Stack.Screen name="h/[serverId]/new-agent" />
<Stack.Screen name="h/[serverId]/open-project" />
<Stack.Screen name="h/[serverId]/settings" />
<Stack.Screen name="pair-scan" />
</Stack>
</AppWithSidebar>
</ToastProvider>
</HorizontalScrollProvider>
</SidebarAnimationProvider>
</ProvidersWrapper>
</HostRuntimeBootstrapProvider>
</BottomSheetModalProvider>
</QueryProvider>
</QueryProvider>
</BottomSheetModalProvider>
</KeyboardProvider>
</SafeAreaProvider>
</PortalProvider>
@@ -1,20 +1,13 @@
import { useEffect, useRef } from "react";
import { useLocalSearchParams, useRouter } from "expo-router";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import { useSessionStore } from "@/stores/session-store";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { buildHostRootRoute } from "@/utils/host-routes";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import { useHostRuntimeSession } from "@/runtime/host-runtime";
import {
buildHostRootRoute,
buildHostWorkspaceAgentRoute,
} from "@/utils/host-routes";
export default function HostAgentReadyRoute() {
return (
<HostRouteBootstrapBoundary>
<HostAgentReadyRouteContent />
</HostRouteBootstrapBoundary>
);
}
function HostAgentReadyRouteContent() {
const router = useRouter();
const params = useLocalSearchParams<{
serverId?: string;
@@ -23,8 +16,7 @@ function HostAgentReadyRouteContent() {
const redirectedRef = useRef(false);
const serverId = typeof params.serverId === "string" ? params.serverId : "";
const agentId = typeof params.agentId === "string" ? params.agentId : "";
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const { client, isConnected } = useHostRuntimeSession(serverId);
const agentCwd = useSessionStore((state) => {
if (!serverId || !agentId) {
return null;
@@ -46,11 +38,7 @@ function HostAgentReadyRouteContent() {
if (normalizedCwd) {
redirectedRef.current = true;
router.replace(
prepareWorkspaceTab({
serverId,
workspaceId: normalizedCwd,
target: { kind: "agent", agentId },
}) as any,
buildHostWorkspaceAgentRoute(serverId, normalizedCwd, agentId) as any
);
}
}, [agentCwd, agentId, router, serverId]);
@@ -67,7 +55,7 @@ function HostAgentReadyRouteContent() {
}
if (!client || !isConnected) {
redirectedRef.current = true;
router.replace(buildHostRootRoute(serverId));
router.replace(buildHostRootRoute(serverId) as any);
}
}, [agentCwd, agentId, client, isConnected, router, serverId]);
@@ -89,23 +77,17 @@ function HostAgentReadyRouteContent() {
const cwd = result?.agent?.cwd?.trim();
redirectedRef.current = true;
if (cwd) {
router.replace(
prepareWorkspaceTab({
serverId,
workspaceId: cwd,
target: { kind: "agent", agentId },
}) as any,
);
router.replace(buildHostWorkspaceAgentRoute(serverId, cwd, agentId) as any);
return;
}
router.replace(buildHostRootRoute(serverId));
router.replace(buildHostRootRoute(serverId) as any);
})
.catch(() => {
if (cancelled || redirectedRef.current) {
return;
}
redirectedRef.current = true;
router.replace(buildHostRootRoute(serverId));
router.replace(buildHostRootRoute(serverId) as any);
});
return () => {
@@ -0,0 +1,9 @@
import { useLocalSearchParams } from "expo-router";
import { AgentsScreen } from "@/screens/agents-screen";
export default function HostAgentsRoute() {
const params = useLocalSearchParams<{ serverId?: string }>();
const serverId = typeof params.serverId === "string" ? params.serverId : "";
return <AgentsScreen serverId={serverId} />;
}
+28 -23
View File
@@ -1,36 +1,27 @@
import { useEffect } from "react";
import { useLocalSearchParams, usePathname, useRouter } from "expo-router";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import { useSessionStore } from "@/stores/session-store";
import { useFormPreferences } from "@/hooks/use-form-preferences";
import {
buildHostOpenProjectRoute,
buildHostRootRoute,
buildHostWorkspaceAgentRoute,
buildHostWorkspaceRoute,
} from "@/utils/host-routes";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
const HOST_ROOT_REDIRECT_DELAY_MS = 300;
export default function HostIndexRoute() {
return (
<HostRouteBootstrapBoundary>
<HostIndexRouteContent />
</HostRouteBootstrapBoundary>
);
}
function HostIndexRouteContent() {
const router = useRouter();
const pathname = usePathname();
const params = useLocalSearchParams<{ serverId?: string }>();
const serverId = typeof params.serverId === "string" ? params.serverId : "";
const { isLoading: preferencesLoading } = useFormPreferences();
const sessionAgents = useSessionStore((state) =>
serverId ? state.sessions[serverId]?.agents : undefined,
const sessionAgents = useSessionStore(
(state) => (serverId ? state.sessions[serverId]?.agents : undefined)
);
const sessionWorkspaces = useSessionStore((state) =>
serverId ? state.sessions[serverId]?.workspaces : undefined,
const sessionWorkspaces = useSessionStore(
(state) => (serverId ? state.sessions[serverId]?.workspaces : undefined)
);
useEffect(() => {
@@ -53,34 +44,48 @@ function HostIndexRouteContent() {
? Array.from(sessionAgents.values()).filter((agent) => !agent.archivedAt)
: [];
visibleAgents.sort(
(left, right) => right.lastActivityAt.getTime() - left.lastActivityAt.getTime(),
(left, right) => right.lastActivityAt.getTime() - left.lastActivityAt.getTime()
);
const visibleWorkspaces = sessionWorkspaces ? Array.from(sessionWorkspaces.values()) : [];
const visibleWorkspaces = sessionWorkspaces
? Array.from(sessionWorkspaces.values())
: [];
visibleWorkspaces.sort((left, right) => {
const leftTime = left.activityAt?.getTime() ?? Number.NEGATIVE_INFINITY;
const rightTime = right.activityAt?.getTime() ?? Number.NEGATIVE_INFINITY;
return rightTime - leftTime;
});
const primaryAgent = visibleAgents[0];
if (primaryAgent?.cwd?.trim()) {
router.replace(
prepareWorkspaceTab({
buildHostWorkspaceAgentRoute(
serverId,
workspaceId: primaryAgent.cwd.trim(),
target: { kind: "agent", agentId: primaryAgent.id },
}) as any,
primaryAgent.cwd.trim(),
primaryAgent.id
) as any
);
return;
}
const primaryWorkspace = visibleWorkspaces[0];
if (primaryWorkspace?.id?.trim()) {
router.replace(buildHostWorkspaceRoute(serverId, primaryWorkspace.id.trim()));
router.replace(buildHostWorkspaceRoute(serverId, primaryWorkspace.id.trim()) as any);
return;
}
router.replace(buildHostOpenProjectRoute(serverId));
router.replace(buildHostOpenProjectRoute(serverId) as any);
}, HOST_ROOT_REDIRECT_DELAY_MS);
return () => clearTimeout(timer);
}, [pathname, preferencesLoading, router, serverId, sessionAgents, sessionWorkspaces]);
}, [
pathname,
preferencesLoading,
router,
serverId,
sessionAgents,
sessionWorkspaces,
]);
return null;
}
@@ -0,0 +1,9 @@
import { useLocalSearchParams } from "expo-router";
import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen";
export default function HostNewAgentRoute() {
const params = useLocalSearchParams<{ serverId?: string }>();
const serverId = typeof params.serverId === "string" ? params.serverId : "";
return <DraftAgentScreen forcedServerId={serverId} />;
}
@@ -1,16 +1,7 @@
import { useLocalSearchParams } from "expo-router";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import { OpenProjectScreen } from "@/screens/open-project-screen";
export default function HostOpenProjectRoute() {
return (
<HostRouteBootstrapBoundary>
<HostOpenProjectRouteContent />
</HostRouteBootstrapBoundary>
);
}
function HostOpenProjectRouteContent() {
const params = useLocalSearchParams<{ serverId?: string }>();
const serverId = typeof params.serverId === "string" ? params.serverId : "";
@@ -1,18 +0,0 @@
import { useLocalSearchParams } from "expo-router";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import { SessionsScreen } from "@/screens/sessions-screen";
export default function HostAgentsRoute() {
return (
<HostRouteBootstrapBoundary>
<HostAgentsRouteContent />
</HostRouteBootstrapBoundary>
);
}
function HostAgentsRouteContent() {
const params = useLocalSearchParams<{ serverId?: string }>();
const serverId = typeof params.serverId === "string" ? params.serverId : "";
return <SessionsScreen serverId={serverId} />;
}
@@ -1,10 +1,3 @@
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import SettingsScreen from "@/screens/settings-screen";
export default function HostSettingsRoute() {
return (
<HostRouteBootstrapBoundary>
<SettingsScreen />
</HostRouteBootstrapBoundary>
);
}
export default SettingsScreen;
@@ -1,113 +1,25 @@
import { useEffect, useRef, useState } from "react";
import { useGlobalSearchParams, useLocalSearchParams, useRootNavigationState } from "expo-router";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
import { useGlobalSearchParams, usePathname } from 'expo-router'
import { WorkspaceScreen } from '@/screens/workspace/workspace-screen'
import {
decodeWorkspaceIdFromPathSegment,
parseHostWorkspaceRouteFromPathname,
parseWorkspaceOpenIntent,
type WorkspaceOpenIntent,
} from "@/utils/host-routes";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import { isWeb } from "@/constants/platform";
function getParamValue(value: string | string[] | undefined): string {
if (typeof value === "string") {
return value.trim();
}
if (Array.isArray(value)) {
const firstValue = value[0];
return typeof firstValue === "string" ? firstValue.trim() : "";
}
return "";
}
function getOpenIntentTarget(openIntent: WorkspaceOpenIntent): WorkspaceTabTarget {
if (openIntent.kind === "agent") {
return { kind: "agent", agentId: openIntent.agentId };
}
if (openIntent.kind === "terminal") {
return { kind: "terminal", terminalId: openIntent.terminalId };
}
if (openIntent.kind === "file") {
return { kind: "file", path: openIntent.path };
}
return { kind: "draft", draftId: openIntent.draftId };
}
} from '@/utils/host-routes'
export default function HostWorkspaceLayout() {
return (
<HostRouteBootstrapBoundary>
<HostWorkspaceLayoutContent />
</HostRouteBootstrapBoundary>
);
}
function HostWorkspaceLayoutContent() {
const rootNavigationState = useRootNavigationState();
const consumedIntentRef = useRef<string | null>(null);
const [intentConsumed, setIntentConsumed] = useState(false);
const params = useLocalSearchParams<{
serverId?: string | string[];
workspaceId?: string | string[];
}>();
const globalParams = useGlobalSearchParams<{
open?: string | string[];
}>();
const serverId = getParamValue(params.serverId);
const workspaceValue = getParamValue(params.workspaceId);
const workspaceId = workspaceValue
? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "")
: "";
const openValue = getParamValue(globalParams.open);
useEffect(() => {
if (!openValue) {
return;
}
if (!rootNavigationState?.key) {
return;
}
const consumptionKey = `${serverId}:${workspaceId}:${openValue}`;
if (consumedIntentRef.current === consumptionKey) {
return;
}
consumedIntentRef.current = consumptionKey;
const openIntent = parseWorkspaceOpenIntent(openValue);
if (openIntent) {
prepareWorkspaceTab({
serverId,
workspaceId,
target: getOpenIntentTarget(openIntent),
pin: openIntent.kind === "agent",
});
}
// Expo Router's replace ignores query-param-only changes (findDivergentState
// skips search params). Strip ?open from the browser URL directly so the
// address bar reflects the clean workspace route.
if (isWeb && typeof window !== "undefined") {
const url = new URL(window.location.href);
if (url.searchParams.has("open")) {
url.searchParams.delete("open");
window.history.replaceState(null, "", url.toString());
}
}
setIntentConsumed(true);
}, [openValue, rootNavigationState?.key, serverId, workspaceId]);
if (openValue && !intentConsumed) {
return null;
}
const expoPathname = usePathname()
const params = useGlobalSearchParams<{ open?: string | string[] }>()
const activeRoute = parseHostWorkspaceRouteFromPathname(expoPathname)
const serverId = activeRoute?.serverId ?? ''
const workspaceId = activeRoute?.workspaceId ?? ''
const openValue = Array.isArray(params.open) ? params.open[0] : params.open
const openIntent = parseWorkspaceOpenIntent(openValue)
return (
<WorkspaceScreen
key={`${serverId}:${workspaceId}`}
serverId={serverId}
workspaceId={workspaceId}
openIntent={openIntent}
/>
);
)
}
+92 -35
View File
@@ -1,56 +1,113 @@
import { useEffect, useSyncExternalStore } from "react";
import { usePathname, useRouter } from "expo-router";
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
import { useHostRuntimeBootstrapState, useStoreReady } from "@/app/_layout";
import { getHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime";
import { buildHostRootRoute } from "@/utils/host-routes";
const WELCOME_ROUTE = "/welcome";
function useAnyOnlineHostServerId(serverIds: string[]): string | null {
const runtime = getHostRuntimeStore();
import { useEffect, useSyncExternalStore, useState } from 'react'
import { usePathname, useRouter } from 'expo-router'
import { useHosts } from '@/runtime/host-runtime'
import { shouldUseManagedDesktopDaemon } from '@/desktop/managed-runtime/managed-runtime'
import { buildHostRootRoute } from '@/utils/host-routes'
import { StartupSplashScreen } from '@/screens/startup-splash-screen'
import { WelcomeScreen } from '@/components/welcome-screen'
import { getHostRuntimeStore, isHostRuntimeConnected } from '@/runtime/host-runtime'
import {
shouldRedirectToWelcome,
shouldWaitOnStartupRace,
WELCOME_ROUTE,
} from '@/app-support/index-startup'
const STARTUP_TIMEOUT_MS = 30_000
function useAnyHostOnline(serverIds: string[]): string | null {
const runtime = getHostRuntimeStore()
return useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange),
() => {
let firstOnlineServerId: string | null = null;
let firstOnlineAt: string | null = null;
let firstOnlineServerId: string | null = null
let firstOnlineAt: string | null = null
for (const serverId of serverIds) {
const snapshot = runtime.getSnapshot(serverId);
const lastOnlineAt = snapshot?.lastOnlineAt ?? null;
const snapshot = runtime.getSnapshot(serverId)
const lastOnlineAt = snapshot?.lastOnlineAt ?? null
if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) {
continue;
continue
}
if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) {
firstOnlineAt = lastOnlineAt;
firstOnlineServerId = serverId;
firstOnlineAt = lastOnlineAt
firstOnlineServerId = serverId
}
}
return firstOnlineServerId;
return firstOnlineServerId
},
() => null,
);
() => {
let firstOnlineServerId: string | null = null
let firstOnlineAt: string | null = null
for (const serverId of serverIds) {
const snapshot = runtime.getSnapshot(serverId)
const lastOnlineAt = snapshot?.lastOnlineAt ?? null
if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) {
continue
}
if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) {
firstOnlineAt = lastOnlineAt
firstOnlineServerId = serverId
}
}
return firstOnlineServerId
}
)
}
export default function Index() {
const router = useRouter();
const pathname = usePathname();
const bootstrapState = useHostRuntimeBootstrapState();
const storeReady = useStoreReady();
const hosts = useHosts();
const anyOnlineServerId = useAnyOnlineHostServerId(hosts.map((host) => host.serverId));
const router = useRouter()
const pathname = usePathname()
const daemons = useHosts()
const [hasTimedOut, setHasTimedOut] = useState(false)
const isDesktopStartupRace = shouldUseManagedDesktopDaemon()
const onlineServerId = useAnyHostOnline(daemons.map((daemon) => daemon.serverId))
useEffect(() => {
const timer = setTimeout(() => {
setHasTimedOut(true)
}, STARTUP_TIMEOUT_MS)
return () => {
clearTimeout(timer)
}
}, [])
useEffect(() => {
if (!storeReady) {
return;
if (!onlineServerId) {
return
}
if (pathname !== "/" && pathname !== "") {
return;
if (pathname !== '/' && pathname !== '') {
return
}
router.replace(buildHostRootRoute(onlineServerId) as any)
}, [onlineServerId, pathname, router])
const targetRoute = anyOnlineServerId ? buildHostRootRoute(anyOnlineServerId) : WELCOME_ROUTE;
router.replace(targetRoute);
}, [anyOnlineServerId, pathname, router, storeReady]);
useEffect(() => {
if (
!shouldRedirectToWelcome({
onlineServerId,
hasTimedOut,
pathname,
isDesktopStartupRace,
daemonCount: daemons.length,
})
) {
return
}
router.replace(WELCOME_ROUTE as any)
}, [daemons.length, hasTimedOut, isDesktopStartupRace, onlineServerId, pathname, router])
return <StartupSplashScreen bootstrapState={bootstrapState} />;
if (
shouldWaitOnStartupRace({
onlineServerId,
hasTimedOut,
isDesktopStartupRace,
daemonCount: daemons.length,
pathname,
})
) {
return <StartupSplashScreen />
}
if (!onlineServerId) {
return <WelcomeScreen />
}
return null
}
+64 -20
View File
@@ -1,16 +1,20 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Alert, Pressable, Text, View } from "react-native";
import { Alert, Platform, Pressable, Text, View } from "react-native";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { CameraView, useCameraPermissions } from "expo-camera";
import type { BarcodeScanningResult } from "expo-camera";
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import { NameHostModal } from "@/components/name-host-modal";
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
import { connectToDaemon } from "@/utils/test-daemon-connection";
import { ConnectionOfferSchema } from "@server/shared/connection-offer";
import { buildHostRootRoute, buildHostSettingsRoute } from "@/utils/host-routes";
import { isWeb } from "@/constants/platform";
import {
buildHostRootRoute,
buildHostSettingsRoute,
} from "@/utils/host-routes";
const styles = StyleSheet.create((theme) => ({
container: {
@@ -60,7 +64,7 @@ const styles = StyleSheet.create((theme) => ({
position: "absolute",
width: 36,
height: 36,
borderColor: theme.colors.accent,
borderColor: theme.colors.palette.blue[400],
},
cornerTL: {
left: 0,
@@ -144,19 +148,30 @@ export default function PairScanScreen() {
targetServerId?: string;
}>();
const source = typeof params.source === "string" ? params.source : "settings";
const sourceServerId = typeof params.sourceServerId === "string" ? params.sourceServerId : null;
const sourceServerId =
typeof params.sourceServerId === "string" ? params.sourceServerId : null;
const targetServerId = typeof params.targetServerId === "string" ? params.targetServerId : null;
const daemons = useHosts();
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations();
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl, renameHost } = useHostMutations();
const [permission, requestPermission] = useCameraPermissions();
const [isPairing, setIsPairing] = useState(false);
const lastScannedRef = useRef<string | null>(null);
const [pendingNameHost, setPendingNameHost] = useState<{ serverId: string; hostname: string | null } | null>(null);
const pendingNameHostname = useSessionStore(
useCallback(
(state) => {
if (!pendingNameHost) return null;
return state.sessions[pendingNameHost.serverId]?.serverInfo?.hostname ?? pendingNameHost.hostname ?? null;
},
[pendingNameHost]
)
);
const returnToSource = useCallback(
(serverId: string) => {
if (source === "onboarding") {
router.replace(buildHostRootRoute(serverId));
router.replace(buildHostRootRoute(serverId) as any);
return;
}
if (source === "editHost" && targetServerId) {
@@ -172,10 +187,10 @@ export default function PairScanScreen() {
router.back();
} catch {
const settingsServerId = sourceServerId ?? serverId;
router.replace(buildHostSettingsRoute(settingsServerId));
router.replace(buildHostSettingsRoute(settingsServerId) as any);
}
},
[router, source, sourceServerId, targetServerId],
[router, source, sourceServerId, targetServerId]
);
const closeToSource = useCallback(() => {
@@ -191,7 +206,7 @@ export default function PairScanScreen() {
router.back();
} catch {
if (sourceServerId) {
router.replace(buildHostSettingsRoute(sourceServerId));
router.replace(buildHostSettingsRoute(sourceServerId) as any);
return;
}
router.replace("/" as any);
@@ -199,13 +214,14 @@ export default function PairScanScreen() {
}, [router, source, sourceServerId, targetServerId]);
useEffect(() => {
if (isWeb) return;
if (Platform.OS === "web") return;
if (permission && permission.granted) return;
void requestPermission().catch(() => undefined);
}, [permission, requestPermission]);
const handleScan = useCallback(
async (result: BarcodeScanningResult) => {
if (pendingNameHost) return;
if (isPairing) return;
const offerUrl = extractOfferUrlFromScan(result);
if (!offerUrl) return;
@@ -222,14 +238,11 @@ export default function PairScanScreen() {
if (targetServerId && offer.serverId !== targetServerId) {
lastScannedRef.current = null;
Alert.alert(
"Wrong daemon",
`That QR code belongs to ${offer.serverId}, not ${targetServerId}.`,
);
Alert.alert("Wrong daemon", `That QR code belongs to ${offer.serverId}, not ${targetServerId}.`);
return;
}
const { client, hostname } = await connectToDaemon(
const { client } = await connectToDaemon(
{
id: "probe",
type: "relay",
@@ -240,7 +253,13 @@ export default function PairScanScreen() {
);
await client.close().catch(() => undefined);
const profile = await upsertDaemonFromOfferUrl(offerUrl, hostname ?? undefined);
const isNewHost = !daemons.some((daemon) => daemon.serverId === offer.serverId);
const profile = await upsertDaemonFromOfferUrl(offerUrl);
if (isNewHost) {
setPendingNameHost({ serverId: profile.serverId, hostname: null });
return;
}
returnToSource(profile.serverId);
} catch (error) {
@@ -251,10 +270,10 @@ export default function PairScanScreen() {
setIsPairing(false);
}
},
[daemons, isPairing, returnToSource, targetServerId, upsertDaemonFromOfferUrl],
[daemons, isPairing, pendingNameHost, returnToSource, targetServerId, upsertDaemonFromOfferUrl]
);
if (isWeb) {
if (Platform.OS === "web") {
return (
<View style={styles.container}>
<View style={[styles.header, { paddingTop: insets.top + theme.spacing[2] }]}>
@@ -282,6 +301,25 @@ export default function PairScanScreen() {
return (
<View style={styles.container}>
{pendingNameHost ? (
<NameHostModal
visible
serverId={pendingNameHost.serverId}
hostname={pendingNameHostname}
onSkip={() => {
const serverId = pendingNameHost.serverId;
setPendingNameHost(null);
returnToSource(serverId);
}}
onSave={(label) => {
const serverId = pendingNameHost.serverId;
void renameHost(serverId, label).finally(() => {
setPendingNameHost(null);
returnToSource(serverId);
});
}}
/>
) : null}
<View style={[styles.header, { paddingTop: insets.top + theme.spacing[2] }]}>
<Text style={styles.headerTitle}>Scan QR</Text>
<Pressable onPress={closeToSource}>
@@ -296,7 +334,10 @@ export default function PairScanScreen() {
<Text style={styles.permissionBody}>
Allow camera access to scan the pairing QR code from your daemon.
</Text>
<Pressable style={styles.permissionButton} onPress={() => void requestPermission()}>
<Pressable
style={styles.permissionButton}
onPress={() => void requestPermission()}
>
<Text style={styles.permissionButtonText}>Grant permission</Text>
</Pressable>
</View>
@@ -315,6 +356,9 @@ export default function PairScanScreen() {
<View style={[styles.corner, styles.cornerBL]} />
<View style={[styles.corner, styles.cornerBR]} />
</View>
<Text style={styles.helperText}>
Point your camera at the pairing QR code.
</Text>
{isPairing ? (
<Text style={[styles.helperText, { color: theme.colors.foreground }]}>
Pairing…
+37 -3
View File
@@ -1,23 +1,57 @@
import { useEffect, useMemo } from "react";
import { ActivityIndicator, View } from "react-native";
import { useRouter } from "expo-router";
import { useUnistyles } from "react-native-unistyles";
import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen";
import { useHosts } from "@/runtime/host-runtime";
import { useFormPreferences } from "@/hooks/use-form-preferences";
import { buildHostSettingsRoute } from "@/utils/host-routes";
export default function LegacySettingsRoute() {
const router = useRouter();
const { theme } = useUnistyles();
const daemons = useHosts();
const { preferences, isLoading: preferencesLoading } = useFormPreferences();
const targetServerId = useMemo(() => {
if (daemons.length === 0) {
return null;
}
if (preferences.serverId) {
const match = daemons.find(
(daemon) => daemon.serverId === preferences.serverId
);
if (match) {
return match.serverId;
}
}
return daemons[0]?.serverId ?? null;
}, [daemons]);
}, [daemons, preferences.serverId]);
useEffect(() => {
if (preferencesLoading) {
return;
}
if (!targetServerId) {
return;
}
router.replace(buildHostSettingsRoute(targetServerId));
}, [router, targetServerId]);
router.replace(buildHostSettingsRoute(targetServerId) as any);
}, [preferencesLoading, router, targetServerId]);
if (preferencesLoading) {
return (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: theme.colors.surface0,
}}
>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
</View>
);
}
if (!targetServerId) {
return <DraftAgentScreen />;
+2 -2
View File
@@ -1,5 +1,5 @@
import { WelcomeScreen } from "@/components/welcome-screen";
import { WelcomeScreen } from '@/components/welcome-screen'
export default function WelcomeRoute() {
return <WelcomeScreen />;
return <WelcomeScreen />
}
@@ -86,7 +86,10 @@ export function createLocalFileAttachmentStore(params: {
storageType: Extract<AttachmentStorageType, "desktop-file" | "native-file">;
baseDirectoryName: string;
resolvePreviewUrl: (attachment: AttachmentMetadata) => Promise<string>;
releasePreviewUrl?: (input: { attachment: AttachmentMetadata; url: string }) => Promise<void>;
releasePreviewUrl?: (input: {
attachment: AttachmentMetadata;
url: string;
}) => Promise<void>;
}): AttachmentStore {
const baseDirectory = FileSystem.cacheDirectory
? `${FileSystem.cacheDirectory}${params.baseDirectoryName}/`
@@ -198,7 +201,7 @@ export function createLocalFileAttachmentStore(params: {
await FileSystem.deleteAsync(`${baseDirectory}${entryName}`, {
idempotent: true,
});
}),
})
);
},
};
@@ -1,5 +1,4 @@
import { createLocalFileAttachmentStore } from "@/attachments/local-file-attachment-store";
import { isAbsolutePath } from "@/utils/path";
export function createNativeFileAttachmentStore() {
return createLocalFileAttachmentStore({
@@ -9,17 +8,8 @@ export function createNativeFileAttachmentStore() {
if (attachment.storageKey.startsWith("file://")) {
return attachment.storageKey;
}
if (isAbsolutePath(attachment.storageKey)) {
if (attachment.storageKey.startsWith("/")) {
return `file://${attachment.storageKey}`;
}
// UNC paths: \\server\share -> file://server/share
if (attachment.storageKey.startsWith("\\\\")) {
return `file:${attachment.storageKey.replace(/\\/g, "/")}`;
}
return `file:///${attachment.storageKey.replace(/\\/g, "/")}`;
if (attachment.storageKey.startsWith("/")) {
return `file://${attachment.storageKey}`;
}
return attachment.storageKey;
},
+8 -6
View File
@@ -47,7 +47,7 @@ export async function persistAttachmentFromFileUri(input: {
}
export async function encodeAttachmentsForSend(
attachments: readonly AttachmentMetadata[] | undefined,
attachments: readonly AttachmentMetadata[] | undefined
): Promise<Array<{ data: string; mimeType: string }> | undefined> {
if (!attachments || attachments.length === 0) {
return undefined;
@@ -69,16 +69,18 @@ export async function encodeAttachmentsForSend(
});
return null;
}
}),
})
);
const valid = encoded.filter(
(entry): entry is { data: string; mimeType: string } => entry !== null,
(entry): entry is { data: string; mimeType: string } => entry !== null
);
return valid.length > 0 ? valid : undefined;
}
export async function resolveAttachmentPreviewUrl(attachment: AttachmentMetadata): Promise<string> {
export async function resolveAttachmentPreviewUrl(
attachment: AttachmentMetadata
): Promise<string> {
const store = await getAttachmentStore();
return await store.resolvePreviewUrl({ attachment });
}
@@ -95,7 +97,7 @@ export async function releaseAttachmentPreviewUrl(input: {
}
export async function deleteAttachments(
attachments: readonly AttachmentMetadata[] | undefined,
attachments: readonly AttachmentMetadata[] | undefined
): Promise<void> {
if (!attachments || attachments.length === 0) {
return;
@@ -111,7 +113,7 @@ export async function deleteAttachments(
error,
});
}
}),
})
);
}
+10 -6
View File
@@ -1,23 +1,27 @@
import { isElectronRuntime } from "@/desktop/host";
import { Platform } from "react-native";
import { isTauriEnvironment } from "@/utils/tauri";
import type { AttachmentStore } from "@/attachments/types";
import { isWeb } from "@/constants/platform";
let attachmentStorePromise: Promise<AttachmentStore> | null = null;
async function createAttachmentStore(): Promise<AttachmentStore> {
if (isWeb) {
if (isElectronRuntime()) {
if (Platform.OS === "web") {
if (isTauriEnvironment()) {
const { createDesktopAttachmentStore } = await import(
"../desktop/attachments/desktop-attachment-store"
);
return createDesktopAttachmentStore();
}
const { createIndexedDbAttachmentStore } = await import("./web/indexeddb-attachment-store");
const { createIndexedDbAttachmentStore } = await import(
"./web/indexeddb-attachment-store"
);
return createIndexedDbAttachmentStore();
}
const { createNativeFileAttachmentStore } = await import("./native/native-file-attachment-store");
const { createNativeFileAttachmentStore } = await import(
"./native/native-file-attachment-store"
);
return createNativeFileAttachmentStore();
}
@@ -1,9 +1,12 @@
import { useEffect, useRef, useState } from "react";
import type { AttachmentMetadata } from "@/attachments/types";
import { releaseAttachmentPreviewUrl, resolveAttachmentPreviewUrl } from "@/attachments/service";
import {
releaseAttachmentPreviewUrl,
resolveAttachmentPreviewUrl,
} from "@/attachments/service";
export function useAttachmentPreviewUrl(
attachment: AttachmentMetadata | null | undefined,
attachment: AttachmentMetadata | null | undefined
): string | null {
const [url, setUrl] = useState<string | null>(null);
const activeAttachmentRef = useRef<AttachmentMetadata | null>(null);
@@ -49,7 +52,12 @@ export function useAttachmentPreviewUrl(
url: currentUrl,
});
};
}, [attachment?.id, attachment?.storageType, attachment?.storageKey, attachment?.mimeType]);
}, [
attachment?.id,
attachment?.storageType,
attachment?.storageKey,
attachment?.mimeType,
]);
return url;
}
@@ -1,24 +0,0 @@
import { describe, expect, it } from "vitest";
import { pathToFileUri } from "./utils";
describe("pathToFileUri", () => {
it("converts POSIX absolute paths to file URIs", () => {
expect(pathToFileUri("/home/user/file.txt")).toBe("file:///home/user/file.txt");
});
it("converts Windows drive-letter paths to file URIs", () => {
expect(pathToFileUri("C:\\Users\\file.txt")).toBe("file:///C:/Users/file.txt");
});
it("converts UNC paths to host-based file URIs", () => {
expect(pathToFileUri("\\\\server\\share\\dir")).toBe("file://server/share/dir");
});
it("passes through file URIs unchanged", () => {
expect(pathToFileUri("file:///already/uri")).toBe("file:///already/uri");
});
it("passes through relative paths unchanged", () => {
expect(pathToFileUri("relative/path")).toBe("relative/path");
});
});
+1 -13
View File
@@ -1,5 +1,4 @@
import { generateMessageId } from "@/types/stream";
import { isAbsolutePath } from "@/utils/path";
export function generateAttachmentId(): string {
return `att_${generateMessageId()}`;
@@ -54,21 +53,10 @@ export function pathToFileUri(path: string): string {
if (path.startsWith("file://")) {
return path;
}
if (!isAbsolutePath(path)) {
return path;
}
if (path.startsWith("/")) {
return `file://${path}`;
}
// UNC paths: \\server\share -> file://server/share
if (path.startsWith("\\\\")) {
return `file:${path.replace(/\\/g, "/")}`;
}
return `file:///${path.replace(/\\/g, "/")}`;
return path;
}
export function fileUriToPath(uri: string): string {
@@ -53,7 +53,7 @@ function openAttachmentDb(): Promise<IDBDatabase> {
function runTx<T>(
db: IDBDatabase,
mode: IDBTransactionMode,
run: (store: IDBObjectStore) => IDBRequest<T>,
run: (store: IDBObjectStore) => IDBRequest<T>
): Promise<T> {
return new Promise((resolve, reject) => {
const transaction = db.transaction(STORE_NAME, mode);
@@ -78,10 +78,7 @@ async function sourceToBlob(input: SaveAttachmentInput): Promise<{ blob: Blob; m
const source = input.source;
if (source.kind === "blob") {
const mimeType = normalizeMimeType(input.mimeType ?? source.blob.type);
const blob =
source.blob.type === mimeType
? source.blob
: source.blob.slice(0, source.blob.size, mimeType);
const blob = source.blob.type === mimeType ? source.blob : source.blob.slice(0, source.blob.size, mimeType);
return { blob, mimeType };
}
@@ -107,7 +104,7 @@ async function sourceToBlob(input: SaveAttachmentInput): Promise<{ blob: Blob; m
async function loadBlob(db: IDBDatabase, id: string): Promise<Blob> {
const record = await runTx<StoredBlobRecord | undefined>(db, "readonly", (store) =>
store.get(id),
store.get(id)
);
if (!record?.blob) {
throw new Error(`Attachment ${id} was not found in IndexedDB.`);
@@ -128,7 +125,7 @@ export function createIndexedDbAttachmentStore(): AttachmentStore {
try {
await runTx(db, "readwrite", (store) =>
store.put({ id, blob, createdAt, fileName } satisfies StoredBlobRecord),
store.put({ id, blob, createdAt, fileName } satisfies StoredBlobRecord)
);
} finally {
db.close();
@@ -187,9 +184,7 @@ export function createIndexedDbAttachmentStore(): AttachmentStore {
const cursorRequest = store.openCursor();
cursorRequest.onerror = () => {
reject(
cursorRequest.error ?? new Error("Failed to iterate IndexedDB attachment store."),
);
reject(cursorRequest.error ?? new Error("Failed to iterate IndexedDB attachment store."));
};
cursorRequest.onsuccess = () => {
@@ -36,12 +36,10 @@ function getModeName(modeId?: string, availableModes?: Agent["availableModes"]):
function getModeColor(modeId?: string): string {
if (!modeId) return "#9ca3af"; // gray
if (modeId.includes("bypass") || modeId.includes("full-access")) return "#ef4444"; // red - dangerous
if (modeId.includes("auto") || modeId.includes("build") || modeId.includes("acceptEdits"))
return "#3b82f6"; // blue - build/auto
if (modeId.includes("plan") || modeId.includes("architect")) return "#a855f7"; // purple - planning
if (modeId.includes("ask") || modeId.includes("read-only") || modeId === "default")
return "#22c55e"; // green - safest
// Color based on common mode types
if (modeId.includes("ask")) return "#f59e0b"; // orange - asks permission
if (modeId.includes("code")) return "#22c55e"; // green - writes code
if (modeId.includes("architect") || modeId.includes("plan")) return "#3b82f6"; // blue - plans
return "#9ca3af"; // gray - unknown
}
@@ -70,16 +68,16 @@ const styles = StyleSheet.create((theme) => ({
backButtonText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: "600",
textAlign: "center",
fontWeight: '600',
textAlign: 'center',
},
scrollView: {
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
},
processItem: {
flexDirection: "row",
alignItems: "center",
flexDirection: 'row',
alignItems: 'center',
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
@@ -106,7 +104,7 @@ const styles = StyleSheet.create((theme) => ({
processText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.xs,
fontWeight: "500",
fontWeight: '500',
},
processTextActive: {
color: theme.colors.primaryForeground,
@@ -149,21 +147,20 @@ export function ActiveProcesses({
onPress={onSelectOrchestrator}
style={({ pressed }) => [
styles.processItem,
viewMode === "orchestrator" ? styles.processItemActive : styles.processItemInactive,
viewMode === 'orchestrator' ? styles.processItemActive : styles.processItemInactive,
pressed && { opacity: 0.7 },
]}
>
<View style={styles.agentIcon} />
<Text
style={[styles.processText, viewMode === "orchestrator" && styles.processTextActive]}
>
Orchestrator
</Text>
<Text style={[
styles.processText,
viewMode === 'orchestrator' && styles.processTextActive,
]}>Orchestrator</Text>
</Pressable>
{/* Agent pills */}
{agents.map((agent) => {
const isActive = viewMode === "agent" && activeAgentId === agent.id;
const isActive = viewMode === 'agent' && activeAgentId === agent.id;
return (
<Pressable
@@ -177,21 +174,15 @@ export function ActiveProcesses({
>
<View style={styles.agentIcon} />
<Text style={[styles.processText, isActive && styles.processTextActive]}>
{agent.id.substring(0, 8)}
</Text>
<Text style={[
styles.processText,
isActive && styles.processTextActive,
]}>{agent.id.substring(0, 8)}</Text>
<View
style={[styles.statusDot, { backgroundColor: getAgentStatusColor(agent.status) }]}
/>
<View style={[styles.statusDot, { backgroundColor: getAgentStatusColor(agent.status) }]} />
{agent.currentModeId && (
<View
style={[
styles.modeIndicator,
{ backgroundColor: getModeColor(agent.currentModeId) },
]}
/>
<View style={[styles.modeIndicator, { backgroundColor: getModeColor(agent.currentModeId) }]} />
)}
</Pressable>
);
@@ -1,10 +1,17 @@
import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react";
import type { ReactNode } from "react";
import { createPortal } from "react-dom";
import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native";
import {
Modal,
Platform,
Pressable,
ScrollView,
Text,
TextInput,
View,
} from "react-native";
import type { TextInputProps } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { getOverlayRoot, OVERLAY_Z } from "../lib/overlay-root";
import {
BottomSheetModal,
@@ -14,7 +21,6 @@ import {
type BottomSheetBackgroundProps,
} from "@gorhom/bottom-sheet";
import { X } from "lucide-react-native";
import { isWeb } from "@/constants/platform";
const styles = StyleSheet.create((theme) => ({
desktopOverlay: {
@@ -30,8 +36,6 @@ const styles = StyleSheet.create((theme) => ({
width: "100%",
maxWidth: 520,
maxHeight: "85%",
flexShrink: 1,
minHeight: 0,
backgroundColor: theme.colors.surface1,
borderRadius: theme.borderRadius.xl,
borderWidth: 1,
@@ -58,13 +62,11 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.surface2,
},
desktopScroll: {
flexShrink: 1,
minHeight: 0,
flex: 1,
},
desktopContent: {
padding: theme.spacing[6],
gap: theme.spacing[4],
flexGrow: 1,
},
bottomSheetHandle: {
backgroundColor: theme.colors.surface2,
@@ -107,7 +109,6 @@ export interface AdaptiveModalSheetProps {
onClose: () => void;
children: ReactNode;
snapPoints?: string[];
stackBehavior?: "push" | "switch" | "replace";
testID?: string;
}
@@ -117,11 +118,11 @@ export function AdaptiveModalSheet({
onClose,
children,
snapPoints,
stackBehavior,
testID,
}: AdaptiveModalSheetProps) {
const { theme } = useUnistyles();
const isMobile = useIsCompactFormFactor();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const sheetRef = useRef<BottomSheetModal>(null);
const dismissingForVisibilityRef = useRef(false);
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
@@ -147,14 +148,19 @@ export function AdaptiveModalSheet({
onClose();
}
},
[onClose],
[onClose]
);
const renderBackdrop = useCallback(
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} />
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
opacity={0.45}
/>
),
[],
[]
);
if (isMobile) {
@@ -167,7 +173,6 @@ export function AdaptiveModalSheet({
onChange={handleSheetChange}
backdropComponent={renderBackdrop}
enablePanDownToClose
stackBehavior={stackBehavior}
backgroundComponent={SheetBackground}
handleIndicatorStyle={styles.bottomSheetHandle}
keyboardBehavior="extend"
@@ -175,7 +180,11 @@ export function AdaptiveModalSheet({
>
<View style={styles.bottomSheetHeader}>
<Text style={styles.title}>{title}</Text>
<Pressable accessibilityLabel="Close" style={styles.closeButton} onPress={onClose}>
<Pressable
accessibilityLabel="Close"
style={styles.closeButton}
onPress={onClose}
>
<X size={16} color={theme.colors.foregroundMuted} />
</Pressable>
</View>
@@ -200,7 +209,11 @@ export function AdaptiveModalSheet({
<View style={styles.desktopCard}>
<View style={styles.header}>
<Text style={styles.title}>{title}</Text>
<Pressable accessibilityLabel="Close" style={styles.closeButton} onPress={onClose}>
<Pressable
accessibilityLabel="Close"
style={styles.closeButton}
onPress={onClose}
>
<X size={16} color={theme.colors.foregroundMuted} />
</Pressable>
</View>
@@ -217,7 +230,7 @@ export function AdaptiveModalSheet({
);
// On web, use portal to overlay root for consistent stacking with toasts
if (isWeb && typeof document !== "undefined") {
if (Platform.OS === "web" && typeof document !== "undefined") {
if (!visible) return null;
return createPortal(desktopContent, getOverlayRoot());
}
@@ -241,12 +254,13 @@ export function AdaptiveModalSheet({
*/
export const AdaptiveTextInput = forwardRef<TextInput, TextInputProps>(
function AdaptiveTextInput(props, ref) {
const isMobile = useIsCompactFormFactor();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
if (isMobile) {
return <BottomSheetTextInput ref={ref as any} {...props} />;
}
return <TextInput ref={ref} {...props} />;
},
}
);
@@ -1,9 +1,8 @@
import { useCallback } from "react";
import { Pressable, Text, View } from "react-native";
import { Pressable, Text, View, Platform } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
import { AdaptiveModalSheet } from "./adaptive-modal-sheet";
import { isNative } from "@/constants/platform";
const styles = StyleSheet.create((theme) => ({
option: {
@@ -61,17 +60,8 @@ export function AddHostMethodModal({
}, [onPasteLink]);
return (
<AdaptiveModalSheet
title="Add connection"
visible={visible}
onClose={onClose}
testID="add-host-method-modal"
>
<Pressable
style={styles.option}
onPress={handleDirect}
accessibilityLabel="Direct connection"
>
<AdaptiveModalSheet title="Add connection" visible={visible} onClose={onClose} testID="add-host-method-modal">
<Pressable style={styles.option} onPress={handleDirect} accessibilityLabel="Direct connection">
<Link2 size={18} color={theme.colors.foreground} />
<View style={styles.optionBody}>
<Text style={styles.optionText}>Direct connection</Text>
@@ -79,7 +69,7 @@ export function AddHostMethodModal({
</View>
</Pressable>
{isNative ? (
{Platform.OS !== "web" ? (
<Pressable style={styles.option} onPress={handleScan} accessibilityLabel="Scan QR code">
<QrCode size={18} color={theme.colors.foreground} />
<View style={styles.optionBody}>
@@ -89,11 +79,7 @@ export function AddHostMethodModal({
</Pressable>
) : null}
<Pressable
style={styles.option}
onPress={handlePaste}
accessibilityLabel="Paste pairing link"
>
<Pressable style={styles.option} onPress={handlePaste} accessibilityLabel="Paste pairing link">
<ClipboardPaste size={18} color={theme.colors.foreground} />
<View style={styles.optionBody}>
<Text style={styles.optionText}>Paste pairing link</Text>
+12 -46
View File
@@ -1,7 +1,6 @@
import { useCallback, useRef, useState } from "react";
import { Alert, Text, TextInput, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { Link2 } from "lucide-react-native";
import type { HostProfile } from "@/types/host-connection";
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
@@ -61,8 +60,8 @@ function formatTechnicalTransportDetails(details: Array<string | null>): string
.map((value) => normalizeTransportMessage(value))
.filter((value): value is string => Boolean(value))
.map((value) => value.trim())
.filter((value) => value.length > 0),
),
.filter((value) => value.length > 0)
)
);
if (unique.length === 0) return null;
@@ -79,10 +78,7 @@ function formatTechnicalTransportDetails(details: Array<string | null>): string
return unique.join(" — ");
}
function buildConnectionFailureCopy(
endpoint: string,
error: unknown,
): { title: string; detail: string | null; raw: string | null } {
function buildConnectionFailureCopy(endpoint: string, error: unknown): { title: string; detail: string | null; raw: string | null } {
const title = `We failed to connect to ${endpoint}.`;
const raw = (() => {
@@ -113,13 +109,8 @@ function buildConnectionFailureCopy(
detail = "Host not found. Check the hostname and try again.";
} else if (rawLower.includes("ehostunreach") || rawLower.includes("host is unreachable")) {
detail = "Host is unreachable. Check your network and firewall.";
} else if (
rawLower.includes("certificate") ||
rawLower.includes("tls") ||
rawLower.includes("ssl")
) {
detail =
"TLS error. Direct connections use an unencrypted local connection. Use relay for remote access.";
} else if (rawLower.includes("certificate") || rawLower.includes("tls") || rawLower.includes("ssl")) {
detail = "TLS error. Direct connections use an unencrypted local connection. Use relay for remote access.";
} else if (raw) {
detail = "Unable to connect. Check the host/port and that the daemon is reachable.";
} else {
@@ -134,25 +125,15 @@ export interface AddHostModalProps {
onClose: () => void;
targetServerId?: string;
onCancel?: () => void;
onSaved?: (result: {
profile: HostProfile;
serverId: string;
hostname: string | null;
isNewHost: boolean;
}) => void;
onSaved?: (result: { profile: HostProfile; serverId: string; hostname: string | null; isNewHost: boolean }) => void;
}
export function AddHostModal({
visible,
onClose,
onCancel,
onSaved,
targetServerId,
}: AddHostModalProps) {
export function AddHostModal({ visible, onClose, onCancel, onSaved, targetServerId }: AddHostModalProps) {
const { theme } = useUnistyles();
const daemons = useHosts();
const { upsertDirectConnection } = useHostMutations();
const isMobile = useIsCompactFormFactor();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const hostInputRef = useRef<TextInput>(null);
@@ -219,7 +200,6 @@ export function AddHostModal({
const profile = await upsertDirectConnection({
serverId,
endpoint,
label: hostname ?? undefined,
});
onSaved?.({ profile, serverId, hostname, isNewHost });
@@ -240,24 +220,10 @@ export function AddHostModal({
} finally {
setIsSaving(false);
}
}, [
daemons,
endpointRaw,
handleClose,
isMobile,
isSaving,
onSaved,
targetServerId,
upsertDirectConnection,
]);
}, [daemons, endpointRaw, handleClose, isMobile, isSaving, onSaved, targetServerId, upsertDirectConnection]);
return (
<AdaptiveModalSheet
title="Direct connection"
visible={visible}
onClose={handleClose}
testID="add-host-modal"
>
<AdaptiveModalSheet title="Direct connection" visible={visible} onClose={handleClose} testID="add-host-modal">
<Text style={styles.helper}>Enter the address of a Paseo server.</Text>
<View style={styles.field}>
+111 -81
View File
@@ -1,77 +1,76 @@
import { useState } from "react";
import { View, Text, Pressable } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useState } from 'react';
import { View, Text, Pressable } from 'react-native';
import { StyleSheet } from 'react-native-unistyles';
import { Fonts } from "@/constants/theme";
import type {
AgentActivity,
GroupedTextMessage,
MergedToolCall,
SessionUpdate,
} from "@/types/agent-activity";
import type { AgentActivity, GroupedTextMessage, MergedToolCall, SessionUpdate } from '@/types/agent-activity';
interface AgentActivityItemProps {
item: GroupedTextMessage | MergedToolCall | AgentActivity;
}
function formatTimestamp(date: Date): string {
return new Intl.DateTimeFormat("en-US", {
hour: "numeric",
minute: "2-digit",
second: "2-digit",
return new Intl.DateTimeFormat('en-US', {
hour: 'numeric',
minute: '2-digit',
second: '2-digit',
hour12: true,
}).format(date);
}
function getToolIcon(toolKind?: string): string {
switch (toolKind) {
case "read":
return "📖";
case "edit":
return "✏️";
case "delete":
return "🗑️";
case "move":
return "📦";
case "search":
return "🔍";
case "execute":
return "▶️";
case "think":
return "💭";
case "fetch":
return "🌐";
case "switch_mode":
return "🔄";
case 'read':
return '📖';
case 'edit':
return '✏️';
case 'delete':
return '🗑️';
case 'move':
return '📦';
case 'search':
return '🔍';
case 'execute':
return '▶️';
case 'think':
return '💭';
case 'fetch':
return '🌐';
case 'switch_mode':
return '🔄';
default:
return "🔧";
return '🔧';
}
}
function getStatusColor(status?: string): string {
switch (status) {
case "pending":
return "#9ca3af";
case "in_progress":
return "#fbbf24";
case "completed":
return "#22c55e";
case "failed":
return "#ef4444";
case 'pending':
return '#9ca3af';
case 'in_progress':
return '#fbbf24';
case 'completed':
return '#22c55e';
case 'failed':
return '#ef4444';
default:
return "#6b7280";
return '#6b7280';
}
}
function GroupedTextItem({ item }: { item: GroupedTextMessage }) {
const isThought = item.messageType === "thought";
const isThought = item.messageType === 'thought';
return (
<View style={[stylesheet.card, isThought && stylesheet.thoughtCard]}>
<Text style={[stylesheet.timestamp, isThought && stylesheet.thoughtTimestamp]}>
{formatTimestamp(item.startTimestamp)}
</Text>
{isThought && <Text style={stylesheet.thoughtLabel}>💭 Thinking</Text>}
<Text style={[stylesheet.text, isThought && stylesheet.thoughtText]}>{item.text}</Text>
{isThought && (
<Text style={stylesheet.thoughtLabel}>💭 Thinking</Text>
)}
<Text style={[stylesheet.text, isThought && stylesheet.thoughtText]}>
{item.text}
</Text>
</View>
);
}
@@ -81,20 +80,28 @@ function MergedToolCallItem({ item }: { item: MergedToolCall }) {
return (
<View style={stylesheet.toolCard}>
<Pressable onPress={() => setIsExpanded(!isExpanded)} style={stylesheet.toolHeader}>
<Pressable
onPress={() => setIsExpanded(!isExpanded)}
style={stylesheet.toolHeader}
>
<View style={stylesheet.toolHeaderLeft}>
<Text style={stylesheet.timestamp}>{formatTimestamp(item.startTimestamp)}</Text>
<Text style={stylesheet.timestamp}>
{formatTimestamp(item.startTimestamp)}
</Text>
<View style={stylesheet.toolTitleRow}>
<Text style={stylesheet.toolIcon}>{getToolIcon(item.toolKind)}</Text>
<Text style={stylesheet.toolTitle}>{item.title}</Text>
<View
style={[stylesheet.statusBadge, { backgroundColor: getStatusColor(item.status) }]}
style={[
stylesheet.statusBadge,
{ backgroundColor: getStatusColor(item.status) },
]}
>
<Text style={stylesheet.statusText}>{item.status}</Text>
</View>
</View>
</View>
<Text style={stylesheet.expandIcon}>{isExpanded ? "▼" : "▶"}</Text>
<Text style={stylesheet.expandIcon}>{isExpanded ? '▼' : '▶'}</Text>
</Pressable>
{isExpanded && (
@@ -102,17 +109,23 @@ function MergedToolCallItem({ item }: { item: MergedToolCall }) {
{item.input && (
<View style={stylesheet.section}>
<Text style={stylesheet.sectionTitle}>Input:</Text>
<Text style={stylesheet.code}>{JSON.stringify(item.input, null, 2)}</Text>
<Text style={stylesheet.code}>
{JSON.stringify(item.input, null, 2)}
</Text>
</View>
)}
{item.output && (
<View style={stylesheet.section}>
<Text style={stylesheet.sectionTitle}>Output:</Text>
<Text style={stylesheet.code}>{JSON.stringify(item.output, null, 2)}</Text>
<Text style={stylesheet.code}>
{JSON.stringify(item.output, null, 2)}
</Text>
</View>
)}
{!item.input && !item.output && (
<Text style={stylesheet.emptyText}>No details available</Text>
<Text style={stylesheet.emptyText}>
No details available
</Text>
)}
</View>
)}
@@ -123,26 +136,36 @@ function MergedToolCallItem({ item }: { item: MergedToolCall }) {
function PlanItem({ update, timestamp }: { update: SessionUpdate; timestamp: Date }) {
const [isExpanded, setIsExpanded] = useState(true);
if (update.kind !== "plan") {
if (update.kind !== 'plan') {
return null;
}
return (
<View style={stylesheet.planCard}>
<Pressable onPress={() => setIsExpanded(!isExpanded)} style={stylesheet.planHeader}>
<Pressable
onPress={() => setIsExpanded(!isExpanded)}
style={stylesheet.planHeader}
>
<View style={stylesheet.planHeaderLeft}>
<Text style={stylesheet.timestamp}>{formatTimestamp(timestamp)}</Text>
<Text style={stylesheet.planTitle}>📋 Tasks ({update.entries.length})</Text>
<Text style={stylesheet.planTitle}>
📋 Tasks ({update.entries.length})
</Text>
</View>
<Text style={stylesheet.expandIcon}>{isExpanded ? "▼" : "▶"}</Text>
<Text style={stylesheet.expandIcon}>{isExpanded ? '▼' : '▶'}</Text>
</Pressable>
{isExpanded && (
<View style={stylesheet.planContent}>
{update.entries.map((entry, idx) => (
<View key={idx} style={stylesheet.planEntry}>
<Text style={[stylesheet.planEntryStatus, { color: getStatusColor(entry.status) }]}>
{entry.status === "completed" ? "✓" : entry.status === "in_progress" ? "⏳" : "○"}
<Text
style={[
stylesheet.planEntryStatus,
{ color: getStatusColor(entry.status) },
]}
>
{entry.status === 'completed' ? '✓' : entry.status === 'in_progress' ? '⏳' : '○'}
</Text>
<Text style={stylesheet.planEntryText}>{entry.content}</Text>
</View>
@@ -158,7 +181,10 @@ function UnknownActivityItem({ update, timestamp }: { update: SessionUpdate; tim
return (
<View style={stylesheet.unknownCard}>
<Pressable onPress={() => setShowDrawer(!showDrawer)} style={stylesheet.unknownHeader}>
<Pressable
onPress={() => setShowDrawer(!showDrawer)}
style={stylesheet.unknownHeader}
>
<Text style={stylesheet.timestamp}>{formatTimestamp(timestamp)}</Text>
<View style={stylesheet.unknownBadge}>
<Text style={stylesheet.unknownBadgeText}>{update.kind}</Text>
@@ -167,7 +193,9 @@ function UnknownActivityItem({ update, timestamp }: { update: SessionUpdate; tim
{showDrawer && (
<View style={stylesheet.drawerContent}>
<Text style={stylesheet.code}>{JSON.stringify(update, null, 2)}</Text>
<Text style={stylesheet.code}>
{JSON.stringify(update, null, 2)}
</Text>
</View>
)}
</View>
@@ -176,12 +204,12 @@ function UnknownActivityItem({ update, timestamp }: { update: SessionUpdate; tim
export function AgentActivityItem({ item }: AgentActivityItemProps) {
// Grouped text message
if ("kind" in item && item.kind === "grouped_text") {
if ('kind' in item && item.kind === 'grouped_text') {
return <GroupedTextItem item={item} />;
}
// Merged tool call
if ("kind" in item && item.kind === "merged_tool_call") {
if ('kind' in item && item.kind === 'merged_tool_call') {
return <MergedToolCallItem item={item} />;
}
@@ -190,12 +218,12 @@ export function AgentActivityItem({ item }: AgentActivityItemProps) {
const update = activity.update;
// Tasks
if (update.kind === "plan") {
if (update.kind === 'plan') {
return <PlanItem update={update} timestamp={activity.timestamp} />;
}
// Available commands update
if (update.kind === "available_commands_update") {
if (update.kind === 'available_commands_update') {
return (
<View style={stylesheet.card}>
<Text style={stylesheet.timestamp}>{formatTimestamp(activity.timestamp)}</Text>
@@ -207,11 +235,13 @@ export function AgentActivityItem({ item }: AgentActivityItemProps) {
}
// Current mode update
if (update.kind === "current_mode_update") {
if (update.kind === 'current_mode_update') {
return (
<View style={stylesheet.card}>
<Text style={stylesheet.timestamp}>{formatTimestamp(activity.timestamp)}</Text>
<Text style={stylesheet.infoText}>Mode changed to: {update.currentModeId}</Text>
<Text style={stylesheet.infoText}>
Mode changed to: {update.currentModeId}
</Text>
</View>
);
}
@@ -254,26 +284,26 @@ const stylesheet = StyleSheet.create((theme) => ({
},
thoughtText: {
color: theme.colors.foregroundMuted,
fontStyle: "italic",
fontStyle: 'italic',
},
toolCard: {
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
marginBottom: theme.spacing[2],
overflow: "hidden",
overflow: 'hidden',
},
toolHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: theme.spacing[3],
},
toolHeaderLeft: {
flex: 1,
},
toolTitleRow: {
flexDirection: "row",
alignItems: "center",
flexDirection: 'row',
alignItems: 'center',
gap: theme.spacing[2],
marginTop: theme.spacing[1],
},
@@ -325,18 +355,18 @@ const stylesheet = StyleSheet.create((theme) => ({
emptyText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontStyle: "italic",
fontStyle: 'italic',
},
planCard: {
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
marginBottom: theme.spacing[2],
overflow: "hidden",
overflow: 'hidden',
},
planHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: theme.spacing[3],
},
planHeaderLeft: {
@@ -354,8 +384,8 @@ const stylesheet = StyleSheet.create((theme) => ({
padding: theme.spacing[3],
},
planEntry: {
flexDirection: "row",
alignItems: "flex-start",
flexDirection: 'row',
alignItems: 'flex-start',
gap: theme.spacing[2],
marginBottom: theme.spacing[2],
},
@@ -376,7 +406,7 @@ const stylesheet = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
marginBottom: theme.spacing[2],
overflow: "hidden",
overflow: 'hidden',
},
unknownHeader: {
padding: theme.spacing[3],
@@ -387,7 +417,7 @@ const stylesheet = StyleSheet.create((theme) => ({
paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.md,
marginTop: theme.spacing[2],
alignSelf: "flex-start",
alignSelf: 'flex-start',
},
unknownBadgeText: {
color: theme.colors.foreground,
@@ -1,6 +1,13 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ReactElement, ReactNode } from "react";
import { View, Text, Pressable, TextInput, ActivityIndicator } from "react-native";
import {
View,
Text,
Pressable,
TextInput,
ActivityIndicator,
Platform,
} from "react-native";
import type { StyleProp, ViewStyle, TextProps } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import {
@@ -10,18 +17,7 @@ import {
BottomSheetBackgroundProps,
} from "@gorhom/bottom-sheet";
import Animated from "react-native-reanimated";
import {
ChevronDown,
ChevronRight,
Pencil,
Check,
X,
Bot,
Brain,
ShieldCheck,
ShieldAlert,
ShieldOff,
} from "lucide-react-native";
import { ChevronDown, ChevronRight, Pencil, Check, X, Bot, Brain, Shield } from "lucide-react-native";
import { theme as defaultTheme } from "@/styles/theme";
import type {
AgentMode,
@@ -29,24 +25,7 @@ import type {
AgentProvider,
} from "@server/server/agent/agent-sdk-types";
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
import { getModeVisuals, type AgentModeIcon } from "@server/server/agent/provider-manifest";
import { Combobox, ComboboxItem, ComboboxEmpty } from "@/components/ui/combobox";
import { baseColors } from "@/styles/theme";
import { isNative } from "@/constants/platform";
const MODE_ICON_MAP: Record<AgentModeIcon, typeof ShieldCheck> = {
ShieldCheck,
ShieldAlert,
ShieldOff,
};
const MODE_COLOR_MAP: Record<string, string> = {
default: baseColors.blue[500],
safe: baseColors.green[500],
moderate: baseColors.amber[500],
dangerous: baseColors.red[500],
readonly: baseColors.purple[500],
};
type DropdownTriggerRenderProps = {
label: string;
@@ -112,14 +91,19 @@ export function DropdownField({
testID={testID}
style={[styles.dropdownControl, disabled && styles.dropdownControlDisabled]}
>
<Text style={value ? styles.dropdownValue : styles.dropdownPlaceholder} numberOfLines={1}>
<Text
style={value ? styles.dropdownValue : styles.dropdownPlaceholder}
numberOfLines={1}
>
{value || placeholder}
</Text>
<ChevronDown size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
</Pressable>
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
{warningMessage ? <Text style={styles.warningText}>{warningMessage}</Text> : null}
{!errorMessage && helperText ? <Text style={styles.helperText}>{helperText}</Text> : null}
{!errorMessage && helperText ? (
<Text style={styles.helperText}>{helperText}</Text>
) : null}
</View>
);
}
@@ -169,14 +153,14 @@ export function SelectField({
const handleKeyDown = useCallback(
(event: unknown) => {
if (isNative) return;
if (Platform.OS !== "web") return;
const key = getWebKey(event);
if (key === "Enter" || key === " ") {
preventWebDefault(event);
onPress();
}
},
[getWebKey, onPress, preventWebDefault],
[getWebKey, onPress, preventWebDefault]
);
const normalizedValue = (value ?? "").trim();
@@ -184,7 +168,9 @@ export function SelectField({
const hasConcreteValue =
normalizedValue.length > 0 &&
(normalizedPlaceholder.length === 0 || normalizedValue !== normalizedPlaceholder);
const displayText = hasConcreteValue ? normalizedValue : normalizedPlaceholder || "Select...";
const displayText = hasConcreteValue
? normalizedValue
: (normalizedPlaceholder || "Select...");
return (
<View style={styles.selectFieldContainer}>
@@ -229,7 +215,12 @@ interface DropdownSheetProps {
}
function DropdownSheetBackground({ style }: BottomSheetBackgroundProps) {
return <Animated.View pointerEvents="none" style={[style, styles.bottomSheetBackground]} />;
return (
<Animated.View
pointerEvents="none"
style={[style, styles.bottomSheetBackground]}
/>
);
}
export function DropdownSheet({
@@ -260,14 +251,19 @@ export function DropdownSheet({
onClose();
}
},
[onClose],
[onClose]
);
const renderBackdrop = useCallback(
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} />
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
opacity={0.45}
/>
),
[],
[]
);
return (
@@ -431,21 +427,23 @@ export function FormSelectTrigger({
const handleKeyDown = useCallback(
(event: unknown) => {
if (isNative) return;
if (Platform.OS !== "web") return;
const key = getWebKey(event);
if (key === "Enter" || key === " ") {
preventWebDefault(event);
onPress();
}
},
[getWebKey, onPress, preventWebDefault],
[getWebKey, onPress, preventWebDefault]
);
const normalizedValue = (value ?? "").trim();
const normalizedPlaceholder = (placeholder ?? "").trim();
const hasConcreteValue =
normalizedValue.length > 0 &&
(normalizedPlaceholder.length === 0 || normalizedValue !== normalizedPlaceholder);
const displayText = hasConcreteValue ? normalizedValue : normalizedPlaceholder || "Select...";
const displayText = hasConcreteValue
? normalizedValue
: (normalizedPlaceholder || "Select...");
return (
<Pressable
@@ -467,7 +465,9 @@ export function FormSelectTrigger({
>
{icon ? <View style={styles.compactSelectLeading}>{icon}</View> : null}
<View style={styles.compactSelectValueContainer}>
{showLabel ? <Text style={styles.compactSelectLabel}>{label}</Text> : null}
{showLabel ? (
<Text style={styles.compactSelectLabel}>{label}</Text>
) : null}
{isLoading ? (
<ActivityIndicator size="small" color={defaultTheme.colors.foregroundMuted} />
) : (
@@ -524,7 +524,7 @@ export function AgentConfigRow({
id: def.id,
label: def.label,
})),
[providerDefinitions],
[providerDefinitions]
);
const modeSelectOptions: ComboSelectOption[] = useMemo(() => {
@@ -538,10 +538,16 @@ export function AgentConfigRow({
}, [modeOptions]);
const modelSelectOptions: ComboSelectOption[] = useMemo(() => {
return models.map((model) => ({
id: model.id,
label: model.label,
}));
const opts: ComboSelectOption[] = [
{ id: "", label: "Auto" },
];
for (const model of models) {
opts.push({
id: model.id,
label: model.label,
});
}
return opts;
}, [models]);
const thinkingSelectOptions: ComboSelectOption[] = useMemo(
@@ -550,21 +556,13 @@ export function AgentConfigRow({
id: option.id,
label: option.label,
})),
[thinkingOptions],
[thinkingOptions]
);
const effectiveSelectedMode = selectedMode || (modeOptions.length > 0 ? modeOptions[0]?.id : "");
const effectiveSelectedThinkingOption =
selectedThinkingOptionId || thinkingSelectOptions[0]?.id || "";
const selectedModeVisuals = getModeVisuals(
selectedProvider,
effectiveSelectedMode,
providerDefinitions,
);
const ModeIcon = MODE_ICON_MAP[selectedModeVisuals?.icon ?? "ShieldCheck"];
const modeIconColor = MODE_COLOR_MAP[selectedModeVisuals?.colorTier ?? "safe"];
return (
<View style={styles.agentConfigRow}>
<View style={styles.agentConfigColumn}>
@@ -587,13 +585,11 @@ export function AgentConfigRow({
title="Select model"
value={selectedModel}
options={modelSelectOptions}
placeholder={isModelLoading ? "Loading..." : "Select model"}
placeholder="Auto"
disabled={disabled}
isLoading={isModelLoading}
onSelect={onSelectModel}
icon={
<Brain size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
}
icon={<Brain size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />}
showLabel={false}
testID="draft-model-select"
/>
@@ -607,7 +603,7 @@ export function AgentConfigRow({
placeholder="Default"
disabled={disabled || modeOptions.length === 0}
onSelect={onSelectMode}
icon={<ModeIcon size={defaultTheme.iconSize.md} color={modeIconColor} />}
icon={<Shield size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />}
showLabel={false}
testID="draft-mode-select"
/>
@@ -622,9 +618,7 @@ export function AgentConfigRow({
placeholder="Select..."
disabled={disabled}
onSelect={onSelectThinkingOption}
icon={
<Brain size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
}
icon={<Brain size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />}
showLabel={false}
/>
</View>
@@ -650,7 +644,7 @@ export function AssistantDropdown({
const anchorRef = useRef<View>(null);
const selectedDefinition = providerDefinitions.find(
(definition) => definition.id === selectedProvider,
(definition) => definition.id === selectedProvider
);
const options = useMemo(
@@ -659,7 +653,7 @@ export function AssistantDropdown({
id: def.id,
label: def.label,
})),
[providerDefinitions],
[providerDefinitions]
);
const handleOpen = useCallback(() => setIsOpen(true), []);
@@ -706,9 +700,9 @@ export function PermissionsDropdown({
const hasOptions = modeOptions.length > 0;
const selectedModeLabel = hasOptions
? (modeOptions.find((mode) => mode.id === selectedMode)?.label ??
? modeOptions.find((mode) => mode.id === selectedMode)?.label ??
modeOptions[0]?.label ??
"Default")
"Default"
: "Automatic";
const options = useMemo(
@@ -718,7 +712,7 @@ export function PermissionsDropdown({
label: mode.label,
description: mode.description,
})),
[modeOptions],
[modeOptions]
);
const handleOpen = useCallback(() => {
@@ -737,7 +731,9 @@ export function PermissionsDropdown({
onPress={handleOpen}
disabled={disabled || !hasOptions}
helperText={
hasOptions ? undefined : "This assistant does not expose selectable permissions."
hasOptions
? undefined
: "This assistant does not expose selectable permissions."
}
controlRef={anchorRef}
/>
@@ -778,9 +774,10 @@ export function ModelDropdown({
const [isOpen, setIsOpen] = useState(false);
const anchorRef = useRef<View>(null);
const selectedLabel =
models.find((model) => model.id === selectedModel)?.label ?? selectedModel ?? "Select model";
const placeholder = isLoading && models.length === 0 ? "Loading..." : "Select model";
const selectedLabel = selectedModel
? models.find((model) => model.id === selectedModel)?.label ?? selectedModel
: "Automatic";
const placeholder = isLoading && models.length === 0 ? "Loading..." : "Automatic";
const helperText = error
? undefined
: isLoading
@@ -790,20 +787,34 @@ export function ModelDropdown({
: undefined;
const options = useMemo(() => {
return models.map((model) => ({
id: model.id,
label: model.label,
description: model.description,
}));
const opts: ComboSelectOption[] = [
{
id: "",
label: "Automatic (provider default)",
description: "Let the assistant pick the recommended model.",
},
];
for (const model of models) {
opts.push({
id: model.id,
label: model.label,
description: model.description,
});
}
return opts;
}, [models]);
const handleOpen = useCallback(() => setIsOpen(true), []);
const handleOpenChange = useCallback((open: boolean) => setIsOpen(open), []);
const handleSelect = useCallback(
(id: string) => {
onSelect(id);
if (id === "") {
onClear();
} else {
onSelect(id);
}
},
[onSelect],
[onClear, onSelect]
);
return (
@@ -851,7 +862,7 @@ export function WorkingDirectoryDropdown({
const options = useMemo(
() => suggestedPaths.map((path) => ({ id: path, label: path })),
[suggestedPaths],
[suggestedPaths]
);
const handleOpen = useCallback(() => setIsOpen(true), []);
@@ -926,7 +937,9 @@ export function ToggleRow({
</View>
<View style={styles.toggleTextContainer}>
<Text style={styles.toggleLabel}>{label}</Text>
{description ? <Text style={styles.helperText}>{description}</Text> : null}
{description ? (
<Text style={styles.helperText}>{description}</Text>
) : null}
</View>
</Pressable>
);
@@ -1019,7 +1032,9 @@ export function GitOptionsSection({
<View style={styles.gitOptionsContainer}>
<Pressable
testID="worktree-create-toggle"
onPress={() => onWorktreeModeChange(isCreateMode ? "none" : "create")}
onPress={() =>
onWorktreeModeChange(isCreateMode ? "none" : "create")
}
disabled={isLoading}
style={[styles.worktreeToggle, isLoading && styles.worktreeToggleDisabled]}
>
@@ -1042,7 +1057,9 @@ export function GitOptionsSection({
<Pressable
testID="worktree-attach-toggle"
onPress={() => onWorktreeModeChange(isAttachMode ? "none" : "attach")}
onPress={() =>
onWorktreeModeChange(isAttachMode ? "none" : "attach")
}
disabled={isLoading}
style={[styles.worktreeToggle, isLoading && styles.worktreeToggleDisabled]}
>
@@ -1106,15 +1123,8 @@ export function GitOptionsSection({
placeholderTextColor={defaultTheme.colors.foregroundMuted}
onSubmitEditing={handleConfirmEdit}
/>
<Pressable
onPress={handleConfirmEdit}
hitSlop={8}
style={styles.baseBranchIconButton}
>
<Check
size={defaultTheme.iconSize.md}
color={defaultTheme.colors.palette.green[500]}
/>
<Pressable onPress={handleConfirmEdit} hitSlop={8} style={styles.baseBranchIconButton}>
<Check size={defaultTheme.iconSize.md} color={defaultTheme.colors.palette.green[500]} />
</Pressable>
<Pressable onPress={handleCancelEdit} hitSlop={8} style={styles.baseBranchIconButton}>
<X size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
@@ -1129,11 +1139,17 @@ export function GitOptionsSection({
</View>
) : null}
{baseBranchError ? <Text style={styles.errorText}>{baseBranchError}</Text> : null}
{baseBranchError ? (
<Text style={styles.errorText}>{baseBranchError}</Text>
) : null}
{repoError ? <Text style={styles.errorText}>{repoError}</Text> : null}
{repoError ? (
<Text style={styles.errorText}>{repoError}</Text>
) : null}
{gitValidationError ? <Text style={styles.errorText}>{gitValidationError}</Text> : null}
{gitValidationError ? (
<Text style={styles.errorText}>{gitValidationError}</Text>
) : null}
</View>
);
}
@@ -1450,7 +1466,11 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.borderAccent,
...theme.shadow.md,
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 8,
elevation: 8,
maxHeight: 400,
overflow: "hidden",
},
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { shouldSkipDraftPersist } from "./agent-input-area.draft-persist-guard";
describe("shouldSkipDraftPersist", () => {
it("blocks persist while hydrate for current uncontrolled generation is incomplete", () => {
expect(
shouldSkipDraftPersist({
isControlled: false,
currentGeneration: 2,
hydratedGeneration: 1,
isCurrentGeneration: true,
})
).toBe(true);
});
it("allows persist after hydrate completes for current generation", () => {
expect(
shouldSkipDraftPersist({
isControlled: false,
currentGeneration: 3,
hydratedGeneration: 3,
isCurrentGeneration: true,
})
).toBe(false);
});
it("blocks persist for stale generations", () => {
expect(
shouldSkipDraftPersist({
isControlled: false,
currentGeneration: 4,
hydratedGeneration: 4,
isCurrentGeneration: false,
})
).toBe(true);
});
it("does not block controlled draft persistence", () => {
expect(
shouldSkipDraftPersist({
isControlled: true,
currentGeneration: 0,
hydratedGeneration: 0,
isCurrentGeneration: true,
})
).toBe(false);
});
});
@@ -0,0 +1,20 @@
export function shouldSkipDraftPersist(input: {
isControlled: boolean;
currentGeneration: number;
hydratedGeneration: number;
isCurrentGeneration: boolean;
}): boolean {
if (input.isControlled) {
return false;
}
if (input.currentGeneration <= 0) {
return true;
}
if (!input.isCurrentGeneration) {
return true;
}
return input.hydratedGeneration !== input.currentGeneration;
}

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