Compare commits

...

37 Commits

Author SHA1 Message Date
Mohamed Boudra
69d8427bd9 chore(release): cut 0.1.26 2026-03-12 21:34:33 +07:00
Mohamed Boudra
d8ccbd5c32 docs: update CHANGELOG for 0.1.26 2026-03-12 21:34:22 +07:00
Mohamed Boudra
be93f8b240 refactor: remove performance monitoring and diagnostics infrastructure 2026-03-12 21:15:01 +07:00
Mohamed Boudra
b09a731d85 refactor: replace welcome message with server_info status payload
Unify the initial connection handshake and capability update paths.
The server now sends a server_info status message on connect and
whenever capabilities change, enabling the onboarding flow where
voice becomes available after models are configured.
2026-03-12 20:38:03 +07:00
Mohamed Boudra
554017b0b8 refactor: move daemon registry into host-runtime store 2026-03-12 19:02:06 +07:00
Mohamed Boudra
9f089be946 refactor: rename sockPath to listen in pid lock, add startup timing instrumentation 2026-03-12 14:19:21 +07:00
Mohamed Boudra
213f155c9c fix(desktop): fix Claude agent spawn from managed runtime and rotate logs on restart
- Always override spawnClaudeCodeProcess to use process.execPath instead of
  SDK's PATH-based "node" lookup, which fails in the managed runtime bundle
- Fix append-mode arg ordering in resolveClaudeSpawnCommand so extra CLI args
  (e.g. --chrome) go after cli.js, not before it (Node exit code 9)
- Rotate daemon.log on every daemon restart for clean startup logs
- Remove dead dev_resource_root fallback from runtime_manager.rs
- Canonicalize current_exe path in CLI runner
2026-03-12 13:16:08 +07:00
Mohamed Boudra
9e6b45e2f0 refactor(cli): remove daemon update command and simplify status runtime info 2026-03-12 11:19:33 +07:00
Mohamed Boudra
355e56db53 fix(server): add trace logging for Codex app server spawn 2026-03-12 10:51:31 +07:00
Mohamed Boudra
a79134ec9e feat: add single-instance support, Android APK download, and splash screen styling 2026-03-12 10:42:40 +07:00
Mohamed Boudra
91dde29146 feat(server): bundle Codex and OpenCode binaries instead of requiring global installs 2026-03-12 10:25:53 +07:00
Mohamed Boudra
586b48e150 Update files 2026-03-11 23:13:36 +07:00
Mohamed Boudra
aeefa22ddf fix: hide chrome on home route 2026-03-11 21:34:16 +07:00
Mohamed Boudra
9f3ef07322 docs: extract guidance into dedicated docs, streamline CLAUDE.md 2026-03-11 21:23:02 +07:00
Mohamed Boudra
e2cb67462d refactor(cli): extract common command options into reusable helpers 2026-03-11 21:08:08 +07:00
Mohamed Boudra
b60d253926 fix: update metro exclusionList import for Expo compatibility 2026-03-11 21:01:31 +07:00
Mohamed Boudra
ee156adffb Merge remote-tracking branch 'origin/improve-startup' 2026-03-11 20:10:11 +07:00
Mohamed Boudra
c99b78f5b0 Handle noisy shell output in executable lookup 2026-03-11 19:05:44 +07:00
Mohamed Boudra
4c6d21af4a refactor(desktop): simplify managed runtime by removing state file and delegating to CLI daemon status 2026-03-11 19:01:49 +07:00
Mohamed Boudra
327b315610 Suppress console windows on Windows and use ~/.paseo as default home
Add CREATE_NO_WINDOW flag to all Windows process spawns to prevent
visible console windows. Change Windows default managed home from
AppData\Roaming to ~/.paseo for consistency with macOS and server.
2026-03-11 15:52:07 +07:00
Mohamed Boudra
78849fa0bf Strip \\?\ extended-length prefix from Windows resource paths
Node.js module resolver can't handle the \\?\ prefix that Tauri's
resource_dir() returns on Windows, causing EISDIR errors. Use dunce
to simplify paths before passing them to Node.
2026-03-11 14:51:47 +07:00
Mohamed Boudra
e5014a5f57 Add Discord link to website navigation and changelog 2026-03-11 14:21:23 +07:00
Mohamed Boudra
5bf698ff84 Add Windows support and improve cross-platform shell execution 2026-03-11 14:14:06 +07:00
Mohamed Boudra
eb5f011161 Add screen orientation polyfill, refactor provider launch config, and update desktop runtime manager
- Add .claude schedule tasks to .gitignore
- Add screen-orientation polyfill for Expo web
- Refactor agent provider launch config into shared utility
- Update desktop runtime manager with improved binary management
- Update desktop release workflow
2026-03-11 14:01:07 +07:00
Mohamed Boudra
acfb933ee8 Update CHANGELOG for 0.1.25 2026-03-11 12:29:39 +07:00
Mohamed Boudra
c37684b246 Preserve entitlements when re-signing managed runtime binaries
The sign script was re-signing Mach-O executables with --force and
hardened runtime but without --entitlements, stripping entitlements
like allow-jit that Node.js needs for V8. This caused SIGTRAP on
any Mac where the binary went through Gatekeeper validation.

Extract existing entitlements before re-signing and pass them back
via --entitlements so they are preserved.
2026-03-11 11:36:21 +07:00
Mohamed Boudra
e2068e3d72 chore(release): cut 0.1.25 2026-03-11 11:09:56 +07:00
Mohamed Boudra
443eb16e67 Notarize macOS DMG to fix quarantine on bundled binaries
Tauri notarizes the .app but not the .dmg container. When users
download the DMG from GitHub Releases, macOS quarantines everything
and Gatekeeper doesn't clear quarantine on embedded helper binaries
(like the bundled Node runtime), causing SIGTRAP on first launch.

Add a post-build step that signs, notarizes, and staples the DMG,
then re-uploads it to the release.
2026-03-11 11:09:41 +07:00
Mohamed Boudra
240dc26013 Restore AppImage bundle for Linux (revert deb workaround)
The linuxdeploy failure was caused by CUDA shared library references in
onnxruntime-node, not by linuxdeploy itself. The CUDA stripping step
added in the previous commit fixes the root cause, so AppImage bundling
should work now.
2026-03-11 09:07:44 +07:00
Mohamed Boudra
6b07555a46 Switch Linux bundle from appimage to deb
linuxdeploy-plugin-appimage's "continuous" release on GitHub is broken,
causing every AppImage build to fail. The downloaded binary is actually
an HTML error page. Switch to deb format which doesn't depend on
linuxdeploy at all.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 23:21:09 +07:00
Mohamed Boudra
b69bd5271b Fix Linux AppImage build: add APPIMAGE_EXTRACT_AND_RUN=1
linuxdeploy is an AppImage itself and needs FUSE to run. GitHub Actions
runners don't always have working FUSE support. Setting this env var
tells AppImage tools to extract-and-run instead, avoiding the FUSE
dependency.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 23:07:48 +07:00
Mohamed Boudra
cf4cae2c7d Fix Linux AppImage: strip CUDA deps from onnxruntime binaries
linuxdeploy scans all ELF binaries in the AppDir and fails when it
can't find libcublasLt.so.12 (a CUDA library referenced by the
onnxruntime native module). Use patchelf to remove these optional
CUDA dependencies since we only need CPU inference.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 22:59:36 +07:00
Mohamed Boudra
d51f18a2f7 Add workflow step to strip CUDA providers before Linux AppImage build
The build script fix only applies to future tags. For v0.1.24 (and any
tag built before that fix), we need the workflow itself to remove the
CUDA .so files after building the managed runtime.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 22:44:54 +07:00
Mohamed Boudra
006db65f08 Remove CUDA/TensorRT providers from onnxruntime-node in managed runtime
linuxdeploy scans all ELF files in the AppDir and fails when it finds
libonnxruntime_providers_cuda.so which links to libcublasLt.so.12 — a
CUDA library not available on CI runners.

onnxruntime falls back to the CPU provider when CUDA is absent, so
removing these has no functional impact.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 22:44:32 +07:00
Mohamed Boudra
f21221c1e1 Add libfuse2 to Linux AppImage build dependencies
linuxdeploy is distributed as an AppImage and may need libfuse2 to
execute even with APPIMAGE_EXTRACT_AND_RUN=1. ubuntu-22.04 runners
don't have it by default.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 22:26:11 +07:00
Mohamed Boudra
9faa88e13b Add --verbose to Linux AppImage build for debugging
Need to see the actual linuxdeploy error output instead of the opaque
"failed to run linuxdeploy" message.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 22:16:34 +07:00
Mohamed Boudra
faf1eed0ab Fix Linux AppImage build: pin ubuntu-22.04 and disable strip
ubuntu-latest switched to 24.04 which has libraries with .relr.dyn
sections that linuxdeploy's bundled eu-strip cannot handle, causing
consistent "failed to run linuxdeploy" errors.

Pin to ubuntu-22.04 (also better glibc compat for AppImage) and set
NO_STRIP=true as a safety net.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 22:06:38 +07:00
129 changed files with 5115 additions and 6174 deletions

View File

@@ -172,6 +172,7 @@ jobs:
- name: Build and publish macOS Tauri release
if: env.IS_SMOKE_TAG != 'true'
id: tauri_build
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -192,6 +193,44 @@ jobs:
prerelease: false
args: --target ${{ matrix.rust_target }}
- 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"
echo "Signing DMG..."
codesign --force --sign "$APPLE_SIGNING_IDENTITY" --timestamp "$dmg_path"
echo "Submitting DMG for notarization..."
xcrun notarytool submit "$dmg_path" \
--apple-id "$APPLE_ID" \
--password "$APPLE_PASSWORD" \
--team-id "$APPLE_TEAM_ID" \
--wait
echo "Stapling notarization ticket..."
xcrun stapler staple "$dmg_path"
echo "Verifying..."
spctl --assess --type install --verbose "$dmg_path"
echo "Replacing release asset with notarized DMG..."
gh release upload "$RELEASE_TAG" "$dmg_path" --repo "${{ github.repository }}" --clobber
- 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
@@ -201,7 +240,7 @@ jobs:
permissions:
contents: write
packages: read
runs-on: ubuntu-latest
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
@@ -267,7 +306,7 @@ jobs:
- 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
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
@@ -303,6 +342,19 @@ jobs:
- 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:
@@ -324,6 +376,8 @@ jobs:
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 }}
NO_STRIP: "true"
APPIMAGE_EXTRACT_AND_RUN: "1"
with:
projectPath: packages/desktop
tagName: ${{ env.RELEASE_TAG }}
@@ -467,7 +521,7 @@ jobs:
releaseBody: See the assets to download and install this version.
releaseDraft: ${{ env.RELEASE_DRAFT }}
prerelease: false
args: --bundles nsis,msi
args: --bundles nsis
- name: Build Windows app (smoke only)
if: env.IS_SMOKE_TAG == 'true'

2
.gitignore vendored
View File

@@ -71,6 +71,8 @@ valknut-report.json/
**/.paseo-provider-history/
.claude/settings.local.json
**/.claude/settings.local.json
.claude/scheduled_tasks.lock
.claude/worktrees/
.plans/
packages/server/src/server/fixtures/dictation/dictation-debug-largest.wav
packages/server/src/server/fixtures/dictation/dictation-debug-largest.transcript.txt

View File

@@ -1,5 +1,29 @@
# Changelog
## 0.1.26 - 2026-03-12
### Added
- Added single-instance desktop behavior, Android APK download access, and refreshed splash screen styling.
- Added bundled Codex and OpenCode binaries in the server so setup no longer depends on global installs.
- Added Windows support with improved cross-platform shell execution.
### Improved
- Improved desktop runtime behavior on Windows by suppressing console windows and defaulting app data to `~/.paseo`.
- Added a Discord link to the website navigation.
### Fixed
- Fixed desktop Claude agent startup from the managed runtime and rotated logs correctly on restart.
- Fixed the home route to hide browser chrome when appropriate.
- Fixed Expo Metro compatibility by updating the `exclusionList` import.
- Fixed noisy shell output interfering with executable lookup.
- Fixed Windows resource-path handling by stripping the extended-length path prefix.
## 0.1.25 - 2026-03-11
### Fixed
- Fixed desktop app failing to start the built-in daemon on fresh macOS installs. The DMG was not notarized and code-signing stripped entitlements from the bundled Node runtime, causing Gatekeeper to block execution.
- Fixed Linux AppImage build by restoring the AppImage bundle format and stripping CUDA dependencies from onnxruntime.
## 0.1.24 - 2026-03-10
### Improved

281
CLAUDE.md
View File

@@ -1,267 +1,54 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Paseo is a mobile app for monitoring and controlling your local AI coding agents from anywhere. Your dev environment, in your pocket.
**Key features:**
- Real-time streaming of agent output
- Voice commands for hands-free interaction
- Push notifications when tasks complete
- Multi-agent orchestration across projects
**Not a cloud sandbox** - Paseo connects directly to your actual development environment. Your code stays on your machine.
Paseo is a mobile app for monitoring and controlling your local AI coding agents from anywhere. Your dev environment, in your pocket. Connects directly to your actual development environment — your code stays on your machine.
**Supported agents:** Claude Code, Codex, and OpenCode.
## Monorepo Structure
## Repository map
This is an npm workspace monorepo:
- **packages/server**: The Paseo daemon that runs on your machine. Manages agent processes, provides WebSocket API for real-time streaming, and exposes an MCP server for agent control.
- **packages/app**: Cross-platform client (Expo). Connects to one or more servers, displays agent output, handles voice input, and sends push notifications.
- **packages/cli**: The `paseo` CLI that is used to manage the deamon, and acts as a client to it with Docker-style commands like `paseo run/ls/logs/wait`
- **packages/website**: Marketing site at paseo.sh (TanStack Router + Cloudflare Workers).
- `packages/server` — Daemon: agent lifecycle, WebSocket API, MCP server
- `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` — Tauri desktop wrapper
- `packages/website` — Marketing site (paseo.sh)
## Development Server
## Documentation
The `npm run dev` script automatically picks an available port for the development server.
| Doc | What's in it |
|---|---|
| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | System design, package layering, WebSocket protocol, agent lifecycle, data flow |
| [docs/CODING_STANDARDS.md](docs/CODING_STANDARDS.md) | Type hygiene, error handling, state design, React patterns, file organization |
| [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/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 |
When running in a worktree or alongside the main checkout, set `PASEO_HOME` to isolate state:
## Quick start
```bash
PASEO_HOME=~/.paseo-blue npm run dev
```
- `PASEO_HOME` path for runtime state (agent data, sockets, etc.). Defaults to `~/.paseo`; set this to a unique directory when running a secondary server instance.
For trace+ logs, check $PASEO_HOME/daemon.log
## Running and checking logs
Both the server and Expo app are running in a Tmux session. See CLAUDE.local.md for system-specific session details.
## Debugging
### Daemon and CLI
The Paseo daemon communicates via WebSocket. In the main checkout:
- Daemon runs at `localhost:6767`
- Expo app at `localhost:8081`
- State lives in `$PASEO_HOME`
In worktrees or when running `npm run dev`, ports and home directories may differ. Never assume the defaults.
Use `npm run cli` to run the local CLI (instead of the globally linked `paseo` which points to the main checkout). Always run `npm run cli -- --help` or load the `/paseo` skill before using it - do not guess commands.
Use `--host <host:port>` to point the CLI at a different daemon (e.g., `--host localhost:7777`).
### Relay build sync (important)
When changing `packages/relay/src/*`, rebuild relay before running/debugging the daemon:
```bash
npm run build --workspace=@getpaseo/relay
```
Reason: Node daemon imports `@getpaseo/relay` from `packages/relay/dist/*` (`node` export path), not directly from `src/*`.
### Server build sync for CLI (important)
When changing `packages/server/src/client/*` (especially `daemon-client.ts`) or shared WS protocol types, rebuild server before running/debugging CLI commands:
```bash
npm run build --workspace=@getpaseo/server
```
Reason: local CLI imports `@getpaseo/server` via package exports that resolve to `packages/server/dist/*` first. If `dist` is stale, CLI can speak an old protocol (for example, sending `session` before `hello`) and fail with handshake warnings/timeouts.
### Quick reference CLI commands
```bash
npm run cli -- ls -a -g # List all agents globally
npm run cli -- ls -a -g --json # Same, as JSON
npm run cli -- inspect <id> # Show detailed agent info
npm run cli -- logs <id> # View agent timeline
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
```
### Agent state
See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requirements, and debugging.
Agent data is stored at:
```
$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json
```
## Critical rules
To find an agent by ID:
```bash
find $PASEO_HOME/agents -name "{agent-id}.json"
```
- **NEVER restart the main Paseo daemon on port 6767 without permission** — it manages all running agents. If you're an agent, restarting it kills your own process.
- **NEVER assume a timeout means the service needs restarting** — timeouts can be transient.
- **NEVER add auth checks to tests** — agent providers handle their own auth.
- **Always run typecheck after every change.**
To find an agent by title or other content:
```bash
rg -l "some title text" $PASEO_HOME/agents/
rg -l "spiteful-toad" $PASEO_HOME/agents/
```
## Orchestrator mode
### Provider session files
Get the session ID from the agent JSON file (`persistence.sessionId`), then:
**Claude sessions:**
```
~/.claude/projects/{cwd-with-dashes}/{session-id}.jsonl
```
**Codex sessions:**
```
~/.codex/sessions/{YYYY}/{MM}/{DD}/rollout-{timestamp}-{session-id}.jsonl
```
## Android
Take screenshots like this: `adb exec-out screencap -p > screenshot.png`
### Android variants (vanilla Expo)
Use `APP_VARIANT` in `packages/app/app.config.js` to control app name + package ID (no custom Gradle flavor plugin):
- `production` -> app name `Paseo`, package `sh.paseo`
- `development` -> app name `Paseo Debug`, package `sh.paseo.debug`
EAS profiles live in `packages/app/eas.json` as `development`, `production`, and `production-apk`.
`development` uses Android `debug`.
### Local build + install (Android device)
From `packages/app`:
```bash
# development (debug)
APP_VARIANT=development npx expo prebuild --platform android --non-interactive
APP_VARIANT=development npx expo run:android --variant=debug
# production (release)
APP_VARIANT=production npx expo prebuild --platform android --non-interactive
APP_VARIANT=production npx expo run:android --variant=release
# clean native project (when needed)
npx expo prebuild --platform android --clean --non-interactive
```
From repo root:
```bash
npm run android:development
npm run android:production
npm run android:clean
```
`npm run android:release` is an alias for `npm run android:production`.
### Cloud build + submit (EAS Workflows)
Tag pushes like `v0.1.0` trigger `packages/app/.eas/workflows/release-mobile.yml` on Expo servers.
Tag pushes like `v0.1.0` also trigger `.github/workflows/android-apk-release.yml` on GitHub Actions to publish an APK asset on the matching GitHub Release.
That workflow does:
- Build iOS with the `production` profile
- Build Android with the `production` profile
- Submit each build with the `production` submit profile
Useful commands:
```bash
# List recent mobile workflow runs
cd packages/app && npx eas workflow:runs --workflow release-mobile.yml --limit 10
# Inspect one run (jobs, status, outputs)
cd packages/app && npx eas workflow:view <run-id>
# Stream logs for all steps in one failed job
cd packages/app && npx eas workflow:logs <job-id> --non-interactive --all-steps
```
## Testing with Playwright MCP
**CRITICAL:** When asked to test the app, you MUST use the Playwright MCP connecting to Metro at `http://localhost:8081`.
Use the Playwright MCP to test the app in Metro web. Navigate to `http://localhost:8081` to interact with the app UI.
**Important:** Do NOT use browser history (back/forward). Always navigate by clicking UI elements or using `browser_navigate` with the full URL. The app uses client-side routing and browser history navigation breaks the state.
## Expo troubleshooting
Run `npx expo-doctor` to diagnose version mismatches and native module issues.
## Release playbook
Use the scripted release flow from repo root. Avoid manual version bumps, manual tags, or ad hoc publish commands unless debugging.
```bash
# Recommended: full patch release (bump, check, publish, push branch+tag)
npm run release:patch
# Manual, step-by-step fallback:
npm run version:all:patch # npm version across all workspaces (creates commit + local tag)
npm run release:check
npm run release:publish
npm run release:push # pushes HEAD and current version tag (triggers desktop + Android APK + EAS mobile workflows)
```
### Draft release flow
```bash
# Stage a draft GitHub release with assets, but do not publish npm yet.
npm run draft-release:patch
# Publish npm and promote the same GitHub draft release to final.
npm run release:finalize
```
Behavior:
- `draft-release:patch` bumps the version, runs release checks, pushes `HEAD` and the new `v*` tag, and creates the matching GitHub Release as a draft so desktop assets, APK uploads, and synced notes attach to that same draft release.
- `release:finalize` requires that the current tag already has a GitHub draft release, publishes the npm packages for that exact version, and promotes the same GitHub Release from draft to published.
- Use the same semver tag for both draft and final states; do not cut a second tag just to publish the release.
Notes:
- `version:all:*` bumps the root package version and runs the root `version` lifecycle script to sync workspace versions and internal `@getpaseo/*` dependency versions before the release commit/tag is created.
- `release:prepare` refreshes workspace `node_modules` links to prevent stale local package types during release checks.
- If `release:publish` fails after a successful publish of one workspace, re-run `npm run release:publish`; npm will skip already-published versions and continue where possible.
- If a user asks to "release paseo" (without specifying major/minor), treat it as a patch release and run `npm run release:patch`.
- All workspaces share one version by design. Keep versions synchronized and release together.
- The website Mac download CTA URL is derived from `packages/website/package.json` version at build time, so no manual update is required after release.
Release completion checklist:
- Manually update CHANGELOG.md with release notes, between current release vs previous one, use Git commands to figure out what changed. The notes are user-facing:
- Ask yourself, what do Paseo users want to know about?
- Include: New features, bug fixes
- Don't include: Refactors or code changes that are not noticeable by users
- `npm run release:patch` completes successfully.
- GitHub `Desktop Release` workflow for the new `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 (Expo queues can take longer on the free plan).
## Orchestrator Mode
- **When agent control tool calls fail**, make sure you list agents before trying to launch another one. It could just be a wait timeout.
- **Always prefix agent titles** so we can tell which ones are running under you (e.g., "🎭 Feature Implementation", "🎭 Design Discussion").
- **Launch agents in the most permissive mode**: Use full access or bypass permissions mode.
- **Set cwd to the repository root** - The agent's working directory should usually be the repo root
**CRITICAL: ALWAYS RUN TYPECHECK AFTER EVERY CHANGE.**
## Agent Authentication
All agent providers (Claude, Codex, OpenCode) handle their own authentication outside of environment variables. They are authenticated without providing any extra configuration—Paseo does not manage API keys or tokens for agents.
**Do not add auth checks to tests.** If auth fails for whatever reason, let the user know instead of patching the code or adding conditional skips.
## NEVER DO THESE THINGS
- **NEVER restart the main Paseo daemon on port 6767 without permission** - This is the production daemon that launches and manages agents. If you are reading this, you are probably running as an agent under it. Restarting it will kill your own process and all other running agents. The daemon is managed by the user in Tmux.
- **NEVER assume a timeout means the service needs restarting** - Timeouts can be transient network issues, not service failures
- **NEVER add authentication checks to tests** - Agent providers handle their own auth. If tests fail due to auth issues, report it rather than adding conditional skips or env var checks
- 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

67
docs/ANDROID.md Normal file
View File

@@ -0,0 +1,67 @@
# Android
## App variants
Controlled by `APP_VARIANT` in `packages/app/app.config.js` (vanilla Expo, no custom Gradle plugin):
| Variant | App name | Package ID |
|---|---|---|
| `production` | Paseo | `sh.paseo` |
| `development` | Paseo Debug | `sh.paseo.debug` |
EAS profiles: `development`, `production`, and `production-apk` in `packages/app/eas.json`.
`development` uses Android `debug`.
## Local build + install
From repo root:
```bash
npm run android:development # Debug build
npm run android:production # Release build
npm run android:clean # Clean native project
```
Or from `packages/app`:
```bash
# Debug
APP_VARIANT=development npx expo prebuild --platform android --non-interactive
APP_VARIANT=development npx expo run:android --variant=debug
# Release
APP_VARIANT=production npx expo prebuild --platform android --non-interactive
APP_VARIANT=production npx expo run:android --variant=release
# Clean
npx expo prebuild --platform android --clean --non-interactive
```
## Screenshots
```bash
adb exec-out screencap -p > screenshot.png
```
## Cloud build + submit (EAS)
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)
### Useful commands
```bash
cd packages/app
# List recent workflow runs
npx eas workflow:runs --workflow release-mobile.yml --limit 10
# Inspect a run
npx eas workflow:view <run-id>
# Stream logs for a failed job
npx eas workflow:logs <job-id> --non-interactive --all-steps
```

184
docs/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,184 @@
# Architecture
Paseo is a client-server system for monitoring and controlling local AI coding agents. The daemon runs on your machine, manages agent processes, and streams their output in real time over WebSocket. Clients (mobile app, CLI, desktop app) connect to the daemon to observe and interact with agents.
Your code never leaves your machine. Paseo is local-first.
## System overview
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Mobile App │ │ CLI │ │ Desktop App │
│ (Expo) │ │ (Commander) │ │ (Tauri) │
└──────┬───────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
│ WebSocket │ WebSocket │ Managed subprocess
│ (direct or │ (direct) │ + WebSocket
│ via relay) │ │
└───────────┬───────┴──────────────────┘
┌──────▼──────┐
│ Daemon │
│ (Node.js) │
└──────┬──────┘
┌────────────┼────────────┐
│ │ │
┌─────▼─────┐ ┌───▼────┐ ┌────▼─────┐
│ Claude │ │ Codex │ │ OpenCode │
│ Agent │ │ Agent │ │ Agent │
│ SDK │ │ Server │ │ │
└───────────┘ └────────┘ └──────────┘
```
## Packages
### `packages/server` — The daemon
The heart of Paseo. A Node.js process that:
- Listens for WebSocket connections from clients
- Manages agent lifecycle (create, run, stop, resume, archive)
- Streams agent output in real time via a timeline model
- Exposes an MCP server for agent-to-agent control
- Optionally connects outbound to a relay for remote access
**Key modules:**
| Module | Responsibility |
|---|---|
| `bootstrap.ts` | Daemon initialization: HTTP server, WS server, agent manager, storage, relay |
| `websocket-server.ts` | WebSocket connection management, hello/welcome handshake, binary multiplexing |
| `session.ts` | Per-client session state, timeline subscriptions, terminal operations |
| `agent/agent-manager.ts` | Agent lifecycle state machine, timeline tracking, subscriber management |
| `agent/agent-storage.ts` | File-backed JSON persistence at `$PASEO_HOME/agents/` |
| `agent/mcp-server.ts` | MCP server for sub-agent creation, permissions, timeouts |
| `providers/` | Provider adapters: Claude (Agent SDK), Codex (AppServer), OpenCode |
| `relay-transport.ts` | Outbound relay connection with E2E encryption |
| `client/daemon-client.ts` | Client library for connecting to the daemon (used by CLI and app) |
### `packages/app` — Mobile + web client (Expo)
Cross-platform React Native app that connects to one or more daemons.
- Expo Router navigation (`/h/[serverId]/agents`, etc.)
- `DaemonRegistryContext` manages saved daemon connections
- `SessionContext` wraps the daemon client for the active session
- `Stream` model handles timeline with compaction, gap detection, sequence-based deduplication
- Voice features: dictation (STT) and voice agent (realtime)
### `packages/cli` — Command-line client
Commander.js CLI with Docker-style commands:
- `paseo agent ls/run/stop/logs/inspect/wait/send/attach`
- `paseo daemon start/stop/restart/status/pair`
- `paseo permit allow/deny/ls`
- `paseo provider ls/models`
- `paseo worktree ls/archive`
Communicates with the daemon via the same WebSocket protocol as the app.
### `packages/relay` — E2E encrypted relay
Enables remote access when the daemon is behind a firewall.
- ECDH key exchange + AES-256-GCM encryption
- Relay server is zero-knowledge — it routes encrypted bytes, cannot read content
- Client and daemon channels with identical API (`createClientChannel`, `createDaemonChannel`)
- Pairing via QR code transfers the daemon's public key to the client
See [SECURITY.md](../SECURITY.md) for the full threat model.
### `packages/desktop` — Desktop app (Tauri)
Tauri wrapper for macOS, Linux, and Windows.
- Can spawn the daemon as a managed subprocess
- Native file access for workspace integration
- Same WebSocket client as mobile app
### `packages/website` — Marketing site
TanStack Router + Cloudflare Workers. Serves paseo.sh.
## WebSocket protocol
All clients speak the same binary-multiplexed WebSocket protocol.
**Handshake:**
```
Client → Server: WSHelloMessage { id, clientId, version, timestamp }
Server → Client: WSWelcomeMessage { clientId, daemonVersion, sessionId, capabilities }
```
**Message types:**
- `agent_update` — Agent state changed (status, title, labels)
- `agent_stream` — New timeline event from a running agent
- `workspace_update` — Workspace state changed
- `agent_permission_request` — Agent needs user approval for a tool call
- Command-response pairs for fetch, list, create, etc.
**Binary multiplexing:**
Terminal I/O and agent streaming share the same connection via `BinaryMuxFrame`:
- Channel 0: control messages
- Channel 1: terminal data
- 1-byte channel ID + 1-byte flags + variable payload
## Agent lifecycle
```
initializing → idle → running → idle (or error → closed)
↑ │
└────────┘ (agent completes a turn, awaits next prompt)
```
- **AgentManager** tracks up to 200 timeline items per agent
- Timeline is append-only with epochs (each run starts a new epoch)
- Events stream to all subscribed clients in real time
- Agent state persists to `$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json`
## Agent providers
Each provider implements a common `AgentClient` interface:
| Provider | Wraps | Session format |
|---|---|---|
| Claude | Anthropic Agent SDK | `~/.claude/projects/{cwd}/{session-id}.jsonl` |
| Codex | CodexAppServer | `~/.codex/sessions/{date}/rollout-{ts}-{id}.jsonl` |
| OpenCode | OpenCode CLI | Provider-managed |
All providers:
- Handle their own authentication (Paseo does not manage API keys)
- Support session resume via persistence handles
- Map tool calls to a normalized `ToolCallDetail` type
- Expose provider-specific modes (plan, default, full-access)
## Data flow: running an agent
1. Client sends `CreateAgentRequestMessage` with config (prompt, cwd, provider, model, mode)
2. Session routes to `AgentManager.create()`
3. AgentManager creates a `ManagedAgent`, initializes provider session
4. Provider runs the agent → emits `AgentStreamEvent` items
5. Events append to the agent timeline, broadcast to all subscribed clients
6. Tool calls are normalized to `ToolCallDetail` (shell, read, edit, write, search, etc.)
7. Permission requests flow: agent → server → client → user decision → server → agent
## Storage
```
$PASEO_HOME/
├── agents/{cwd-with-dashes}/{agent-id}.json # Agent state + config
├── projects/projects.json # Project registry
├── projects/workspaces.json # Workspace registry
└── daemon.log # Daemon trace logs
```
## Deployment models
1. **Local daemon** (default): `paseo daemon start` on `127.0.0.1:6767`
2. **Managed desktop**: Tauri app spawns daemon as subprocess
3. **Remote + relay**: Daemon behind firewall, relay bridges with E2E encryption

174
docs/CODING_STANDARDS.md Normal file
View File

@@ -0,0 +1,174 @@
# Coding Standards
These standards apply to all code changes: features, bug fixes, refactors, and performance work.
## Core principles
- **Zero complexity budget** — justify every abstraction with specific benefits
- **Fully typed TypeScript** — no `any`, no untyped boundaries
- **YAGNI** — build features and abstractions only when needed
- **Functional and declarative** over object-oriented
- **`interface`** over `type` when possible
- **`function` declarations** over arrow function assignments
- **Single-purpose functions** — one function, one job
- **Design for edge cases through types** rather than explicit handling
- **Don't catch errors** unless there's a strong reason to
- **No index.ts barrel files** that only re-export — they create unnecessary indirection
- **No "while I'm at it" improvements** — stay focused on the task
## Type hygiene
### Infer from schemas
Never hand-write a TypeScript type that can be inferred from a Zod schema.
```typescript
// Bad: duplicate type that can drift
const schema = z.object({ procedure: z.string(), args: z.record(z.unknown()) });
type RPCArgs = { procedure: string; args: Record<string, unknown> };
// Good: infer from schema
type RPCArgs = z.infer<typeof schema>;
```
### Named types over inline
No complex inline types in public function signatures.
```typescript
// Bad
function enqueueJob(input: { userId: string; priority: "low" | "normal" | "high" }) {}
// Good
interface EnqueueJobInput { userId: string; priority: "low" | "normal" | "high" }
function enqueueJob(input: EnqueueJobInput) {}
```
### Object parameters
If a function needs more than one argument, use a single object parameter.
```typescript
// Bad: positional args
function createToolCall(provider: string, toolName: string, payload: unknown) {}
// Good: object param
interface CreateToolCallInput { provider: string; toolName: string; payload: unknown }
function createToolCall(input: CreateToolCallInput) {}
```
### One canonical type per concept
Don't redefine the same concept in different layer-specific shapes (`RpcX`, `DbX`, `UiX`). Keep one canonical type and add explicit layer wrappers that reference it.
```typescript
// Bad: duplicated fields across layers
type RpcToolCall = { toolName: string; args: Record<string, unknown>; requestId: string };
type DbToolCall = { toolName: string; args: Record<string, unknown>; id: string; createdAt: Date };
// Good: canonical type + wrappers
type ToolCall = { toolName: string; args: Record<string, unknown> };
type ToolCallRequest = { requestId: string; toolCall: ToolCall };
type ToolCallRecord = { id: string; createdAt: Date; toolCall: ToolCall };
```
## Make impossible states impossible
Use discriminated unions instead of bags of booleans and optionals.
```typescript
// Bad
interface FetchState { isLoading: boolean; error?: Error; data?: Data }
// Good
type FetchState =
| { status: "idle" }
| { status: "loading" }
| { status: "error"; error: Error }
| { status: "success"; data: Data };
```
## Optionality is a design decision
Don't mark fields optional to avoid migrations. Decide deliberately:
1. Is optionality actually needed?
2. If there are distinct valid states → discriminated union
3. If value can be intentionally empty → explicit `null`
4. Keep optionality at real boundaries (external input), then resolve it
## Validate at boundaries, trust internally
Parse external data once at the boundary with schema validation. Then use typed values everywhere else.
```typescript
// Bad: optional chaining because shape is unclear
const value = response?.data?.items?.[0]?.name;
// Good: validate at boundary, trust the types
const parsed = responseSchema.parse(rawResponse);
const value = parsed.data.items[0].name;
```
## Error handling
- **Fail explicitly** — if caller requests X and X is unavailable, throw rather than silently returning Y
- **Use typed domain errors** — not plain `Error`. Carry structured metadata for handling, logging, and user messaging
- **Preserve error semantics** — don't collapse meaningful typed errors into generic `Error`
```typescript
class TimeoutError extends Error {
constructor(
public readonly operation: string,
public readonly waitedMs: number,
) {
super(`${operation} timed out after ${waitedMs}ms`);
this.name = "TimeoutError";
}
}
```
## Keep logic density low
Avoid packing branching, lookup, and transformation into single dense expressions.
```typescript
// Bad: nested ternaries + inline lookups
const billing = shouldUseLegacy(account)
? getLegacy(account)
: buildBilling(account, rates.find((r) => r.region === account.region));
// Good: named steps, then assemble
const rate = rates.find((r) => r.region === account.region);
if (!rate) throw new MissingRateError(account.region);
const billing = shouldUseLegacy(account) ? getLegacy(account) : buildBilling(account, rate);
```
## Centralize policy
When the same discriminator (`plan`, `provider`, `kind`, `status`) is checked across multiple files, centralize it into a policy model. A new case should require editing one place, not many.
## React: keep components dumb
- Components render state and dispatch events — they don't compute transitions
- If a component has more than two interacting `useState` calls, extract a state machine or reducer
- `useRef` for mutable coordination state (flags, timers) is a smell — model states explicitly
- Never mirror a source of truth into local state; derive from it
- Test state logic as pure functions without rendering
## File organization
- Organize by domain first (`providers/claude/`), not by technical type (`tool-parsers/`)
- Name files after the main export (`create-toolcall.ts`)
- Use `index.ts` as an entrypoint, not a dumping ground
- Collocate tests with implementation (`thing.ts` + `thing.test.ts`)
## Refactoring contract
Refactoring is structure work, not feature work.
- Preserve behavior by default, especially user-facing behavior
- Do not remove features to simplify code without explicit approval
- Have a verification strategy before you start
- Fully migrate callers and remove old paths in the same refactor
- No fallback behavior by default — prefer explicit error over silent degradation

73
docs/DESIGN.md Normal file
View File

@@ -0,0 +1,73 @@
# Designing Features
How to think through a feature before writing code.
## Start from the user
Even for backend work, start from the user's perspective:
- What problem does this solve?
- What triggers it? User action, schedule, event?
- What does success look like from the user's perspective?
- What data does it need? Where does that data come from?
## Map existing code
Before designing anything new, understand what exists:
- Where does similar functionality live?
- What patterns does the codebase already use?
- What layers exist? (See [ARCHITECTURE.md](./ARCHITECTURE.md))
- What types and data shapes are already defined?
New features rarely mean only new code. Usually they require modifying existing interfaces, extending existing types, or refactoring to accommodate the new functionality. Identify what needs to change, not just what needs to be added.
## Define verification before implementation
Before designing the solution, define how you'll know it works:
- What tests will prove this feature is correct?
- At what layer? Unit, integration, E2E?
- What's the simplest way to verify the core behavior?
If you can't define verification, you don't understand the feature well enough yet.
## Design the shape
### Data
- What types are needed?
- Use discriminated unions — make impossible states impossible
- One canonical type per concept (see [CODING_STANDARDS.md](./CODING_STANDARDS.md))
### Layers
- What belongs in each layer?
- Where are the boundaries?
- What does each layer expose to the layer above?
### Interactions
- How does data flow through the system?
- What triggers what?
- Where do side effects happen?
### Refactoring
- What existing code needs to change?
- Is existing code testable enough? If not, that's part of the plan.
## Create a concrete plan
Once the design is clear:
1. **Acceptance criteria** — specific, verifiable outcomes (not "should work well" but "returns X when given Y")
2. **Ordered steps** — what to build first (usually: types, then lowest layer, then up)
3. **What to refactor** before adding new code
4. **How to verify** each step
## Principles
- **Fit, don't force** — new code should fit existing patterns, or refactor first
- **Simple** — the best design is the simplest one that works
- **Verify early** — define how to test before designing the implementation

130
docs/DEVELOPMENT.md Normal file
View File

@@ -0,0 +1,130 @@
# Development
## Prerequisites
- Node.js (see `.tool-versions` for exact version)
- npm workspaces (comes with Node)
## Running the dev server
```bash
npm run dev
```
The dev script automatically picks an available port. Both the server and Expo app run in a Tmux session — see `CLAUDE.local.md` for system-specific session details.
### Running alongside the main checkout
Set `PASEO_HOME` to isolate state when running a second instance (e.g., in a worktree):
```bash
PASEO_HOME=~/.paseo-blue npm run dev
```
- `PASEO_HOME` — path for runtime state (agents, sockets, etc.). Defaults to `~/.paseo`.
### Default ports
In the main checkout:
- Daemon: `localhost:6767`
- Expo app: `localhost:8081`
In worktrees or with `npm run dev`, ports may differ. Never assume defaults.
### Daemon logs
Check `$PASEO_HOME/daemon.log` for trace-level logs.
## Build sync gotchas
### Relay → Daemon
When changing `packages/relay/src/*`, rebuild before running the daemon:
```bash
npm run build --workspace=@getpaseo/relay
```
The Node daemon imports `@getpaseo/relay` from `packages/relay/dist/*`, not `src/*`.
### Server → CLI
When changing `packages/server/src/client/*` (especially `daemon-client.ts`) or shared WS protocol types, rebuild before running CLI commands:
```bash
npm run build --workspace=@getpaseo/server
```
The CLI imports `@getpaseo/server` via package exports resolving to `dist/*`. Stale `dist` means the CLI speaks an old protocol and fails with handshake warnings or timeouts.
## CLI reference
Use `npm run cli` to run the local CLI (instead of the globally installed `paseo` which points to the main checkout).
```bash
npm run cli -- ls -a -g # List all agents globally
npm run cli -- ls -a -g --json # Same, as JSON
npm run cli -- inspect <id> # Show detailed agent info
npm run cli -- logs <id> # View agent timeline
npm run cli -- daemon status # Check daemon status
```
Use `--host <host:port>` to point the CLI at a different daemon:
```bash
npm run cli -- --host localhost:7777 ls -a
```
## Agent state
Agent data lives at:
```
$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json
```
Find an agent by ID:
```bash
find $PASEO_HOME/agents -name "{agent-id}.json"
```
Find by content:
```bash
rg -l "some title text" $PASEO_HOME/agents/
```
## Provider session files
Get the session ID from the agent JSON (`persistence.sessionId`), then:
**Claude:**
```
~/.claude/projects/{cwd-with-dashes}/{session-id}.jsonl
```
**Codex:**
```
~/.codex/sessions/{YYYY}/{MM}/{DD}/rollout-{timestamp}-{session-id}.jsonl
```
## Testing with Playwright MCP
Use Playwright MCP connecting to Metro at `http://localhost:8081` for UI testing.
Do NOT use browser history (back/forward). Always navigate by clicking UI elements or using `browser_navigate` with the full URL — the app uses client-side routing and browser history breaks state.
## Expo troubleshooting
```bash
npx expo-doctor
```
Diagnoses version mismatches and native module issues.
## Typecheck
Always run typecheck after changes:
```bash
npm run typecheck
```

48
docs/RELEASE.md Normal file
View File

@@ -0,0 +1,48 @@
# Release
All workspaces share one version and release together.
## Standard release (patch)
```bash
npm run release:patch
```
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag (triggering desktop, APK, and EAS mobile workflows).
If asked to "release paseo" without specifying major/minor, treat it as a patch release.
## Manual step-by-step
```bash
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)
```
## Draft release flow
```bash
npm run draft-release:patch # Bump, push tag, create draft GitHub Release
npm run release:finalize # Publish npm, promote draft to published
```
- `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
- If `release:publish` partially fails, re-run it — npm skips already-published versions
- Website Mac download CTA URL derives from `packages/website/package.json` version at build time
## Completion checklist
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors)
- [ ] `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

123
docs/TESTING.md Normal file
View File

@@ -0,0 +1,123 @@
# Testing
## Philosophy
Tests prove behavior, not structure. Every test should answer: "what user-visible or API-visible behavior does this verify?"
## Test-driven development
Work in vertical slices: one test, one implementation, repeat. Each test responds to what you learned from the previous cycle.
```
RIGHT (vertical):
RED→GREEN: test1→impl1
RED→GREEN: test2→impl2
RED→GREEN: test3→impl3
WRONG (horizontal):
RED: test1, test2, test3, test4, test5
GREEN: impl1, impl2, impl3, impl4, impl5
```
Writing all tests first then all implementation produces bad tests — you end up testing imagined behavior instead of actual behavior.
## Determinism first
Tests must produce the same result every run:
- No conditional assertions or branching paths
- No reliance on timing, randomness, or network jitter
- No weak assertions (`toBeTruthy`, `toBeDefined`)
- Assert the full intended behavior, not fragments
```typescript
// Bad: conditional and weak
it("creates a tool call", async () => {
const result = await createToolCall(input);
if (result.ok) {
expect(result.id).toBeDefined();
}
});
// Good: deterministic and explicit
it("returns timeout error when provider times out", async () => {
const result = await createToolCall(input);
expect(result).toEqual({
ok: false,
error: { code: "PROVIDER_TIMEOUT", waitedMs: 30000 },
});
});
```
## Flaky tests are a bug
Never remove a test because it's flaky. Find the variance source (time, randomness, race condition, shared state, non-deterministic output, environment drift) and fix it.
## Real dependencies over mocks
Mocks are not the default. They require an explicit decision.
- **Database**: real test database, not a mock
- **APIs**: real APIs with test/sandbox credentials, not request mocks
- **File system**: temporary directory that gets cleaned up, not fs mocks
Ask: "will this still hold with real dependencies at runtime?" If no, don't mock.
### Use swappable adapters instead
When you need test isolation, design code so dependencies are injectable:
```typescript
interface EmailSender {
send(to: string, body: string): Promise<void>;
}
// Production
const realSender: EmailSender = { send: sendgrid.send };
// Test: in-memory adapter
function createTestEmailSender() {
const sent: Array<{ to: string; body: string }> = [];
return {
send: async (to: string, body: string) => { sent.push({ to, body }); },
sent,
};
}
```
## End-to-end means end-to-end
When a test is labeled end-to-end, it calls the real service. No environment variable gates, no conditional skipping, no mocking the external dependency.
## Test organization
- Collocate tests with implementation: `thing.ts` + `thing.test.ts`
- Extract complex setup into reusable helpers
- Test bodies should read like plain English
- Build a vocabulary of test helpers that make complex flows simple
## Agent authentication in tests
Agent providers handle their own auth. Do not add auth checks, environment variable gates, or conditional skips to tests. If auth fails, report it.
## Debugging with tests
Use the test as your debugging ground:
1. Add temporary logging to the code under test
2. Run the test, observe actual values
3. Trace the flow end-to-end through test output
4. Confirm each assumption with actual output
5. Remove logging when done
The test output is the source of truth, not your reading of the code.
## Design for testability
If code isn't testable, refactor it. Signs:
- You want to reach for a mock
- You can't inject a dependency
- You need to test private internals
- Setup requires too much global state
Aim for deep modules: small interface, deep implementation. Fewer methods = fewer tests needed, simpler params = simpler setup.

313
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.24",
"version": "0.1.26",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.24",
"version": "0.1.26",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -6402,6 +6402,128 @@
"node": ">=20.0"
}
},
"node_modules/@openai/codex": {
"version": "0.114.0",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.114.0.tgz",
"integrity": "sha512-HMo8LRR6CtfKkaa28xvFK6eOarmBFTDfsrS9GJtEoaspGGemFok494CpafDspiTZaHZZGHfSUe5SWTUxYq7OaA==",
"license": "Apache-2.0",
"bin": {
"codex": "bin/codex.js"
},
"engines": {
"node": ">=16"
},
"optionalDependencies": {
"@openai/codex-darwin-arm64": "npm:@openai/codex@0.114.0-darwin-arm64",
"@openai/codex-darwin-x64": "npm:@openai/codex@0.114.0-darwin-x64",
"@openai/codex-linux-arm64": "npm:@openai/codex@0.114.0-linux-arm64",
"@openai/codex-linux-x64": "npm:@openai/codex@0.114.0-linux-x64",
"@openai/codex-win32-arm64": "npm:@openai/codex@0.114.0-win32-arm64",
"@openai/codex-win32-x64": "npm:@openai/codex@0.114.0-win32-x64"
}
},
"node_modules/@openai/codex-darwin-arm64": {
"name": "@openai/codex",
"version": "0.114.0-darwin-arm64",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.114.0-darwin-arm64.tgz",
"integrity": "sha512-c9dlgo9O+66alr3s3G36fKE3KaO2+xrLZ/QcU3oujDruV86pqgVSEK2njHi+WIyyOa6m2AyWxdaHLEbKxPCNRg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=16"
}
},
"node_modules/@openai/codex-darwin-x64": {
"name": "@openai/codex",
"version": "0.114.0-darwin-x64",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.114.0-darwin-x64.tgz",
"integrity": "sha512-oPAPeZSaJZ1AQneFPSJu44uqbZr63Bxut9FOTWZwuSqYx4YEees7i0HJmJcqQc0XnCbYtHJdqo/i07Uv374NLw==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=16"
}
},
"node_modules/@openai/codex-linux-arm64": {
"name": "@openai/codex",
"version": "0.114.0-linux-arm64",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.114.0-linux-arm64.tgz",
"integrity": "sha512-oUFZGZgMiEqmo85C+dfhckpVApH25alLWfwBgfKr2IRgdx/tovy8vN101f6kj01E6nDDe4LfJdNSZGtZht4fRA==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16"
}
},
"node_modules/@openai/codex-linux-x64": {
"name": "@openai/codex",
"version": "0.114.0-linux-x64",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.114.0-linux-x64.tgz",
"integrity": "sha512-MF6RhxfBoccccd5CYII2C7TL2TvYzombBQhanDZfQAajr6n7IuoazCmoHDlrFHS4AcSw9bYA4MRlrwEjbEjgsw==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16"
}
},
"node_modules/@openai/codex-win32-arm64": {
"name": "@openai/codex",
"version": "0.114.0-win32-arm64",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.114.0-win32-arm64.tgz",
"integrity": "sha512-CnRMHopj3en9aqQ2UaDW7EgpEGkxHdZVLLRq2cOy5D0HyuzF6Qb6595ADoFVJHEmPeN5Iz/KUbiGs5GLDjUwOA==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=16"
}
},
"node_modules/@openai/codex-win32-x64": {
"name": "@openai/codex",
"version": "0.114.0-win32-x64",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.114.0-win32-x64.tgz",
"integrity": "sha512-ztRsH5Z+gPVZ5ZInx6HzXsTFFkuEdjk3Ay0UGVOhRRCWvevXQi/SL4eU8hwysCYrs8K/WRq3mo4c95w9zQ2wLw==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=16"
}
},
"node_modules/@opencode-ai/sdk": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.2.6.tgz",
@@ -9138,6 +9260,15 @@
"node": ">= 10"
}
},
"node_modules/@tauri-apps/plugin-log": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-log/-/plugin-log-2.8.0.tgz",
"integrity": "sha512-a+7rOq3MJwpTOLLKbL8d0qGZ85hgHw5pNOWusA9o3cf7cEgtYHiGY/+O8fj8MvywQIGqFv0da2bYQDlrqLE7rw==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tsconfig/node10": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
@@ -21551,6 +21682,161 @@
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"license": "MIT"
},
"node_modules/opencode-ai": {
"version": "1.2.24",
"resolved": "https://registry.npmjs.org/opencode-ai/-/opencode-ai-1.2.24.tgz",
"integrity": "sha512-LaSoATkVEF6jyXNnAPrkqYpmHsZfuf2uPLDSEGJkL4hT6HYFq8LkoknHDjCGIiE5Be3MmId4+l17eSIKDfmddw==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"opencode": "bin/opencode"
},
"optionalDependencies": {
"opencode-darwin-arm64": "1.2.24",
"opencode-darwin-x64": "1.2.24",
"opencode-darwin-x64-baseline": "1.2.24",
"opencode-linux-arm64": "1.2.24",
"opencode-linux-arm64-musl": "1.2.24",
"opencode-linux-x64": "1.2.24",
"opencode-linux-x64-baseline": "1.2.24",
"opencode-linux-x64-baseline-musl": "1.2.24",
"opencode-linux-x64-musl": "1.2.24",
"opencode-windows-x64": "1.2.24",
"opencode-windows-x64-baseline": "1.2.24"
}
},
"node_modules/opencode-darwin-arm64": {
"version": "1.2.24",
"resolved": "https://registry.npmjs.org/opencode-darwin-arm64/-/opencode-darwin-arm64-1.2.24.tgz",
"integrity": "sha512-mk5AkNbmATLwjnczd09Fu9dLTPcW4GSlRlIGWJBbLuGSPe7DWfbuhA5tj00VuaGeC09wDKVnW06BxWp/gRzgMQ==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"darwin"
]
},
"node_modules/opencode-darwin-x64": {
"version": "1.2.24",
"resolved": "https://registry.npmjs.org/opencode-darwin-x64/-/opencode-darwin-x64-1.2.24.tgz",
"integrity": "sha512-SuG7ItUejx42z9Bz46t0R3LLFIM34vZUcwJAkqqpS6MRd1KV9G4OQIJl9BR+Cwwvk70iqCnoDxj5NgFZjeeNOw==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"darwin"
]
},
"node_modules/opencode-darwin-x64-baseline": {
"version": "1.2.24",
"resolved": "https://registry.npmjs.org/opencode-darwin-x64-baseline/-/opencode-darwin-x64-baseline-1.2.24.tgz",
"integrity": "sha512-+MT/ZkgXxeID/kOcT04SBn8Vg+4tk8CytKldaNDLgUnsi/78vTxDXWs0Cc21Ohc894r45fEtOOb5npSNIMOVfQ==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"darwin"
]
},
"node_modules/opencode-linux-arm64": {
"version": "1.2.24",
"resolved": "https://registry.npmjs.org/opencode-linux-arm64/-/opencode-linux-arm64-1.2.24.tgz",
"integrity": "sha512-zSpbVCwVfUQ/Bey1MEGkJg3A52EiIPgnpS44HwmSANkiLPVXRBVuwbFYN2qJ7eJVAH939K9+QPmDOW4X8DWKeQ==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
]
},
"node_modules/opencode-linux-arm64-musl": {
"version": "1.2.24",
"resolved": "https://registry.npmjs.org/opencode-linux-arm64-musl/-/opencode-linux-arm64-musl-1.2.24.tgz",
"integrity": "sha512-LY28mspJOJ2s32E0AbrfbdWH5Bw+5YQkuB3ACJiVxW1MafjhRgPOsXjnw38WMiFjZ+NbhSPBOKnL0guKseVd4Q==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
]
},
"node_modules/opencode-linux-x64": {
"version": "1.2.24",
"resolved": "https://registry.npmjs.org/opencode-linux-x64/-/opencode-linux-x64-1.2.24.tgz",
"integrity": "sha512-OM3umRJ2ZpDe4YtDOGp32cQCqvMPuFJBKKngfvWL4zz4EWDzLPttb0sNL7xdC1xR/XidtADRYcvtzc2rr7/4Rw==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
]
},
"node_modules/opencode-linux-x64-baseline": {
"version": "1.2.24",
"resolved": "https://registry.npmjs.org/opencode-linux-x64-baseline/-/opencode-linux-x64-baseline-1.2.24.tgz",
"integrity": "sha512-yakykM+GxCMZ5WK2G0CQk5cGJkoKt09A/8Y0bhKy7l+Jc/QiTV2ZIlBwT5FO15dnuGlNH6zvukLyOQvOZinM+Q==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
]
},
"node_modules/opencode-linux-x64-baseline-musl": {
"version": "1.2.24",
"resolved": "https://registry.npmjs.org/opencode-linux-x64-baseline-musl/-/opencode-linux-x64-baseline-musl-1.2.24.tgz",
"integrity": "sha512-XHPuf9Rj9nWkVX7JJZ5NtYx9UWT0hPiem/kAhZzYITseRGmQMMfFAhIOBYYvvlzJzgdhcrLi198kJHaKCdXjag==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
]
},
"node_modules/opencode-linux-x64-musl": {
"version": "1.2.24",
"resolved": "https://registry.npmjs.org/opencode-linux-x64-musl/-/opencode-linux-x64-musl-1.2.24.tgz",
"integrity": "sha512-nFT4J4m4wKiZh4RsfgQJlW6W2w/VtMNnXnMiQk93FnncSf0nUXEB5Nam3P+vCjdR3i4cs355JiOvbshnMiJGSA==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
]
},
"node_modules/opencode-windows-x64": {
"version": "1.2.24",
"resolved": "https://registry.npmjs.org/opencode-windows-x64/-/opencode-windows-x64-1.2.24.tgz",
"integrity": "sha512-7nkwFgS8Jn35kgw+6JIWRU6PmbggSuTDiQ+RnFGoySFgYW/Sz/R30Z4KmnbfJLElzEef0kX+nZQasfVl0pSf6A==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"win32"
]
},
"node_modules/opencode-windows-x64-baseline": {
"version": "1.2.24",
"resolved": "https://registry.npmjs.org/opencode-windows-x64-baseline/-/opencode-windows-x64-baseline-1.2.24.tgz",
"integrity": "sha512-hXp1nPnkrHgM6cid1AogRJvjz931UbZ/G165B6AAR1gOb5EqtIh2gZcT0IRUyjooEpwH7+oGFRhT+3Kql/M0WA==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"win32"
]
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -27561,7 +27847,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.24",
"version": "0.1.26",
"dependencies": {
"@boudra/expo-two-way-audio": "^0.1.3",
"@dnd-kit/core": "^6.3.1",
@@ -27569,7 +27855,7 @@
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/server": "0.1.24",
"@getpaseo/server": "0.1.26",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@lezer/common": "^1.5.0",
@@ -27589,6 +27875,7 @@
"@tanstack/react-query": "^5.90.11",
"@tanstack/react-virtual": "^3.13.21",
"@tauri-apps/api": "^2.9.1",
"@tauri-apps/plugin-log": "^2.8.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-unicode11": "^0.9.0",
"@xterm/addon-webgl": "^0.19.0",
@@ -27707,11 +27994,11 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.24",
"version": "0.1.26",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.24",
"@getpaseo/server": "0.1.24",
"@getpaseo/relay": "0.1.26",
"@getpaseo/server": "0.1.26",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -27748,14 +28035,14 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.24",
"version": "0.1.26",
"devDependencies": {
"@tauri-apps/cli": "^2.9.6"
}
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.24",
"version": "0.1.26",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -27771,12 +28058,12 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.24",
"version": "0.1.26",
"dependencies": {
"@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0",
"@getpaseo/relay": "0.1.24",
"@getpaseo/relay": "0.1.26",
"@lezer/common": "^1.5.0",
"@lezer/css": "^1.3.0",
"@lezer/highlight": "^1.2.3",
@@ -27786,6 +28073,7 @@
"@lezer/markdown": "^1.6.2",
"@lezer/python": "^1.1.18",
"@modelcontextprotocol/sdk": "^1.20.1",
"@openai/codex": "^0.114.0",
"@opencode-ai/sdk": "1.2.6",
"@sctg/sentencepiece-js": "^1.1.0",
"@xterm/headless": "^6.0.0",
@@ -27800,6 +28088,7 @@
"node-pty": "1.2.0-beta.11",
"onnxruntime-node": "^1.23.0",
"openai": "^4.20.0",
"opencode-ai": "^1.2.24",
"pino": "^10.2.0",
"pino-pretty": "^13.1.3",
"qrcode": "^1.5.4",
@@ -28132,7 +28421,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.24",
"version": "0.1.26",
"dependencies": {
"@cloudflare/vite-plugin": "^1.20.3",
"@cloudflare/workers-types": "^4.20260114.0",

View File

@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.24",
"version": "0.1.26",
"private": true,
"workspaces": [
"packages/server",

View File

@@ -175,29 +175,6 @@ test("workspace terminal responsiveness benchmark (report-only, single stress pr
timeout: 120_000,
}).toBe(true);
const diagnostics = await page.evaluate(async () => {
const debug = (
window as {
__PASEO_PERF_DIAGNOSTICS_DEBUG__?: {
consumeReports?: () => Promise<unknown[]>;
};
}
).__PASEO_PERF_DIAGNOSTICS_DEBUG__;
if (!debug || typeof debug.consumeReports !== "function") {
return { available: false, reports: [] as unknown[] };
}
try {
const reports = await debug.consumeReports();
return { available: true, reports: Array.isArray(reports) ? reports : [] };
} catch (error) {
return {
available: true,
reports: [] as unknown[],
error: error instanceof Error ? error.message : String(error),
};
}
});
const frameGapsMs = (rafResult.samples ?? []).filter(
(sample) => Number.isFinite(sample) && sample > 0
);
@@ -220,12 +197,6 @@ test("workspace terminal responsiveness benchmark (report-only, single stress pr
over500Ms: frameGapsMs.filter((gap) => gap > 500).length,
},
explorerToggleLatencyMs: summarize(interactionLatenciesMs),
diagnostics: {
available: diagnostics.available,
reportCount: diagnostics.reports.length,
reports: diagnostics.reports,
error: "error" in diagnostics ? diagnostics.error : undefined,
},
};
await testInfo.attach("terminal-responsiveness-report", {

View File

@@ -2,6 +2,17 @@
import { polyfillCrypto } from "./src/polyfills/crypto";
polyfillCrypto();
// 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";

View File

@@ -1,10 +1,13 @@
const { getDefaultConfig } = require("expo/metro-config");
const exclusionList =
require("@expo/metro/metro-config/defaults/exclusionList").default;
const { resolve } = require("metro-resolver");
const fs = require("fs");
const path = require("path");
const projectRoot = __dirname;
const appNodeModulesRoot = path.resolve(projectRoot, "node_modules");
const appSrcRoot = path.resolve(projectRoot, "src");
const serverSrcRoot = path.resolve(projectRoot, "../server/src");
const relaySrcRoot = path.resolve(projectRoot, "../relay/src");
const customWebPlatform = (process.env.PASEO_WEB_PLATFORM ?? "")
@@ -14,6 +17,9 @@ const customWebPlatform = (process.env.PASEO_WEB_PLATFORM ?? "")
const config = getDefaultConfig(projectRoot);
const defaultResolveRequest = config.resolver.resolveRequest ?? resolve;
const escapedAppSrcRoot = appSrcRoot
.replace(/[|\\{}()[\]^$+*?.]/g, "\\$&")
.replace(/\//g, "[\\\\/]");
config.resolver.extraNodeModules = {
...(config.resolver.extraNodeModules ?? {}),
@@ -22,6 +28,9 @@ config.resolver.extraNodeModules = {
"react/jsx-runtime": path.join(appNodeModulesRoot, "react/jsx-runtime"),
"react/jsx-dev-runtime": path.join(appNodeModulesRoot, "react/jsx-dev-runtime"),
};
config.resolver.blockList = exclusionList([
new RegExp(`^${escapedAppSrcRoot}[\\\\/].*\\.(test|spec)\\.(ts|tsx)$`),
]);
function isLocalModuleImport(moduleName) {
return (

View File

@@ -1,7 +1,7 @@
{
"name": "@getpaseo/app",
"main": "index.ts",
"version": "0.1.24",
"version": "0.1.26",
"private": true,
"scripts": {
"start": "expo start",
@@ -33,7 +33,7 @@
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/server": "0.1.24",
"@getpaseo/server": "0.1.26",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@lezer/common": "^1.5.0",
@@ -53,6 +53,7 @@
"@tanstack/react-query": "^5.90.11",
"@tanstack/react-virtual": "^3.13.21",
"@tauri-apps/api": "^2.9.1",
"@tauri-apps/plugin-log": "^2.8.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-unicode11": "^0.9.0",
"@xterm/addon-webgl": "^0.19.0",

View File

@@ -12,10 +12,24 @@ import { useFaviconStatus } from "@/hooks/use-favicon-status";
import { View, ActivityIndicator, Text } from "react-native";
import { UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { darkTheme } from "@/styles/theme";
import { DaemonRegistryProvider, useDaemonRegistry } from "@/contexts/daemon-registry-context";
import { MultiDaemonSessionHost } from "@/components/multi-daemon-session-host";
import { QueryClientProvider } from "@tanstack/react-query";
import { useState, useEffect, type ReactNode, useMemo, useRef } from "react";
import {
getHostRuntimeStore,
useHosts,
useHostMutations,
useHostRuntimeSession,
} from "@/runtime/host-runtime";
import { SessionProvider } from "@/contexts/session-context";
import type { HostProfile } from "@/types/host-connection";
import {
createContext,
useContext,
useState,
useEffect,
type ReactNode,
useMemo,
useRef,
} from "react";
import { Platform } from "react-native";
import * as Linking from "expo-linking";
import * as Notifications from "expo-notifications";
@@ -50,9 +64,12 @@ import {
parseWorkspaceOpenIntent,
} from "@/utils/host-routes";
import { getTauri } from "@/utils/tauri";
import { PerfDiagnosticsProvider } from "@/runtime/perf-diagnostics";
import { attachConsole } from "@/utils/tauri-attach-console";
polyfillCrypto();
attachConsole();
const HostRuntimeBootstrapContext = createContext(false);
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
function logLeftSidebarOpenGesture(
@@ -141,6 +158,79 @@ function PushNotificationRouter() {
return null;
}
function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
const { client } = useHostRuntimeSession(daemon.serverId);
if (!client) {
return null;
}
return (
<SessionProvider
key={daemon.serverId}
serverId={daemon.serverId}
client={client}
>
{null}
</SessionProvider>
);
}
function HostSessionManager() {
const hosts = useHosts();
if (hosts.length === 0) {
return null;
}
return (
<>
{hosts.map((daemon) => (
<ManagedDaemonSession key={daemon.serverId} daemon={daemon} />
))}
</>
);
}
function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
const [ready, setReady] = useState(false);
useEffect(() => {
let cancelled = false;
const store = getHostRuntimeStore();
void store
.loadFromStorage()
.then(() => {
if (cancelled) {
return;
}
setReady(true);
void store.bootstrap();
})
.catch((error) => {
console.error("[HostRuntime] Failed to initialize store", error);
if (!cancelled) {
setReady(true);
}
});
return () => {
cancelled = true;
};
}, []);
return (
<HostRuntimeBootstrapContext.Provider value={ready}>
{children}
</HostRuntimeBootstrapContext.Provider>
);
}
function useStoreReady(): boolean {
return useContext(HostRuntimeBootstrapContext);
}
function QueryProvider({ children }: { children: ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
@@ -148,11 +238,16 @@ function QueryProvider({ children }: { children: ReactNode }) {
interface AppContainerProps {
children: ReactNode;
selectedAgentId?: string;
chromeEnabled?: boolean;
}
function AppContainer({ children, selectedAgentId }: AppContainerProps) {
function AppContainer({
children,
selectedAgentId,
chromeEnabled: chromeEnabledOverride,
}: AppContainerProps) {
const { theme } = useUnistyles();
const { daemons } = useDaemonRegistry();
const daemons = useHosts();
const mobileView = usePanelStore((state) => state.mobileView);
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const openAgentList = usePanelStore((state) => state.openAgentList);
@@ -162,7 +257,7 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const chromeEnabled = daemons.length > 0;
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
const isOpen = chromeEnabled
? isMobile
? mobileView === "agent-list"
@@ -305,8 +400,9 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
function ProvidersWrapper({ children }: { children: ReactNode }) {
const { settings, isLoading: settingsLoading } = useAppSettings();
const { daemons, isLoading: registryLoading, upsertDaemonFromOfferUrl } = useDaemonRegistry();
const isLoading = settingsLoading || registryLoading;
const storeReady = useStoreReady();
const { upsertConnectionFromOfferUrl } = useHostMutations();
const isLoading = settingsLoading || !storeReady;
// Apply theme setting on mount and when it changes
useEffect(() => {
@@ -325,7 +421,7 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
return (
<VoiceProvider>
<OfferLinkListener upsertDaemonFromOfferUrl={upsertDaemonFromOfferUrl} />
<OfferLinkListener upsertDaemonFromOfferUrl={upsertConnectionFromOfferUrl} />
{children}
</VoiceProvider>
);
@@ -375,6 +471,7 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
const pathname = usePathname();
const params = useGlobalSearchParams<{ open?: string | string[] }>();
useFaviconStatus();
const shouldShowAppChrome = pathname !== "/" && pathname !== "";
// Parse selectedAgentKey directly from pathname
// useLocalSearchParams doesn't update when navigating between same-pattern routes
@@ -393,7 +490,12 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
}, [params.open, pathname]);
return (
<AppContainer selectedAgentId={selectedAgentKey}>{children}</AppContainer>
<AppContainer
selectedAgentId={shouldShowAppChrome ? selectedAgentKey : undefined}
chromeEnabled={shouldShowAppChrome}
>
{children}
</AppContainer>
);
}
@@ -453,55 +555,53 @@ export default function RootLayout() {
<GestureHandlerRootView
style={{ flex: 1, backgroundColor: darkTheme.colors.surface0 }}
>
<PerfDiagnosticsProvider scope="root_layout">
<PortalProvider>
<SafeAreaProvider>
<KeyboardProvider>
<BottomSheetModalProvider>
<QueryProvider>
<DaemonRegistryProvider>
<PushNotificationRouter />
<MultiDaemonSessionHost />
<ProvidersWrapper>
<SidebarAnimationProvider>
<HorizontalScrollProvider>
<ToastProvider>
<AppWithSidebar>
<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>
</DaemonRegistryProvider>
</QueryProvider>
</BottomSheetModalProvider>
</KeyboardProvider>
</SafeAreaProvider>
</PortalProvider>
</PerfDiagnosticsProvider>
<PortalProvider>
<SafeAreaProvider>
<KeyboardProvider>
<BottomSheetModalProvider>
<QueryProvider>
<HostRuntimeBootstrapProvider>
<PushNotificationRouter />
<HostSessionManager />
<ProvidersWrapper>
<SidebarAnimationProvider>
<HorizontalScrollProvider>
<ToastProvider>
<AppWithSidebar>
<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>
</QueryProvider>
</BottomSheetModalProvider>
</KeyboardProvider>
</SafeAreaProvider>
</PortalProvider>
</GestureHandlerRootView>
);
}

View File

@@ -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
}

View File

@@ -1,65 +1,113 @@
import { useEffect, useMemo } from "react";
import { useLocalSearchParams, usePathname, useRouter } from "expo-router";
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
import { useFormPreferences } from "@/hooks/use-form-preferences";
import { buildHostRootRoute } from "@/utils/host-routes";
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
import { WelcomeScreen } from "@/components/welcome-screen";
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 './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
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
},
() => {
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 params = useLocalSearchParams<{ serverId?: string }>();
const { daemons, isLoading: registryLoading, isReconciling } = useDaemonRegistry();
const { preferences, isLoading: preferencesLoading } = useFormPreferences();
const requestedServerId = useMemo(() => {
return typeof params.serverId === "string" ? params.serverId.trim() : "";
}, [params.serverId]);
const targetServerId = useMemo(() => {
if (daemons.length === 0) {
return null;
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)
}
if (requestedServerId) {
const requested = daemons.find(
(daemon) => daemon.serverId === requestedServerId
);
if (requested) {
return requested.serverId;
}
}
if (preferences.serverId) {
const match = daemons.find((daemon) => daemon.serverId === preferences.serverId);
if (match) {
return match.serverId;
}
}
return daemons[0]?.serverId ?? null;
}, [daemons, preferences.serverId, requestedServerId]);
}, [])
useEffect(() => {
if (registryLoading || preferencesLoading) {
return;
if (!onlineServerId) {
return
}
if (!targetServerId) {
return;
if (pathname !== '/' && pathname !== '') {
return
}
if (pathname !== "/" && pathname !== "") {
return;
}
router.replace(buildHostRootRoute(targetServerId) as any);
}, [pathname, preferencesLoading, registryLoading, router, targetServerId]);
router.replace(buildHostRootRoute(onlineServerId) as any)
}, [onlineServerId, pathname, router])
if (registryLoading || preferencesLoading) {
return <StartupSplashScreen />;
useEffect(() => {
if (
!shouldRedirectToWelcome({
onlineServerId,
hasTimedOut,
pathname,
isDesktopStartupRace,
daemonCount: daemons.length,
})
) {
return
}
router.replace(WELCOME_ROUTE as any)
}, [daemons.length, hasTimedOut, isDesktopStartupRace, onlineServerId, pathname, router])
if (
shouldWaitOnStartupRace({
onlineServerId,
hasTimedOut,
isDesktopStartupRace,
daemonCount: daemons.length,
pathname,
})
) {
return <StartupSplashScreen />
}
if (!targetServerId) {
if (isReconciling) {
return <StartupSplashScreen />;
}
return <WelcomeScreen />;
if (!onlineServerId) {
return <WelcomeScreen />
}
return null;
return null
}

View File

@@ -5,11 +5,11 @@ 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 { useDaemonRegistry } from "@/contexts/daemon-registry-context";
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 { probeConnection } from "@/utils/test-daemon-connection";
import { connectToDaemon } from "@/utils/test-daemon-connection";
import { ConnectionOfferSchema } from "@server/shared/connection-offer";
import {
buildHostRootRoute,
@@ -151,7 +151,8 @@ export default function PairScanScreen() {
const sourceServerId =
typeof params.sourceServerId === "string" ? params.sourceServerId : null;
const targetServerId = typeof params.targetServerId === "string" ? params.targetServerId : null;
const { daemons, upsertDaemonFromOfferUrl, updateHost } = useDaemonRegistry();
const daemons = useHosts();
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl, renameHost } = useHostMutations();
const [permission, requestPermission] = useCameraPermissions();
const [isPairing, setIsPairing] = useState(false);
@@ -241,7 +242,7 @@ export default function PairScanScreen() {
return;
}
await probeConnection(
const { client } = await connectToDaemon(
{
id: "probe",
type: "relay",
@@ -250,6 +251,7 @@ export default function PairScanScreen() {
},
{ serverId: offer.serverId },
);
await client.close().catch(() => undefined);
const isNewHost = !daemons.some((daemon) => daemon.serverId === offer.serverId);
const profile = await upsertDaemonFromOfferUrl(offerUrl);
@@ -311,7 +313,7 @@ export default function PairScanScreen() {
}}
onSave={(label) => {
const serverId = pendingNameHost.serverId;
void updateHost(serverId, { label }).finally(() => {
void renameHost(serverId, label).finally(() => {
setPendingNameHost(null);
returnToSource(serverId);
});

View File

@@ -3,14 +3,14 @@ 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 { useDaemonRegistry } from "@/contexts/daemon-registry-context";
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, isLoading: registryLoading } = useDaemonRegistry();
const daemons = useHosts();
const { preferences, isLoading: preferencesLoading } = useFormPreferences();
const targetServerId = useMemo(() => {
@@ -29,16 +29,16 @@ export default function LegacySettingsRoute() {
}, [daemons, preferences.serverId]);
useEffect(() => {
if (registryLoading || preferencesLoading) {
if (preferencesLoading) {
return;
}
if (!targetServerId) {
return;
}
router.replace(buildHostSettingsRoute(targetServerId) as any);
}, [preferencesLoading, registryLoading, router, targetServerId]);
}, [preferencesLoading, router, targetServerId]);
if (registryLoading || preferencesLoading) {
if (preferencesLoading) {
return (
<View
style={{

View File

@@ -0,0 +1,5 @@
import { WelcomeScreen } from '@/components/welcome-screen'
export default function WelcomeRoute() {
return <WelcomeScreen />
}

View File

@@ -2,9 +2,10 @@ import { useCallback, useRef, useState } from "react";
import { Alert, Text, TextInput, View } from "react-native";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { Link2 } from "lucide-react-native";
import { useDaemonRegistry, type HostProfile } from "@/contexts/daemon-registry-context";
import type { HostProfile } from "@/types/host-connection";
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
import { normalizeHostPort } from "@/utils/daemon-endpoints";
import { DaemonConnectionTestError, probeConnection } from "@/utils/test-daemon-connection";
import { DaemonConnectionTestError, connectToDaemon } from "@/utils/test-daemon-connection";
import { AdaptiveModalSheet, AdaptiveTextInput } from "./adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
@@ -129,7 +130,8 @@ export interface AddHostModalProps {
export function AddHostModal({ visible, onClose, onCancel, onSaved, targetServerId }: AddHostModalProps) {
const { theme } = useUnistyles();
const { daemons, upsertDirectConnection } = useDaemonRegistry();
const daemons = useHosts();
const { upsertDirectConnection } = useHostMutations();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
@@ -179,11 +181,12 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved, targetServer
setIsSaving(true);
setErrorMessage("");
const { serverId, hostname } = await probeConnection({
const { client, serverId, hostname } = await connectToDaemon({
id: "probe",
type: "directTcp",
endpoint,
});
await client.close().catch(() => undefined);
if (targetServerId && serverId !== targetServerId) {
const message = `That endpoint belongs to ${serverId}, not ${targetServerId}.`;
setErrorMessage(message);

View File

@@ -16,7 +16,6 @@ import { shortenPath } from '@/utils/shorten-path'
import { type AggregatedAgent } from '@/hooks/use-aggregated-agents'
import { useSessionStore } from '@/stores/session-store'
import { AgentStatusDot } from '@/components/agent-status-dot'
import { buildAgentNavigationKey, startNavigationTiming } from '@/utils/navigation-timing'
import { buildHostWorkspaceAgentRoute } from '@/utils/host-routes'
interface AgentListProps {
@@ -271,13 +270,6 @@ export function AgentList({
const serverId = agent.serverId
const agentId = agent.id
const navigationKey = buildAgentNavigationKey(serverId, agentId)
startNavigationTiming(navigationKey, {
from: 'home',
to: 'agent',
params: { serverId, agentId },
})
const shouldReplace = pathname.startsWith('/h/')
const navigate = shouldReplace ? router.replace : router.push

View File

@@ -67,17 +67,12 @@ import {
} from "./use-bottom-anchor-controller";
import { createMarkdownStyles } from "@/styles/markdown-styles";
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import { isPerfLoggingEnabled, measurePayload, perfLog } from "@/utils/perf";
import { getMarkdownListMarker } from "@/utils/markdown-list";
import { buildHostWorkspaceFileRoute } from "@/utils/host-routes";
const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message";
const isToolSequenceItem = (item?: StreamItem) =>
item?.kind === "tool_call" || item?.kind === "thought" || item?.kind === "todo_list";
const AGENT_STREAM_LOG_TAG = "[AgentStreamView]";
const STREAM_ITEM_LOG_MIN_COUNT = 200;
const STREAM_ITEM_LOG_DELTA_THRESHOLD = 50;
export interface AgentStreamViewHandle {
scrollToBottom(reason?: BottomAnchorLocalRequest["reason"]): void;
prepareForViewportChange(): void;
@@ -116,7 +111,6 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
[isMobile]
);
const [isNearBottom, setIsNearBottom] = useState(true);
const streamItemCountRef = useRef(0);
const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState<Set<string>>(new Set());
const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
@@ -462,74 +456,6 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
[pendingPermissions, agentId]
);
useEffect(() => {
if (!isPerfLoggingEnabled()) {
return;
}
const totalCount = streamItems.length;
const prevCount = streamItemCountRef.current;
if (totalCount === prevCount) {
return;
}
const delta = Math.abs(totalCount - prevCount);
streamItemCountRef.current = totalCount;
if (
totalCount < STREAM_ITEM_LOG_MIN_COUNT &&
delta < STREAM_ITEM_LOG_DELTA_THRESHOLD
) {
return;
}
let userCount = 0;
let assistantCount = 0;
let toolCallCount = 0;
let thoughtCount = 0;
let activityCount = 0;
let todoCount = 0;
for (const item of streamItems) {
switch (item.kind) {
case "user_message":
userCount += 1;
break;
case "assistant_message":
assistantCount += 1;
break;
case "tool_call":
toolCallCount += 1;
break;
case "thought":
thoughtCount += 1;
break;
case "activity_log":
activityCount += 1;
break;
case "todo_list":
todoCount += 1;
break;
default:
break;
}
}
const metrics =
totalCount >= STREAM_ITEM_LOG_MIN_COUNT
? measurePayload(streamItems)
: null;
perfLog(AGENT_STREAM_LOG_TAG, {
event: "stream_items",
agentId,
totalCount,
userCount,
assistantCount,
toolCallCount,
thoughtCount,
activityCount,
todoCount,
pendingPermissionCount: pendingPermissionItems.length,
streamHeadCount: streamHead?.length ?? 0,
payloadApproxBytes: metrics?.approxBytes ?? 0,
payloadFieldCount: metrics?.fieldCount ?? 0,
});
}, [agentId, pendingPermissionItems.length, streamHead, streamItems]);
const showWorkingIndicator = agent.status === "running";
const renderModel = useMemo<AgentStreamRenderModel>(() => {
const pendingPermissionsNode =

View File

@@ -41,7 +41,7 @@ import type {
AgentFileExplorerState,
ExplorerEntry,
} from "@/stores/session-store";
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
import { useHosts } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import { useDownloadStore } from "@/stores/download-store";
import {
@@ -105,7 +105,7 @@ export function FileExplorerPane({
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
const { daemons } = useDaemonRegistry();
const daemons = useHosts();
const daemonProfile = useMemo(
() => daemons.find((daemon) => daemon.serverId === serverId),
[daemons, serverId]

View File

@@ -38,7 +38,6 @@ import { useCheckoutPrStatusQuery } from "@/hooks/use-checkout-pr-status-query";
import { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-context";
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
import { Fonts } from "@/constants/theme";
import { getNowMs, isPerfLoggingEnabled, perfLog } from "@/utils/perf";
import { shouldAnchorHeaderBeforeCollapse } from "@/utils/git-diff-scroll";
import {
DropdownMenu,
@@ -66,11 +65,6 @@ function openURLInNewTab(url: string): void {
void openExternalUrl(url);
}
const DIFF_PANE_LOG_TAG = "[GitDiffPane]";
const DIFF_FILE_LOG_TAG = "[DiffFileSection]";
const DIFF_FILE_LOG_LINE_THRESHOLD = 500;
const DIFF_FILE_LOG_TOKEN_THRESHOLD = 5000;
type HighlightStyle = NonNullable<HighlightToken["style"]>;
interface HighlightedTextProps {
@@ -199,75 +193,14 @@ const DiffFileHeader = memo(function DiffFileHeader({
onHeaderHeightChange,
testID,
}: DiffFileSectionProps) {
const expandStartRef = useRef<number | null>(null);
const layoutYRef = useRef<number | null>(null);
const pressHandledRef = useRef(false);
const pressInRef = useRef<{ ts: number; pageX: number; pageY: number } | null>(null);
const { hunkCount, lineCount, tokenCount } = useMemo(() => {
let totalLines = 0;
let totalTokens = 0;
for (const hunk of file.hunks) {
totalLines += hunk.lines.length;
for (const line of hunk.lines) {
if (line.tokens) {
totalTokens += line.tokens.length;
}
}
}
return {
hunkCount: file.hunks.length,
lineCount: totalLines,
tokenCount: totalTokens,
};
}, [file]);
const shouldLogFileMetrics =
lineCount >= DIFF_FILE_LOG_LINE_THRESHOLD ||
tokenCount >= DIFF_FILE_LOG_TOKEN_THRESHOLD;
const toggleExpanded = useCallback(() => {
pressHandledRef.current = true;
if (isPerfLoggingEnabled() && shouldLogFileMetrics) {
expandStartRef.current = getNowMs();
perfLog(DIFF_FILE_LOG_TAG, {
event: "toggle",
path: file.path,
nextExpanded: !isExpanded,
hunkCount,
lineCount,
tokenCount,
});
}
onToggle(file.path);
}, [file.path, onToggle, isExpanded, hunkCount, lineCount, tokenCount, shouldLogFileMetrics]);
useEffect(() => {
if (!isPerfLoggingEnabled() || !shouldLogFileMetrics) {
return;
}
const startMs = expandStartRef.current;
if (startMs === null) {
return;
}
expandStartRef.current = null;
const logCommit = () => {
const durationMs = getNowMs() - startMs;
perfLog(DIFF_FILE_LOG_TAG, {
event: isExpanded ? "expand_commit" : "collapse_commit",
path: file.path,
durationMs: Math.round(durationMs),
hunkCount,
lineCount,
tokenCount,
});
};
if (typeof requestAnimationFrame === "function") {
requestAnimationFrame(() => logCommit());
} else {
logCommit();
}
}, [isExpanded, file.path, hunkCount, lineCount, tokenCount, shouldLogFileMetrics]);
}, [file.path, onToggle]);
return (
<View
@@ -506,30 +439,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
const headerHeightByPathRef = useRef<Record<string, number>>({});
const bodyHeightByPathRef = useRef<Record<string, number>>({});
const defaultHeaderHeightRef = useRef<number>(44);
const diffMetrics = useMemo(() => {
let hunkCount = 0;
let lineCount = 0;
let tokenCount = 0;
for (const file of files) {
hunkCount += file.hunks.length;
for (const hunk of file.hunks) {
lineCount += hunk.lines.length;
for (const line of hunk.lines) {
if (line.tokens) {
tokenCount += line.tokens.length;
}
}
}
}
return {
fileCount: files.length,
hunkCount,
lineCount,
tokenCount,
};
}, [files]);
const lastMetricsKeyRef = useRef<string | null>(null);
const handleRefresh = useCallback(() => {
setIsManualRefresh(true);
void refreshDiff();
@@ -712,28 +621,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
setDiffModeOverride(null);
}, [autoDiffMode]);
useEffect(() => {
if (!isPerfLoggingEnabled()) {
return;
}
const metricsKey = `${diffMetrics.fileCount}:${diffMetrics.hunkCount}:${diffMetrics.lineCount}:${diffMetrics.tokenCount}`;
if (lastMetricsKeyRef.current === metricsKey) {
return;
}
lastMetricsKeyRef.current = metricsKey;
perfLog(DIFF_PANE_LOG_TAG, {
event: "files_snapshot",
serverId,
workspaceId: workspaceId ?? cwd,
fileCount: diffMetrics.fileCount,
hunkCount: diffMetrics.hunkCount,
lineCount: diffMetrics.lineCount,
tokenCount: diffMetrics.tokenCount,
isLoading: isDiffLoading,
isFetching: isDiffFetching,
});
}, [cwd, diffMetrics, isDiffFetching, isDiffLoading, serverId, workspaceId]);
const commitStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "commit" })
);

View File

@@ -19,8 +19,7 @@ import { useSidebarWorkspacesList } from '@/hooks/use-sidebar-workspaces-list'
import { useSidebarAnimation } from '@/contexts/sidebar-animation-context'
import { useTauriDragHandlers, useTrafficLightPadding } from '@/utils/tauri-window'
import { Combobox } from '@/components/ui/combobox'
import { useDaemonRegistry } from '@/contexts/daemon-registry-context'
import { getHostRuntimeStore } from '@/runtime/host-runtime'
import { getHostRuntimeStore, useHosts } from '@/runtime/host-runtime'
import { formatConnectionStatus } from '@/utils/daemons'
import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from '@/constants/layout'
import {
@@ -53,7 +52,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen)
const closeToAgent = usePanelStore((state) => state.closeToAgent)
const pathname = usePathname()
const { daemons } = useDaemonRegistry()
const daemons = useHosts()
const runtime = getHostRuntimeStore()
const runtimeConnectionStatusSignature = useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange),

View File

@@ -68,7 +68,6 @@ import {
buildToolCallDisplayModel,
} from "@/utils/tool-call-display";
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
import { getNowMs, isPerfLoggingEnabled, perfLog } from "@/utils/perf";
import { parseInlinePathToken, type InlinePathTarget } from "@/utils/inline-path";
import { getMarkdownListMarker } from "@/utils/markdown-list";
import { openExternalUrl } from "@/utils/open-external-url";
@@ -1786,10 +1785,6 @@ interface ToolCallProps {
onInlineDetailsExpandedChange?: (expanded: boolean) => void;
}
const TOOL_CALL_LOG_TAG = "[ToolCall]";
const TOOL_CALL_COMMIT_THRESHOLD_MS = 16;
export const ToolCall = memo(function ToolCall({
toolName,
args,
@@ -1806,7 +1801,6 @@ export const ToolCall = memo(function ToolCall({
}: ToolCallProps) {
const { openToolCall } = useToolCallSheet();
const [isExpanded, setIsExpanded] = useState(false);
const toggleStartRef = useRef<number | null>(null);
// Check if we're on mobile (use bottom sheet) or desktop (inline expand)
const isMobile =
@@ -1849,7 +1843,6 @@ export const ToolCall = memo(function ToolCall({
const displayName = displayModel.displayName;
const summary = displayModel.summary;
const errorText = displayModel.errorText;
const iconCategory = effectiveDetail?.type ?? toolName.trim().toLowerCase();
const IconComponent = resolveToolCallIcon(toolName, effectiveDetail);
// Check if there's any content to display
@@ -1862,9 +1855,6 @@ export const ToolCall = memo(function ToolCall({
: false);
const handleToggle = useCallback(() => {
if (!isMobile && isPerfLoggingEnabled()) {
toggleStartRef.current = getNowMs();
}
if (isMobile) {
openToolCall({
toolName,
@@ -1878,33 +1868,6 @@ export const ToolCall = memo(function ToolCall({
}
}, [isMobile, openToolCall, toolName, displayName, summary, effectiveDetail, errorText]);
useEffect(() => {
if (isMobile || !isPerfLoggingEnabled()) {
return;
}
const startMs = toggleStartRef.current;
if (startMs === null) {
return;
}
toggleStartRef.current = null;
const logCommit = () => {
const durationMs = getNowMs() - startMs;
if (durationMs >= TOOL_CALL_COMMIT_THRESHOLD_MS) {
perfLog(TOOL_CALL_LOG_TAG, {
event: isExpanded ? "expand_commit" : "collapse_commit",
toolName,
iconCategory,
durationMs: Math.round(durationMs),
});
}
};
if (typeof requestAnimationFrame === "function") {
requestAnimationFrame(() => logCommit());
} else {
logCommit();
}
}, [isExpanded, isMobile, toolName, iconCategory]);
useEffect(() => {
if (!onInlineDetailsHoverChange || isMobile || isExpanded) {
return;

View File

@@ -1,46 +0,0 @@
import { useEffect } from "react";
import { SessionProvider } from "@/contexts/session-context";
import { useDaemonRegistry, type HostProfile } from "@/contexts/daemon-registry-context";
import {
getHostRuntimeStore,
useHostRuntimeSession,
} from "@/runtime/host-runtime";
function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
const { client } = useHostRuntimeSession(daemon.serverId);
if (!client) {
return null;
}
return (
<SessionProvider
key={daemon.serverId}
serverId={daemon.serverId}
client={client}
>
{null}
</SessionProvider>
);
}
export function MultiDaemonSessionHost() {
const { daemons } = useDaemonRegistry();
useEffect(() => {
const runtime = getHostRuntimeStore();
runtime.syncHosts(daemons);
}, [daemons]);
if (daemons.length === 0) {
return null;
}
return (
<>
{daemons.map((daemon) => (
<ManagedDaemonSession key={daemon.serverId} daemon={daemon} />
))}
</>
);
}

View File

@@ -2,9 +2,10 @@ import { useCallback, useState } from "react";
import { Alert, Text, View } from "react-native";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { Link } from "lucide-react-native";
import { useDaemonRegistry, type HostProfile } from "@/contexts/daemon-registry-context";
import type { HostProfile } from "@/types/host-connection";
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
import { probeConnection } from "@/utils/test-daemon-connection";
import { connectToDaemon } from "@/utils/test-daemon-connection";
import { ConnectionOfferSchema } from "@server/shared/connection-offer";
import { AdaptiveModalSheet, AdaptiveTextInput } from "./adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
@@ -52,7 +53,8 @@ export interface PairLinkModalProps {
export function PairLinkModal({ visible, onClose, onCancel, onSaved, targetServerId }: PairLinkModalProps) {
const { theme } = useUnistyles();
const { daemons, upsertDaemonFromOfferUrl } = useDaemonRegistry();
const daemons = useHosts();
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
@@ -122,7 +124,7 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved, targetServe
setIsSaving(true);
setErrorMessage("");
const probeResult = await probeConnection(
const { client, hostname } = await connectToDaemon(
{
id: "probe",
type: "relay",
@@ -131,10 +133,11 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved, targetServe
},
{ serverId: parsedOffer.serverId },
);
await client.close().catch(() => undefined);
const isNewHost = !daemons.some((daemon) => daemon.serverId === parsedOffer.serverId);
const profile = await upsertDaemonFromOfferUrl(raw);
onSaved?.({ profile, serverId: parsedOffer.serverId, hostname: probeResult.hostname, isNewHost });
onSaved?.({ profile, serverId: parsedOffer.serverId, hostname, isNewHost });
handleClose();
} catch (error) {
const message = error instanceof Error ? error.message : "Unable to pair host";

View File

@@ -17,8 +17,7 @@ import {
normalizeWorkspaceDescriptor,
useSessionStore,
} from "@/stores/session-store";
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
import { useHostRuntimeSession } from "@/runtime/host-runtime";
import { useHosts, useHostRuntimeSession } from "@/runtime/host-runtime";
import { useToast } from "@/contexts/toast-context";
import { parseServerIdFromPathname } from "@/utils/host-routes";
import { buildHostWorkspaceRouteWithOpenIntent } from "@/utils/host-routes";
@@ -28,7 +27,7 @@ export function ProjectPickerModal() {
const { theme } = useUnistyles();
const toast = useToast();
const pathname = usePathname();
const { daemons } = useDaemonRegistry();
const daemons = useHosts();
const open = useKeyboardShortcutsStore((s) => s.projectPickerOpen);
const setOpen = useKeyboardShortcutsStore((s) => s.setProjectPickerOpen);

View File

@@ -3,11 +3,11 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { MicOff, Square } from "lucide-react-native";
import { VolumeMeter } from "./volume-meter";
import { useVoice } from "@/contexts/voice-context";
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
import { useHosts } from "@/runtime/host-runtime";
export function VoicePanel() {
const { theme } = useUnistyles();
const { daemons } = useDaemonRegistry();
const daemons = useHosts();
const {
volume,
isMuted,

View File

@@ -3,8 +3,8 @@ import { Image, Pressable, Text, View, Platform, ScrollView } from "react-native
import { useRouter } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
import type { HostProfile } from "@/contexts/daemon-registry-context";
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
import type { HostProfile } from "@/types/host-connection";
import { useHostMutations } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import { AddHostModal } from "./add-host-modal";
import { PairLinkModal } from "./pair-link-modal";
@@ -87,7 +87,7 @@ export interface WelcomeScreenProps {
export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
const { theme } = useUnistyles();
const router = useRouter();
const { updateHost } = useDaemonRegistry();
const { renameHost } = useHostMutations();
const appVersion = resolveAppVersion();
const appVersionText = formatVersionWithPrefix(appVersion);
const [isDirectOpen, setIsDirectOpen] = useState(false);
@@ -201,7 +201,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
}}
onSave={(label) => {
const serverId = pendingRedirectServerId;
void updateHost(pendingNameHost.serverId, { label }).finally(() => {
void renameHost(pendingNameHost.serverId, label).finally(() => {
setPendingNameHost(null);
setPendingRedirectServerId(null);
finishOnboarding(serverId);

View File

@@ -1,323 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
hostHasDirectEndpoint,
registryHasDirectEndpoint,
reconcileDesktopStartupRegistry,
resolveManagedDesktopStartupStatus,
type HostProfile,
} from './daemon-registry-context'
function makeHost(input: Partial<HostProfile> & Pick<HostProfile, 'serverId'>): HostProfile {
const now = '2026-01-01T00:00:00.000Z'
return {
serverId: input.serverId,
label: input.label ?? input.serverId,
lifecycle: input.lifecycle ?? {
managed: false,
managedRuntimeId: null,
managedRuntimeVersion: null,
associatedServerId: null,
},
connections: input.connections ?? [],
preferredConnectionId: input.preferredConnectionId ?? null,
createdAt: input.createdAt ?? now,
updatedAt: input.updatedAt ?? now,
}
}
describe('hostHasDirectEndpoint', () => {
it('returns true when host has matching direct endpoint', () => {
const host = makeHost({
serverId: 'srv_local',
connections: [{ id: 'direct:localhost:6767', type: 'directTcp', endpoint: 'localhost:6767' }],
preferredConnectionId: 'direct:localhost:6767',
})
expect(hostHasDirectEndpoint(host, 'localhost:6767')).toBe(true)
})
it('returns false when only relay connections exist', () => {
const host = makeHost({
serverId: 'srv_relay',
connections: [
{
id: 'relay:relay.example:443',
type: 'relay',
relayEndpoint: 'relay.example:443',
daemonPublicKeyB64: 'abcd',
},
],
preferredConnectionId: 'relay:relay.example:443',
})
expect(hostHasDirectEndpoint(host, 'localhost:6767')).toBe(false)
})
})
describe('registryHasDirectEndpoint', () => {
it('returns true when any host contains the direct endpoint', () => {
const hosts: HostProfile[] = [
makeHost({
serverId: 'srv_one',
connections: [{ id: 'direct:127.0.0.1:7777', type: 'directTcp', endpoint: '127.0.0.1:7777' }],
preferredConnectionId: 'direct:127.0.0.1:7777',
}),
makeHost({
serverId: 'srv_two',
connections: [{ id: 'direct:localhost:6767', type: 'directTcp', endpoint: 'localhost:6767' }],
preferredConnectionId: 'direct:localhost:6767',
}),
]
expect(registryHasDirectEndpoint(hosts, 'localhost:6767')).toBe(true)
})
it('returns false when no host has the endpoint', () => {
const hosts: HostProfile[] = [
makeHost({
serverId: 'srv_one',
connections: [{ id: 'direct:127.0.0.1:7777', type: 'directTcp', endpoint: '127.0.0.1:7777' }],
preferredConnectionId: 'direct:127.0.0.1:7777',
}),
]
expect(registryHasDirectEndpoint(hosts, 'localhost:6767')).toBe(false)
})
})
describe('reconcileDesktopStartupRegistry', () => {
it('seeds managed and localhost connections as normal host entries', () => {
const now = '2026-03-08T00:00:00.000Z'
const result = reconcileDesktopStartupRegistry({
existing: [],
managed: {
serverId: 'srv_managed',
hostname: 'managed-host',
runtimeId: 'runtime_1',
runtimeVersion: '1.2.3',
transportType: 'socket',
transportPath: '/Users/test/.paseo-test/paseo.sock',
associatedServerId: 'srv_managed',
},
localhost: {
serverId: 'srv_localhost',
hostname: 'local-dev',
endpoint: 'localhost:6767',
},
now,
})
expect(result).toEqual([
makeHost({
serverId: 'srv_managed',
label: 'managed-host',
lifecycle: {
managed: true,
managedRuntimeId: 'runtime_1',
managedRuntimeVersion: '1.2.3',
associatedServerId: 'srv_managed',
},
connections: [
{
id: 'socket:/Users/test/.paseo-test/paseo.sock',
type: 'directSocket',
path: '/Users/test/.paseo-test/paseo.sock',
},
],
preferredConnectionId: 'socket:/Users/test/.paseo-test/paseo.sock',
createdAt: now,
updatedAt: now,
}),
makeHost({
serverId: 'srv_localhost',
label: 'local-dev',
connections: [
{
id: 'direct:localhost:6767',
type: 'directTcp',
endpoint: 'localhost:6767',
},
],
preferredConnectionId: 'direct:localhost:6767',
createdAt: now,
updatedAt: now,
}),
])
})
it('keeps managed and localhost connections together when they resolve to the same server', () => {
const now = '2026-03-08T00:00:00.000Z'
const result = reconcileDesktopStartupRegistry({
existing: [],
managed: {
serverId: 'srv_shared',
hostname: 'devbox',
runtimeId: 'runtime_1',
runtimeVersion: '1.2.3',
transportType: 'socket',
transportPath: '/Users/test/.paseo-test/paseo.sock',
associatedServerId: 'srv_shared',
},
localhost: {
serverId: 'srv_shared',
hostname: 'devbox',
endpoint: 'localhost:6767',
},
now,
})
expect(result).toEqual([
makeHost({
serverId: 'srv_shared',
label: 'devbox',
lifecycle: {
managed: true,
managedRuntimeId: 'runtime_1',
managedRuntimeVersion: '1.2.3',
associatedServerId: 'srv_shared',
},
connections: [
{
id: 'socket:/Users/test/.paseo-test/paseo.sock',
type: 'directSocket',
path: '/Users/test/.paseo-test/paseo.sock',
},
{
id: 'direct:localhost:6767',
type: 'directTcp',
endpoint: 'localhost:6767',
},
],
preferredConnectionId: 'socket:/Users/test/.paseo-test/paseo.sock',
createdAt: now,
updatedAt: now,
}),
])
})
it('is idempotent for repeated desktop startup reconciliation', () => {
const now = '2026-03-08T00:00:00.000Z'
const first = reconcileDesktopStartupRegistry({
existing: [],
managed: {
serverId: 'srv_shared',
hostname: 'devbox',
runtimeId: 'runtime_1',
runtimeVersion: '1.2.3',
transportType: 'socket',
transportPath: '/Users/test/.paseo-test/paseo.sock',
associatedServerId: 'srv_shared',
},
localhost: {
serverId: 'srv_shared',
hostname: 'devbox',
endpoint: 'localhost:6767',
},
now,
})
const second = reconcileDesktopStartupRegistry({
existing: first,
managed: {
serverId: 'srv_shared',
hostname: 'devbox',
runtimeId: 'runtime_1',
runtimeVersion: '1.2.3',
transportType: 'socket',
transportPath: '/Users/test/.paseo-test/paseo.sock',
associatedServerId: 'srv_shared',
},
localhost: {
serverId: 'srv_shared',
hostname: 'devbox',
endpoint: 'localhost:6767',
},
now: '2026-03-09T00:00:00.000Z',
})
expect(second).toEqual(first)
})
})
describe('resolveManagedDesktopStartupStatus', () => {
it('starts the managed daemon when management is enabled', async () => {
const managedStatus = {
runtimeId: 'runtime_1',
runtimeVersion: '1.2.3',
runtimeRoot: '/runtime',
managedHome: '/home',
transportType: 'socket',
transportPath: '/tmp/paseo.sock',
daemonPid: 123,
daemonRunning: true,
daemonStatus: 'running',
logPath: '/tmp/daemon.log',
serverId: 'srv_managed',
hostname: 'managed-host',
relayEnabled: true,
tcpEnabled: false,
tcpListen: null,
cliShimPath: null,
}
let startCalls = 0
let statusCalls = 0
const result = await resolveManagedDesktopStartupStatus({
settings: { manageBuiltInDaemon: true },
startManagedDaemonFn: async () => {
startCalls += 1
return managedStatus
},
getManagedDaemonStatusFn: async () => {
statusCalls += 1
return managedStatus
},
})
expect(result).toEqual(managedStatus)
expect(startCalls).toBe(1)
expect(statusCalls).toBe(0)
})
it('only reads managed daemon status when management is paused', async () => {
const managedStatus = {
runtimeId: 'runtime_1',
runtimeVersion: '1.2.3',
runtimeRoot: '/runtime',
managedHome: '/home',
transportType: 'socket',
transportPath: '/tmp/paseo.sock',
daemonPid: null,
daemonRunning: false,
daemonStatus: 'stopped',
logPath: '/tmp/daemon.log',
serverId: null,
hostname: null,
relayEnabled: true,
tcpEnabled: false,
tcpListen: null,
cliShimPath: null,
}
let startCalls = 0
let statusCalls = 0
const result = await resolveManagedDesktopStartupStatus({
settings: { manageBuiltInDaemon: false },
startManagedDaemonFn: async () => {
startCalls += 1
return managedStatus
},
getManagedDaemonStatusFn: async () => {
statusCalls += 1
return managedStatus
},
})
expect(result).toEqual(managedStatus)
expect(startCalls).toBe(0)
expect(statusCalls).toBe(1)
})
})

View File

@@ -1,941 +0,0 @@
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import AsyncStorage from '@react-native-async-storage/async-storage'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { decodeOfferFragmentPayload, normalizeHostPort } from '@/utils/daemon-endpoints'
import { probeConnection } from '@/utils/test-daemon-connection'
import { useAppSettings, type AppSettings } from '@/hooks/use-settings'
import { ConnectionOfferSchema, type ConnectionOffer } from '@server/shared/connection-offer'
import {
getManagedDaemonStatus,
type ManagedDaemonStatus,
shouldUseManagedDesktopDaemon,
startManagedDaemon,
} from '@/desktop/managed-runtime/managed-runtime'
const REGISTRY_STORAGE_KEY = '@paseo:daemon-registry'
const DAEMON_REGISTRY_QUERY_KEY = ['daemon-registry']
const DEFAULT_LOCALHOST_ENDPOINT = 'localhost:6767'
const DEFAULT_LOCALHOST_BOOTSTRAP_KEY = '@paseo:default-localhost-bootstrap-v1'
const DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS = 2500
const DEFAULT_LOCAL_TRANSPORT_BOOTSTRAP_TIMEOUT_MS = 6000
const DEFAULT_LOCAL_TRANSPORT_BOOTSTRAP_RETRY_MS = 2000
const DEFAULT_LOCAL_TRANSPORT_BOOTSTRAP_DEADLINE_MS = 120000
const E2E_STORAGE_KEY = '@paseo:e2e'
export type DirectTcpHostConnection = {
id: string
type: 'directTcp'
endpoint: string
}
export type DirectSocketHostConnection = {
id: string
type: 'directSocket'
path: string
}
export type DirectPipeHostConnection = {
id: string
type: 'directPipe'
path: string
}
export type RelayHostConnection = {
id: string
type: 'relay'
relayEndpoint: string
daemonPublicKeyB64: string
}
export type HostConnection =
| DirectTcpHostConnection
| DirectSocketHostConnection
| DirectPipeHostConnection
| RelayHostConnection
export type HostLifecycle = {
managed: boolean
managedRuntimeId: string | null
managedRuntimeVersion: string | null
associatedServerId: string | null
}
export type HostProfile = {
serverId: string
label: string
lifecycle: HostLifecycle
connections: HostConnection[]
preferredConnectionId: string | null
createdAt: string
updatedAt: string
}
export type UpdateHostInput = Partial<Omit<HostProfile, 'serverId' | 'createdAt'>>
export type ManagedHostReconciliationInput = {
serverId: string
hostname?: string | null
runtimeId: string
runtimeVersion: string
transportType: string
transportPath: string
associatedServerId?: string | null
}
export type LocalhostHostReconciliationInput = {
serverId: string
hostname: string | null
endpoint: string
}
export type DesktopStartupReconciliationInput = {
existing: HostProfile[]
managed: ManagedHostReconciliationInput | null
localhost: LocalhostHostReconciliationInput | null
now?: string
}
interface DaemonRegistryContextValue {
daemons: HostProfile[]
isLoading: boolean
isReconciling: boolean
error: unknown | null
upsertDirectConnection: (input: {
serverId: string
endpoint: string
label?: string
}) => Promise<HostProfile>
upsertRelayConnection: (input: {
serverId: string
relayEndpoint: string
daemonPublicKeyB64: string
label?: string
}) => Promise<HostProfile>
updateHost: (serverId: string, updates: UpdateHostInput) => Promise<void>
removeHost: (serverId: string) => Promise<void>
removeConnection: (serverId: string, connectionId: string) => Promise<void>
upsertDaemonFromOffer: (offer: ConnectionOffer) => Promise<HostProfile>
upsertDaemonFromOfferUrl: (offerUrlOrFragment: string) => Promise<HostProfile>
}
const DaemonRegistryContext = createContext<DaemonRegistryContextValue | null>(null)
function defaultLifecycle(): HostLifecycle {
return {
managed: false,
managedRuntimeId: null,
managedRuntimeVersion: null,
associatedServerId: null,
}
}
function normalizeHostLabel(value: string | null | undefined, serverId: string): string {
const trimmed = value?.trim() ?? ''
return trimmed.length > 0 ? trimmed : serverId
}
function normalizeEndpointOrNull(endpoint: string): string | null {
try {
return normalizeHostPort(endpoint)
} catch {
return null
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms)
})
}
function normalizeManagedTransportConnection(input: {
transportType: string
transportPath: string
}): HostConnection | null {
const transportPath = input.transportPath.trim()
if (!transportPath) {
return null
}
if (input.transportType === 'tcp') {
try {
const endpoint = normalizeHostPort(transportPath)
return {
id: `direct:${endpoint}`,
type: 'directTcp',
endpoint,
}
} catch {
return null
}
}
if (input.transportType === 'pipe') {
return {
id: `pipe:${transportPath}`,
type: 'directPipe',
path: transportPath,
}
}
if (input.transportType === 'socket') {
return {
id: `socket:${transportPath}`,
type: 'directSocket',
path: transportPath,
}
}
return null
}
function normalizeStoredConnection(connection: unknown): HostConnection | null {
if (!connection || typeof connection !== 'object') {
return null
}
const record = connection as Record<string, unknown>
const type = typeof record.type === 'string' ? record.type : null
if (type === 'directTcp') {
try {
const endpoint = normalizeHostPort(String(record.endpoint ?? ''))
return { id: `direct:${endpoint}`, type: 'directTcp', endpoint }
} catch {
return null
}
}
if (type === 'directSocket') {
const path = String(record.path ?? '').trim()
return path ? { id: `socket:${path}`, type: 'directSocket', path } : null
}
if (type === 'directPipe') {
const path = String(record.path ?? '').trim()
return path ? { id: `pipe:${path}`, type: 'directPipe', path } : null
}
if (type === 'relay') {
try {
const relayEndpoint = normalizeHostPort(String(record.relayEndpoint ?? ''))
const daemonPublicKeyB64 = String(record.daemonPublicKeyB64 ?? '').trim()
if (!daemonPublicKeyB64) return null
return {
id: `relay:${relayEndpoint}`,
type: 'relay',
relayEndpoint,
daemonPublicKeyB64,
}
} catch {
return null
}
}
return null
}
function normalizeStoredLifecycle(lifecycle: unknown): HostLifecycle {
const record =
lifecycle && typeof lifecycle === 'object' ? (lifecycle as Record<string, unknown>) : null
return {
managed: record?.managed === true,
managedRuntimeId:
typeof record?.managedRuntimeId === 'string' ? record.managedRuntimeId : null,
managedRuntimeVersion:
typeof record?.managedRuntimeVersion === 'string' ? record.managedRuntimeVersion : null,
associatedServerId:
typeof record?.associatedServerId === 'string' ? record.associatedServerId : null,
}
}
function normalizeStoredHostProfile(entry: unknown): HostProfile | null {
if (!entry || typeof entry !== 'object') {
return null
}
const record = entry as Record<string, unknown>
const serverId = typeof record.serverId === 'string' ? record.serverId.trim() : ''
if (!serverId) {
return null
}
const rawConnections = Array.isArray(record.connections) ? record.connections : []
const connections = rawConnections
.map((connection) => normalizeStoredConnection(connection))
.filter((connection): connection is HostConnection => connection !== null)
if (connections.length === 0) {
return null
}
const lifecycle = normalizeStoredLifecycle(record.lifecycle)
const now = new Date().toISOString()
const label = normalizeHostLabel(
typeof record.label === 'string' ? record.label : null,
serverId
)
const preferredConnectionId =
typeof record.preferredConnectionId === 'string' &&
connections.some((connection) => connection.id === record.preferredConnectionId)
? record.preferredConnectionId
: connections[0]?.id ?? null
return {
serverId,
label,
lifecycle,
connections,
preferredConnectionId,
createdAt: typeof record.createdAt === 'string' ? record.createdAt : now,
updatedAt: typeof record.updatedAt === 'string' ? record.updatedAt : now,
}
}
function hostConnectionEquals(left: HostConnection, right: HostConnection): boolean {
if (left.type !== right.type || left.id !== right.id) {
return false
}
if (left.type === 'directTcp' && right.type === 'directTcp') {
return left.endpoint === right.endpoint
}
if (left.type === 'directSocket' && right.type === 'directSocket') {
return left.path === right.path
}
if (left.type === 'directPipe' && right.type === 'directPipe') {
return left.path === right.path
}
if (left.type === 'relay' && right.type === 'relay') {
return (
left.relayEndpoint === right.relayEndpoint &&
left.daemonPublicKeyB64 === right.daemonPublicKeyB64
)
}
return false
}
function hostLifecycleEquals(left: HostLifecycle, right: HostLifecycle): boolean {
return (
left.managed === right.managed &&
left.managedRuntimeId === right.managedRuntimeId &&
left.managedRuntimeVersion === right.managedRuntimeVersion &&
left.associatedServerId === right.associatedServerId
)
}
function upsertHostConnectionInProfiles(input: {
profiles: HostProfile[]
serverId: string
label?: string
lifecycle?: Partial<HostLifecycle>
connection: HostConnection
now?: string
}): HostProfile[] {
const serverId = input.serverId.trim()
if (!serverId) {
throw new Error('serverId is required')
}
const now = input.now ?? new Date().toISOString()
const labelTrimmed = input.label?.trim() ?? ''
const derivedLabel = labelTrimmed || serverId
const existing = input.profiles
const idx = existing.findIndex((daemon) => daemon.serverId === serverId)
if (idx === -1) {
const profile: HostProfile = {
serverId,
label: derivedLabel,
lifecycle: {
...defaultLifecycle(),
...(input.lifecycle ?? {}),
},
connections: [input.connection],
preferredConnectionId: input.connection.id,
createdAt: now,
updatedAt: now,
}
return [...existing, profile]
}
const prev = existing[idx]!
const connectionIdx = prev.connections.findIndex((connection) => connection.id === input.connection.id)
const hadConnection = connectionIdx !== -1
const connectionChanged =
connectionIdx === -1
? true
: !hostConnectionEquals(prev.connections[connectionIdx]!, input.connection)
const nextConnections =
connectionIdx === -1
? [...prev.connections, input.connection]
: connectionChanged
? prev.connections.map((connection, index) =>
index === connectionIdx ? input.connection : connection
)
: prev.connections
const nextLifecycle = {
...prev.lifecycle,
...(input.lifecycle ?? {}),
}
const nextLabel = labelTrimmed ? labelTrimmed : prev.label
const nextPreferredConnectionId = prev.preferredConnectionId ?? input.connection.id
const changed =
nextLabel !== prev.label ||
nextPreferredConnectionId !== prev.preferredConnectionId ||
!hostLifecycleEquals(prev.lifecycle, nextLifecycle) ||
!hadConnection ||
connectionChanged
if (!changed) {
return existing
}
const nextProfile: HostProfile = {
...prev,
label: nextLabel,
lifecycle: nextLifecycle,
connections: nextConnections,
preferredConnectionId: nextPreferredConnectionId,
updatedAt: now,
}
const next = [...existing]
next[idx] = nextProfile
return next
}
function reconcileManagedHostInProfiles(input: {
profiles: HostProfile[]
managed: ManagedHostReconciliationInput
now?: string
}): HostProfile[] {
const connection = normalizeManagedTransportConnection(input.managed)
if (!connection) {
throw new Error(`Unsupported managed daemon transport: ${input.managed.transportType}`)
}
const nextBase = input.profiles.filter((daemon) => {
return !daemon.lifecycle.managed || daemon.serverId === input.managed.serverId
})
const profiles = nextBase.length === input.profiles.length ? input.profiles : nextBase
return upsertHostConnectionInProfiles({
profiles,
serverId: input.managed.serverId,
label: input.managed.hostname ?? undefined,
lifecycle: {
managed: true,
managedRuntimeId: input.managed.runtimeId,
managedRuntimeVersion: input.managed.runtimeVersion,
associatedServerId:
input.managed.associatedServerId?.trim() || input.managed.serverId,
},
connection,
now: input.now,
})
}
export function reconcileDesktopStartupRegistry(
input: DesktopStartupReconciliationInput
): HostProfile[] {
let next = input.existing
if (input.managed) {
next = reconcileManagedHostInProfiles({
profiles: next,
managed: input.managed,
now: input.now,
})
}
if (input.localhost) {
next = upsertHostConnectionInProfiles({
profiles: next,
serverId: input.localhost.serverId,
label: input.localhost.hostname ?? undefined,
connection: {
id: `direct:${input.localhost.endpoint}`,
type: 'directTcp',
endpoint: input.localhost.endpoint,
},
now: input.now,
})
}
return next
}
export async function resolveManagedDesktopStartupStatus(input: {
settings: Pick<AppSettings, 'manageBuiltInDaemon'>
startManagedDaemonFn?: () => Promise<ManagedDaemonStatus>
getManagedDaemonStatusFn?: () => Promise<ManagedDaemonStatus>
}): Promise<ManagedDaemonStatus> {
if (input.settings.manageBuiltInDaemon) {
return await (input.startManagedDaemonFn ?? startManagedDaemon)()
}
return await (input.getManagedDaemonStatusFn ?? getManagedDaemonStatus)()
}
async function probeManagedStartupTarget(input: {
managedDaemon: ManagedDaemonStatus
cancelled?: () => boolean
}): Promise<ManagedHostReconciliationInput | null> {
const connection = normalizeManagedTransportConnection({
transportType: input.managedDaemon.transportType,
transportPath: input.managedDaemon.transportPath,
})
if (!connection) {
return null
}
let serverId = input.managedDaemon.serverId
let hostname = input.managedDaemon.hostname
if (!serverId) {
const probed = await probeConnection(connection, {
timeoutMs: DEFAULT_LOCAL_TRANSPORT_BOOTSTRAP_TIMEOUT_MS,
})
if (input.cancelled?.()) {
throw new Error('Managed daemon bootstrap cancelled')
}
serverId = probed.serverId
hostname = hostname ?? probed.hostname
}
return {
serverId,
hostname,
runtimeId: input.managedDaemon.runtimeId,
runtimeVersion: input.managedDaemon.runtimeVersion,
transportType: input.managedDaemon.transportType,
transportPath: input.managedDaemon.transportPath,
associatedServerId: input.managedDaemon.serverId,
}
}
async function probeManagedConnectionUntilReady(
input: {
managedDaemon: ManagedDaemonStatus
cancelled?: () => boolean
}
): Promise<ManagedHostReconciliationInput | null> {
const startedAt = Date.now()
let lastError: unknown = null
while (Date.now() - startedAt < DEFAULT_LOCAL_TRANSPORT_BOOTSTRAP_DEADLINE_MS) {
if (input.cancelled?.()) {
throw new Error('Managed daemon bootstrap cancelled')
}
try {
return await probeManagedStartupTarget(input)
} catch (error) {
lastError = error
if (input.cancelled?.()) {
throw error
}
await sleep(DEFAULT_LOCAL_TRANSPORT_BOOTSTRAP_RETRY_MS)
}
}
throw lastError ?? new Error('Managed daemon bootstrap timed out')
}
export function hostHasDirectEndpoint(host: HostProfile, endpoint: string): boolean {
const normalized = normalizeEndpointOrNull(endpoint)
if (!normalized) {
return false
}
return host.connections.some(
(connection) => connection.type === 'directTcp' && connection.endpoint === normalized
)
}
export function registryHasDirectEndpoint(hosts: HostProfile[], endpoint: string): boolean {
return hosts.some((host) => hostHasDirectEndpoint(host, endpoint))
}
export function useDaemonRegistry(): DaemonRegistryContextValue {
const ctx = useContext(DaemonRegistryContext)
if (!ctx) {
throw new Error('useDaemonRegistry must be used within DaemonRegistryProvider')
}
return ctx
}
export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
const queryClient = useQueryClient()
const desktopStartupReconciledRef = useRef(false)
const localhostBootstrapAttemptedRef = useRef(false)
const [isReconciling, setIsReconciling] = useState(true)
const { settings, isLoading: settingsLoading } = useAppSettings()
const {
data: daemons = [],
isPending,
error,
} = useQuery({
queryKey: DAEMON_REGISTRY_QUERY_KEY,
queryFn: loadDaemonRegistryFromStorage,
staleTime: Infinity,
gcTime: Infinity,
})
const persist = useCallback(
async (profiles: HostProfile[]) => {
queryClient.setQueryData<HostProfile[]>(DAEMON_REGISTRY_QUERY_KEY, profiles)
await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(profiles))
},
[queryClient]
)
const readDaemons = useCallback(() => {
return queryClient.getQueryData<HostProfile[]>(DAEMON_REGISTRY_QUERY_KEY) ?? daemons
}, [queryClient, daemons])
const updateHost = useCallback(
async (serverId: string, updates: UpdateHostInput) => {
const next = readDaemons().map((daemon) =>
daemon.serverId === serverId
? {
...daemon,
...updates,
updatedAt: new Date().toISOString(),
}
: daemon
)
await persist(next)
},
[persist, readDaemons]
)
const removeHost = useCallback(
async (serverId: string) => {
const existing = readDaemons()
const remaining = existing.filter((daemon) => daemon.serverId !== serverId)
await persist(remaining)
},
[persist, readDaemons]
)
const removeConnection = useCallback(
async (serverId: string, connectionId: string) => {
const existing = readDaemons()
const now = new Date().toISOString()
const next = existing
.map((daemon) => {
if (daemon.serverId !== serverId) return daemon
const remaining = daemon.connections.filter((conn) => conn.id !== connectionId)
if (remaining.length === 0) {
return null
}
const preferred =
daemon.preferredConnectionId === connectionId
? (remaining[0]?.id ?? null)
: daemon.preferredConnectionId
return {
...daemon,
connections: remaining,
preferredConnectionId: preferred,
updatedAt: now,
} satisfies HostProfile
})
.filter((entry): entry is HostProfile => entry !== null)
await persist(next)
},
[persist, readDaemons]
)
const upsertHostConnection = useCallback(
async (input: {
serverId: string
label?: string
lifecycle?: Partial<HostLifecycle>
connection: HostConnection
}) => {
const now = new Date().toISOString()
const next = upsertHostConnectionInProfiles({
profiles: readDaemons(),
serverId: input.serverId,
label: input.label,
lifecycle: input.lifecycle,
connection: input.connection,
now,
})
await persist(next)
return next.find((daemon) => daemon.serverId === input.serverId) as HostProfile
},
[persist, readDaemons]
)
const upsertDirectConnection = useCallback(
async (input: { serverId: string; endpoint: string; label?: string }) => {
const endpoint = normalizeHostPort(input.endpoint)
return upsertHostConnection({
serverId: input.serverId,
label: input.label,
connection: {
id: `direct:${endpoint}`,
type: 'directTcp',
endpoint,
},
})
},
[upsertHostConnection]
)
useEffect(() => {
if (isPending) return
if (settingsLoading) return
if (!shouldUseManagedDesktopDaemon()) return
if (desktopStartupReconciledRef.current) return
desktopStartupReconciledRef.current = true
let cancelled = false
const reconcileDesktopStartup = async () => {
try {
const isE2E = await AsyncStorage.getItem(E2E_STORAGE_KEY)
if (cancelled || isE2E) {
return
}
let managed: ManagedHostReconciliationInput | null = null
try {
const managedDaemon = await resolveManagedDesktopStartupStatus({
settings,
})
if (managedDaemon.daemonRunning) {
managed = await probeManagedConnectionUntilReady({
managedDaemon,
cancelled: () => cancelled,
})
}
} catch (managedBootstrapError) {
if (!cancelled) {
console.warn(
'[DaemonRegistry] Failed to reconcile managed daemon transport',
managedBootstrapError
)
}
}
let localhost: LocalhostHostReconciliationInput | null = null
try {
const { serverId, hostname } = await probeConnection(
{
id: `bootstrap:${DEFAULT_LOCALHOST_ENDPOINT}`,
type: 'directTcp',
endpoint: DEFAULT_LOCALHOST_ENDPOINT,
},
{ timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS }
)
if (!cancelled) {
localhost = {
serverId,
hostname,
endpoint: DEFAULT_LOCALHOST_ENDPOINT,
}
}
} catch {
// Best-effort reconciliation only; keep startup resilient if localhost isn't reachable.
}
if (cancelled) {
return
}
const existing = readDaemons()
const next = reconcileDesktopStartupRegistry({
existing,
managed,
localhost,
})
if (next !== existing) {
await persist(next)
}
} catch (reconciliationError) {
if (cancelled) return
console.warn(
'[DaemonRegistry] Failed to reconcile desktop startup host connections',
reconciliationError
)
}
}
void reconcileDesktopStartup().finally(() => {
if (!cancelled) {
setIsReconciling(false)
}
})
return () => {
cancelled = true
}
}, [
isPending,
persist,
readDaemons,
settings.manageBuiltInDaemon,
settingsLoading,
])
useEffect(() => {
if (isPending) return
if (shouldUseManagedDesktopDaemon()) return
if (localhostBootstrapAttemptedRef.current) return
localhostBootstrapAttemptedRef.current = true
let cancelled = false
const bootstrapLocalhost = async () => {
try {
const isE2E = await AsyncStorage.getItem(E2E_STORAGE_KEY)
if (cancelled || isE2E) {
return
}
const alreadyHandled = await AsyncStorage.getItem(DEFAULT_LOCALHOST_BOOTSTRAP_KEY)
if (cancelled || alreadyHandled) {
return
}
const existing = readDaemons()
if (registryHasDirectEndpoint(existing, DEFAULT_LOCALHOST_ENDPOINT)) {
await AsyncStorage.setItem(DEFAULT_LOCALHOST_BOOTSTRAP_KEY, '1')
return
}
try {
const { serverId, hostname } = await probeConnection(
{
id: `bootstrap:${DEFAULT_LOCALHOST_ENDPOINT}`,
type: 'directTcp',
endpoint: DEFAULT_LOCALHOST_ENDPOINT,
},
{ timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS }
)
if (cancelled) return
await upsertDirectConnection({
serverId,
endpoint: DEFAULT_LOCALHOST_ENDPOINT,
label: hostname ?? undefined,
})
await AsyncStorage.setItem(DEFAULT_LOCALHOST_BOOTSTRAP_KEY, '1')
} catch {
// Best-effort bootstrap only; keep startup resilient if localhost isn't reachable.
}
} catch (bootstrapError) {
if (cancelled) return
console.warn('[DaemonRegistry] Failed to bootstrap host connections', bootstrapError)
}
}
void bootstrapLocalhost().finally(() => {
if (!cancelled) {
setIsReconciling(false)
}
})
return () => {
cancelled = true
}
}, [
isPending,
readDaemons,
upsertDirectConnection,
])
const upsertRelayConnection = useCallback(
async (input: {
serverId: string
relayEndpoint: string
daemonPublicKeyB64: string
label?: string
}) => {
const relayEndpoint = normalizeHostPort(input.relayEndpoint)
const daemonPublicKeyB64 = input.daemonPublicKeyB64.trim()
if (!daemonPublicKeyB64) {
throw new Error('daemonPublicKeyB64 is required')
}
return upsertHostConnection({
serverId: input.serverId,
label: input.label,
connection: {
id: `relay:${relayEndpoint}`,
type: 'relay',
relayEndpoint,
daemonPublicKeyB64,
},
})
},
[upsertHostConnection]
)
const upsertDaemonFromOffer = useCallback(
async (offer: ConnectionOffer) => {
return upsertRelayConnection({
serverId: offer.serverId,
relayEndpoint: offer.relay.endpoint,
daemonPublicKeyB64: offer.daemonPublicKeyB64,
})
},
[upsertRelayConnection]
)
const upsertDaemonFromOfferUrl = useCallback(
async (offerUrlOrFragment: string) => {
const marker = '#offer='
const idx = offerUrlOrFragment.indexOf(marker)
if (idx === -1) {
throw new Error('Missing #offer= fragment')
}
const encoded = offerUrlOrFragment.slice(idx + marker.length).trim()
if (!encoded) {
throw new Error('Offer payload is empty')
}
const payload = decodeOfferFragmentPayload(encoded)
const offer = ConnectionOfferSchema.parse(payload)
return upsertDaemonFromOffer(offer)
},
[upsertDaemonFromOffer]
)
const value: DaemonRegistryContextValue = {
daemons,
isLoading: isPending,
isReconciling,
error: error ?? null,
upsertDirectConnection,
upsertRelayConnection,
updateHost,
removeHost,
removeConnection,
upsertDaemonFromOffer,
upsertDaemonFromOfferUrl,
}
return <DaemonRegistryContext.Provider value={value}>{children}</DaemonRegistryContext.Provider>
}
async function loadDaemonRegistryFromStorage(): Promise<HostProfile[]> {
try {
const stored = await AsyncStorage.getItem(REGISTRY_STORAGE_KEY)
if (!stored) {
return []
}
const parsed = JSON.parse(stored) as unknown
if (!Array.isArray(parsed)) {
return []
}
return parsed
.map((entry) => normalizeStoredHostProfile(entry))
.filter((entry): entry is HostProfile => entry !== null)
} catch (error) {
console.error('[DaemonRegistry] Failed to load daemon registry', error)
throw error
}
}

View File

@@ -1,335 +1,323 @@
import { useCallback, useEffect, useState } from "react";
import { ActivityIndicator, Alert, Image, Text, View } from "react-native";
import * as Clipboard from "expo-clipboard";
import * as QRCode from "qrcode";
import { useFocusEffect } from "@react-navigation/native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { settingsStyles } from "@/styles/settings";
import { ArrowUpRight, Play, Pause, RotateCw, Terminal, Copy, FileText, Smartphone } from "lucide-react-native";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import { useAppSettings } from "@/hooks/use-settings";
import { confirmDialog } from "@/utils/confirm-dialog";
import { openExternalUrl } from "@/utils/open-external-url";
import { useCallback, useEffect, useState } from 'react'
import { ActivityIndicator, Alert, Image, Text, View } from 'react-native'
import * as Clipboard from 'expo-clipboard'
import * as QRCode from 'qrcode'
import { useFocusEffect } from '@react-navigation/native'
import { StyleSheet, useUnistyles } from 'react-native-unistyles'
import { settingsStyles } from '@/styles/settings'
import {
formatVersionWithPrefix,
isVersionMismatch,
} from "@/desktop/updates/desktop-updates";
ArrowUpRight,
Play,
Pause,
RotateCw,
Terminal,
Copy,
FileText,
Smartphone,
} from 'lucide-react-native'
import { AdaptiveModalSheet } from '@/components/adaptive-modal-sheet'
import { Button } from '@/components/ui/button'
import { useAppSettings } from '@/hooks/use-settings'
import { confirmDialog } from '@/utils/confirm-dialog'
import { openExternalUrl } from '@/utils/open-external-url'
import { formatVersionWithPrefix, isVersionMismatch } from '@/desktop/updates/desktop-updates'
import {
getCliSymlinkInstructions,
getManagedDaemonLogs,
getManagedDaemonPairing,
getManagedDaemonStatus,
installManagedCliShim,
restartManagedDaemon,
shouldUseManagedDesktopDaemon,
startManagedDaemon,
stopManagedDaemon,
uninstallManagedCliShim,
type CliSymlinkInstructions,
type ManagedDaemonLogs,
type ManagedPairingOffer,
type ManagedDaemonStatus,
type CliManualInstructions,
} from "@/desktop/managed-runtime/managed-runtime";
} from '@/desktop/managed-runtime/managed-runtime'
export interface LocalDaemonSectionProps {
appVersion: string | null;
appVersion: string | null
}
export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
const { theme } = useUnistyles();
const showSection = shouldUseManagedDesktopDaemon();
const { settings, updateSettings } = useAppSettings();
const [managedStatus, setManagedStatus] = useState<ManagedDaemonStatus | null>(null);
const [statusError, setStatusError] = useState<string | null>(null);
const [isRestartingDaemon, setIsRestartingDaemon] = useState(false);
const [isUpdatingDaemonManagement, setIsUpdatingDaemonManagement] = useState(false);
const [isInstallingCli, setIsInstallingCli] = useState(false);
const [statusMessage, setStatusMessage] = useState<string | null>(null);
const [cliStatusMessage, setCliStatusMessage] = useState<string | null>(null);
const [managedLogs, setManagedLogs] = useState<ManagedDaemonLogs | null>(null);
const [isLogsModalOpen, setIsLogsModalOpen] = useState(false);
const [isPairingModalOpen, setIsPairingModalOpen] = useState(false);
const [isCliInstallModalOpen, setIsCliInstallModalOpen] = useState(false);
const [isLoadingPairing, setIsLoadingPairing] = useState(false);
const [pairingOffer, setPairingOffer] = useState<ManagedPairingOffer | null>(null);
const [cliInstallInstructions, setCliInstallInstructions] = useState<CliManualInstructions | null>(
null
);
const [pairingStatusMessage, setPairingStatusMessage] = useState<string | null>(null);
const { theme } = useUnistyles()
const showSection = shouldUseManagedDesktopDaemon()
const { settings, updateSettings } = useAppSettings()
const [managedStatus, setManagedStatus] = useState<ManagedDaemonStatus | null>(null)
const [statusError, setStatusError] = useState<string | null>(null)
const [isRestartingDaemon, setIsRestartingDaemon] = useState(false)
const [isUpdatingDaemonManagement, setIsUpdatingDaemonManagement] = useState(false)
const [isLoadingCliSymlinkInstructions, setIsLoadingCliSymlinkInstructions] = useState(false)
const [statusMessage, setStatusMessage] = useState<string | null>(null)
const [cliStatusMessage, setCliStatusMessage] = useState<string | null>(null)
const [managedLogs, setManagedLogs] = useState<ManagedDaemonLogs | null>(null)
const [isLogsModalOpen, setIsLogsModalOpen] = useState(false)
const [isPairingModalOpen, setIsPairingModalOpen] = useState(false)
const [isCliSymlinkModalOpen, setIsCliSymlinkModalOpen] = useState(false)
const [isLoadingPairing, setIsLoadingPairing] = useState(false)
const [pairingOffer, setPairingOffer] = useState<ManagedPairingOffer | null>(null)
const [cliSymlinkInstructions, setCliSymlinkInstructions] =
useState<CliSymlinkInstructions | null>(null)
const [pairingStatusMessage, setPairingStatusMessage] = useState<string | null>(null)
const loadManagedStatus = useCallback(() => {
if (!showSection) {
return Promise.resolve();
return Promise.resolve()
}
return Promise.all([getManagedDaemonStatus(), getManagedDaemonLogs()])
.then(([status, logs]) => {
setManagedStatus(status);
setManagedLogs(logs);
setStatusError(null);
setManagedStatus(status)
setManagedLogs(logs)
setStatusError(null)
})
.catch((error) => {
const message = error instanceof Error ? error.message : String(error);
setStatusError(message);
});
}, [showSection]);
const message = error instanceof Error ? error.message : String(error)
setStatusError(message)
})
}, [showSection])
useFocusEffect(
useCallback(() => {
if (!showSection) {
return undefined;
return undefined
}
void loadManagedStatus();
return undefined;
void loadManagedStatus()
return undefined
}, [loadManagedStatus, showSection])
);
)
const localDaemonVersionText = formatVersionWithPrefix(managedStatus?.runtimeVersion ?? null);
const daemonVersionMismatch = isVersionMismatch(appVersion, managedStatus?.runtimeVersion ?? null);
const localDaemonVersionText = formatVersionWithPrefix(managedStatus?.runtimeVersion ?? null)
const daemonVersionMismatch = isVersionMismatch(appVersion, managedStatus?.runtimeVersion ?? null)
const daemonStatusStateText =
statusError ??
(managedStatus?.daemonRunning
? managedStatus?.daemonStatus ?? "running"
: "not running");
const daemonStatusDetailText = `PID ${managedStatus?.daemonPid ? managedStatus.daemonPid : "—"}`;
const isDaemonManagementPaused = !settings.manageBuiltInDaemon;
const daemonActionLabel = managedStatus?.daemonRunning ? "Restart daemon" : "Start daemon";
const daemonActionMessage = managedStatus?.daemonRunning
? "Restarts the built-in daemon."
: isDaemonManagementPaused
? "Starts the built-in daemon manually. Paseo will not auto-start it while paused."
: "Starts the built-in daemon.";
statusError ?? (managedStatus?.status === 'running' ? managedStatus.status : 'not running')
const daemonStatusDetailText = `PID ${managedStatus?.pid ? managedStatus.pid : '—'}`
const isDaemonManagementPaused = !settings.manageBuiltInDaemon
const daemonActionLabel = managedStatus?.status === 'running' ? 'Restart daemon' : 'Start daemon'
const daemonActionMessage =
managedStatus?.status === 'running'
? 'Restarts the built-in daemon.'
: 'Starts the built-in daemon.'
const handleUpdateLocalDaemon = useCallback(() => {
if (!showSection) {
return;
return
}
if (isRestartingDaemon) {
return;
return
}
void confirmDialog({
title: daemonActionLabel,
message: managedStatus?.daemonRunning
? "This will restart the built-in daemon. The app will reconnect automatically."
: "This will start the built-in daemon.",
message:
managedStatus?.status === 'running'
? 'This will restart the built-in daemon. The app will reconnect automatically.'
: 'This will start the built-in daemon.',
confirmLabel: daemonActionLabel,
cancelLabel: "Cancel",
cancelLabel: 'Cancel',
})
.then((confirmed) => {
if (!confirmed) {
return;
return
}
setIsRestartingDaemon(true);
setStatusMessage(null);
setIsRestartingDaemon(true)
setStatusMessage(null)
const action = managedStatus?.daemonRunning ? restartManagedDaemon : startManagedDaemon;
const action =
managedStatus?.status === 'running' ? restartManagedDaemon : startManagedDaemon
void action()
.then((status) => {
setManagedStatus(status);
setManagedStatus(status)
setStatusMessage(
managedStatus?.daemonRunning ? "Daemon restarted." : "Daemon started."
);
return loadManagedStatus();
managedStatus?.status === 'running' ? 'Daemon restarted.' : 'Daemon started.'
)
return loadManagedStatus()
})
.catch((error) => {
console.error("[Settings] Failed to change managed daemon state", error);
const message = error instanceof Error ? error.message : String(error);
setStatusMessage(`${daemonActionLabel} failed: ${message}`);
console.error('[Settings] Failed to change managed daemon state', error)
const message = error instanceof Error ? error.message : String(error)
setStatusMessage(`${daemonActionLabel} failed: ${message}`)
})
.finally(() => {
setIsRestartingDaemon(false);
});
setIsRestartingDaemon(false)
})
})
.catch((error) => {
console.error("[Settings] Failed to open managed daemon action confirmation", error);
Alert.alert("Error", "Unable to open the daemon confirmation dialog.");
});
}, [daemonActionLabel, isRestartingDaemon, loadManagedStatus, managedStatus?.daemonRunning, showSection]);
console.error('[Settings] Failed to open managed daemon action confirmation', error)
Alert.alert('Error', 'Unable to open the daemon confirmation dialog.')
})
}, [daemonActionLabel, isRestartingDaemon, loadManagedStatus, managedStatus?.status, showSection])
const handleToggleDaemonManagement = useCallback(() => {
if (isUpdatingDaemonManagement) {
return;
return
}
if (!settings.manageBuiltInDaemon) {
setIsUpdatingDaemonManagement(true);
setStatusMessage(null);
setIsUpdatingDaemonManagement(true)
setStatusMessage(null)
void updateSettings({ manageBuiltInDaemon: true })
.then(() => {
setStatusMessage("Paseo will resume managing the built-in daemon on startup.");
setStatusMessage('Built-in daemon management resumed.')
})
.catch((error) => {
console.error("[Settings] Failed to update built-in daemon management", error);
Alert.alert("Error", "Unable to update built-in daemon management.");
console.error('[Settings] Failed to update built-in daemon management', error)
Alert.alert('Error', 'Unable to update built-in daemon management.')
})
.finally(() => {
setIsUpdatingDaemonManagement(false);
});
return;
setIsUpdatingDaemonManagement(false)
})
return
}
void confirmDialog({
title: "Pause built-in daemon",
title: 'Pause built-in daemon',
message:
"This will stop the built-in daemon immediately and prevent Paseo from auto-starting it on launch. Running agents and terminals connected to the built-in daemon will be stopped.",
confirmLabel: "Pause and stop",
cancelLabel: "Cancel",
'This will stop the built-in daemon immediately. Running agents and terminals connected to the built-in daemon will be stopped.',
confirmLabel: 'Pause and stop',
cancelLabel: 'Cancel',
destructive: true,
})
.then((confirmed) => {
if (!confirmed) {
return;
return
}
setIsUpdatingDaemonManagement(true);
setStatusMessage(null);
setIsUpdatingDaemonManagement(true)
setStatusMessage(null)
const stopPromise = managedStatus?.daemonRunning
? stopManagedDaemon()
: Promise.resolve(managedStatus ?? null);
const stopPromise =
managedStatus?.status === 'running'
? stopManagedDaemon()
: Promise.resolve(managedStatus ?? null)
void stopPromise
.then(() => updateSettings({ manageBuiltInDaemon: false }))
.then(() => loadManagedStatus())
.then(() => {
setStatusMessage(
"Paseo paused the built-in daemon and will no longer auto-start it on launch."
);
setStatusMessage('Built-in daemon paused and stopped.')
})
.catch((error) => {
console.error("[Settings] Failed to pause built-in daemon management", error);
Alert.alert("Error", "Unable to pause built-in daemon management.");
console.error('[Settings] Failed to pause built-in daemon management', error)
Alert.alert('Error', 'Unable to pause built-in daemon management.')
})
.finally(() => {
setIsUpdatingDaemonManagement(false);
});
setIsUpdatingDaemonManagement(false)
})
})
.catch((error) => {
console.error("[Settings] Failed to open built-in daemon pause confirmation", error);
Alert.alert("Error", "Unable to open the daemon confirmation dialog.");
});
console.error('[Settings] Failed to open built-in daemon pause confirmation', error)
Alert.alert('Error', 'Unable to open the daemon confirmation dialog.')
})
}, [
isUpdatingDaemonManagement,
loadManagedStatus,
managedStatus,
settings.manageBuiltInDaemon,
updateSettings,
]);
])
const handleToggleCliShim = useCallback(() => {
if (!showSection || isInstallingCli) {
return;
const handleOpenCliSymlinkInstructions = useCallback(() => {
if (!showSection || isLoadingCliSymlinkInstructions) {
return
}
setIsInstallingCli(true);
const isInstalling = !managedStatus?.cliShimPath;
setCliStatusMessage(
isInstalling
? "A permissions popup may appear while Paseo installs the CLI globally."
: null
);
const action = managedStatus?.cliShimPath ? uninstallManagedCliShim : installManagedCliShim;
void action()
.then((result) => {
setCliStatusMessage(result.message);
if (result.manualInstructions) {
setCliInstallInstructions(result.manualInstructions);
setIsCliInstallModalOpen(true);
} else {
setCliInstallInstructions(null);
setIsCliInstallModalOpen(false);
}
return loadManagedStatus();
setIsLoadingCliSymlinkInstructions(true)
setCliStatusMessage(null)
void getCliSymlinkInstructions()
.then((instructions) => {
setCliSymlinkInstructions(instructions)
setIsCliSymlinkModalOpen(true)
})
.catch((error) => {
const message = error instanceof Error ? error.message : String(error);
setCliStatusMessage(`CLI install failed: ${message}`);
const message = error instanceof Error ? error.message : String(error)
setCliStatusMessage(`Unable to load CLI symlink instructions: ${message}`)
})
.finally(() => {
setIsInstallingCli(false);
});
}, [isInstallingCli, loadManagedStatus, managedStatus?.cliShimPath, showSection]);
setIsLoadingCliSymlinkInstructions(false)
})
}, [isLoadingCliSymlinkInstructions, showSection])
const handleCopyCliInstallCommands = useCallback(() => {
if (!cliInstallInstructions?.commands) {
return;
const handleCopyCliSymlinkCommands = useCallback(() => {
if (!cliSymlinkInstructions?.commands) {
return
}
void Clipboard.setStringAsync(cliInstallInstructions.commands)
void Clipboard.setStringAsync(cliSymlinkInstructions.commands)
.then(() => {
Alert.alert("Copied", "CLI install commands copied.");
Alert.alert('Copied', 'CLI symlink commands copied.')
})
.catch((error) => {
console.error("[Settings] Failed to copy CLI install commands", error);
Alert.alert("Error", "Unable to copy CLI install commands.");
});
}, [cliInstallInstructions?.commands]);
console.error('[Settings] Failed to copy CLI symlink commands', error)
Alert.alert('Error', 'Unable to copy CLI symlink commands.')
})
}, [cliSymlinkInstructions?.commands])
const handleCopyLogPath = useCallback(() => {
const logPath = managedLogs?.logPath ?? managedStatus?.logPath;
const logPath = managedLogs?.logPath
if (!logPath) {
return;
return
}
void Clipboard.setStringAsync(logPath)
.then(() => {
Alert.alert("Copied", "Log path copied.");
Alert.alert('Copied', 'Log path copied.')
})
.catch((error) => {
console.error("[Settings] Failed to copy log path", error);
Alert.alert("Error", "Unable to copy log path.");
});
}, [managedLogs?.logPath, managedStatus?.logPath]);
console.error('[Settings] Failed to copy log path', error)
Alert.alert('Error', 'Unable to copy log path.')
})
}, [managedLogs?.logPath])
const handleOpenLogs = useCallback(() => {
if (!managedLogs) {
return;
return
}
setIsLogsModalOpen(true);
}, [managedLogs]);
setIsLogsModalOpen(true)
}, [managedLogs])
const handleOpenPairingModal = useCallback(() => {
if (isLoadingPairing) {
return;
return
}
setIsPairingModalOpen(true);
setIsLoadingPairing(true);
setPairingStatusMessage(null);
setIsPairingModalOpen(true)
setIsLoadingPairing(true)
setPairingStatusMessage(null)
void getManagedDaemonPairing()
.then((pairing) => {
setPairingOffer(pairing);
setPairingOffer(pairing)
if (!pairing.relayEnabled || !pairing.url) {
setPairingStatusMessage("Relay pairing is not available.");
setPairingStatusMessage('Relay pairing is not available.')
}
})
.catch((error) => {
const message = error instanceof Error ? error.message : String(error);
setPairingOffer(null);
setPairingStatusMessage(`Unable to load pairing offer: ${message}`);
const message = error instanceof Error ? error.message : String(error)
setPairingOffer(null)
setPairingStatusMessage(`Unable to load pairing offer: ${message}`)
})
.finally(() => {
setIsLoadingPairing(false);
});
}, [isLoadingPairing]);
setIsLoadingPairing(false)
})
}, [isLoadingPairing])
const handleCopyPairingLink = useCallback(() => {
if (!pairingOffer?.url) {
return;
return
}
void Clipboard.setStringAsync(pairingOffer.url)
.then(() => {
Alert.alert("Copied", "Pairing link copied.");
Alert.alert('Copied', 'Pairing link copied.')
})
.catch((error) => {
console.error("[Settings] Failed to copy pairing link", error);
Alert.alert("Error", "Unable to copy pairing link.");
});
}, [pairingOffer?.url]);
console.error('[Settings] Failed to copy pairing link', error)
Alert.alert('Error', 'Unable to copy pairing link.')
})
}, [pairingOffer?.url])
if (!showSection) {
return null;
return null
}
return (
@@ -364,35 +352,37 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
<Text style={styles.rowTitle}>Daemon management</Text>
<Text style={styles.hintText}>
{isDaemonManagementPaused
? "Paused. Paseo will not auto-start the built-in daemon on app launch."
: "Enabled. Paseo will start the built-in daemon automatically when needed."}
? 'Paused. The built-in daemon stays stopped until you start it again.'
: 'Enabled. Paseo can manage the built-in daemon from the desktop app.'}
</Text>
</View>
<Button
variant="outline"
size="sm"
leftIcon={isDaemonManagementPaused
? <Play size={theme.iconSize.sm} color={theme.colors.foreground} />
: <Pause size={theme.iconSize.sm} color={theme.colors.foreground} />}
leftIcon={
isDaemonManagementPaused ? (
<Play size={theme.iconSize.sm} color={theme.colors.foreground} />
) : (
<Pause size={theme.iconSize.sm} color={theme.colors.foreground} />
)
}
onPress={handleToggleDaemonManagement}
disabled={isUpdatingDaemonManagement}
>
{isUpdatingDaemonManagement
? isDaemonManagementPaused
? "Resuming..."
: "Pausing..."
? 'Resuming...'
: 'Pausing...'
: isDaemonManagementPaused
? "Resume"
: "Pause"}
? 'Resume'
: 'Pause'}
</Button>
</View>
<View style={[styles.row, styles.rowBorder]}>
<View style={styles.rowContent}>
<Text style={styles.rowTitle}>{daemonActionLabel}</Text>
<Text style={styles.hintText}>{daemonActionMessage}</Text>
{statusMessage ? (
<Text style={styles.statusText}>{statusMessage}</Text>
) : null}
{statusMessage ? <Text style={styles.statusText}>{statusMessage}</Text> : null}
</View>
<Button
variant="outline"
@@ -402,46 +392,41 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
disabled={isRestartingDaemon}
>
{isRestartingDaemon
? managedStatus?.daemonRunning
? "Restarting..."
: "Starting..."
? managedStatus?.status === 'running'
? 'Restarting...'
: 'Starting...'
: daemonActionLabel}
</Button>
</View>
<View style={[styles.row, styles.rowBorder]}>
<View style={styles.rowContent}>
<Text style={styles.rowTitle}>Command line (CLI)</Text>
<Text style={styles.hintText}>
Adds the `paseo` command to your terminal.
</Text>
<Text style={styles.hintText}>Shows the command to add `paseo` to your terminal.</Text>
{cliStatusMessage ? <Text style={styles.statusText}>{cliStatusMessage}</Text> : null}
</View>
<Button
variant="outline"
size="sm"
leftIcon={<Terminal size={theme.iconSize.sm} color={theme.colors.foreground} />}
onPress={handleToggleCliShim}
disabled={isInstallingCli}
onPress={handleOpenCliSymlinkInstructions}
disabled={isLoadingCliSymlinkInstructions}
>
{isInstallingCli
? "Working..."
: managedStatus?.cliShimPath
? "Uninstall CLI"
: "Install CLI"}
{isLoadingCliSymlinkInstructions ? 'Loading...' : 'Show instructions'}
</Button>
</View>
<View style={[styles.row, styles.rowBorder]}>
<View style={styles.rowContent}>
<Text style={styles.rowTitle}>Log file</Text>
<Text style={styles.hintText}>
{managedLogs?.logPath ??
managedStatus?.logPath ??
"Log path unavailable."}
</Text>
<Text style={styles.hintText}>{managedLogs?.logPath ?? 'Log path unavailable.'}</Text>
</View>
<View style={styles.actionGroup}>
{(managedLogs?.logPath ?? managedStatus?.logPath) ? (
<Button variant="outline" size="sm" leftIcon={<Copy size={theme.iconSize.sm} color={theme.colors.foreground} />} onPress={handleCopyLogPath}>
{managedLogs?.logPath ? (
<Button
variant="outline"
size="sm"
leftIcon={<Copy size={theme.iconSize.sm} color={theme.colors.foreground} />}
onPress={handleCopyLogPath}
>
Copy path
</Button>
) : null}
@@ -459,11 +444,14 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
<View style={[styles.row, styles.rowBorder]}>
<View style={styles.rowContent}>
<Text style={styles.rowTitle}>Pair device</Text>
<Text style={styles.hintText}>
Connect your phone to this computer.
</Text>
<Text style={styles.hintText}>Connect your phone to this computer.</Text>
</View>
<Button variant="outline" size="sm" leftIcon={<Smartphone size={theme.iconSize.sm} color={theme.colors.foreground} />} onPress={handleOpenPairingModal}>
<Button
variant="outline"
size="sm"
leftIcon={<Smartphone size={theme.iconSize.sm} color={theme.colors.foreground} />}
onPress={handleOpenPairingModal}
>
Pair device
</Button>
</View>
@@ -479,27 +467,26 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
) : null}
<AdaptiveModalSheet
visible={isCliInstallModalOpen}
onClose={() => setIsCliInstallModalOpen(false)}
title="Install CLI manually"
testID="managed-daemon-cli-install-dialog"
visible={isCliSymlinkModalOpen}
onClose={() => setIsCliSymlinkModalOpen(false)}
title="Add paseo to your shell"
testID="managed-daemon-cli-symlink-dialog"
>
<View style={styles.modalBody}>
<Text style={styles.hintText}>
A permissions popup should appear when Paseo installs the CLI globally. If it does not
complete, open a terminal and run the commands below.
Paseo does not add the command for you. Run the command below in your terminal.
</Text>
{cliInstallInstructions?.detail ? (
<Text style={styles.hintText}>{cliInstallInstructions.detail}</Text>
{cliSymlinkInstructions?.detail ? (
<Text style={styles.hintText}>{cliSymlinkInstructions.detail}</Text>
) : null}
<Text style={styles.codeBlock} selectable>
{cliInstallInstructions?.commands ?? ""}
{cliSymlinkInstructions?.commands ?? ''}
</Text>
<View style={styles.modalActions}>
<Button variant="outline" size="sm" onPress={() => setIsCliInstallModalOpen(false)}>
<Button variant="outline" size="sm" onPress={() => setIsCliSymlinkModalOpen(false)}>
Close
</Button>
<Button size="sm" onPress={handleCopyCliInstallCommands}>
<Button size="sm" onPress={handleCopyCliSymlinkCommands}>
Copy commands
</Button>
</View>
@@ -525,71 +512,67 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
onClose={() => setIsLogsModalOpen(false)}
title="Daemon logs"
testID="managed-daemon-logs-dialog"
snapPoints={["70%", "92%"]}
snapPoints={['70%', '92%']}
>
<View style={styles.modalBody}>
<Text style={styles.hintText}>
{managedLogs?.logPath ??
managedStatus?.logPath ??
"Log path unavailable."}
</Text>
<Text style={styles.hintText}>{managedLogs?.logPath ?? 'Log path unavailable.'}</Text>
<Text style={styles.logOutput} selectable>
{managedLogs?.contents.length ? managedLogs.contents : "(log file is empty)"}
{managedLogs?.contents.length ? managedLogs.contents : '(log file is empty)'}
</Text>
</View>
</AdaptiveModalSheet>
</View>
);
)
}
const ADVANCED_DAEMON_SETTINGS_URL = "https://paseo.sh/docs/configuration";
const ADVANCED_DAEMON_SETTINGS_URL = 'https://paseo.sh/docs/configuration'
function PairingOfferDialogContent(input: {
isLoading: boolean;
pairingOffer: ManagedPairingOffer | null;
statusMessage: string | null;
onCopyLink: () => void;
isLoading: boolean
pairingOffer: ManagedPairingOffer | null
statusMessage: string | null
onCopyLink: () => void
}) {
const { isLoading, pairingOffer, statusMessage, onCopyLink } = input;
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const [qrError, setQrError] = useState<string | null>(null);
const { isLoading, pairingOffer, statusMessage, onCopyLink } = input
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null)
const [qrError, setQrError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false;
let cancelled = false
if (!pairingOffer?.url) {
setQrDataUrl(null);
setQrError(null);
setQrDataUrl(null)
setQrError(null)
return () => {
cancelled = true;
};
cancelled = true
}
}
setQrError(null);
setQrDataUrl(null);
setQrError(null)
setQrDataUrl(null)
void QRCode.toDataURL(pairingOffer.url, {
errorCorrectionLevel: "M",
errorCorrectionLevel: 'M',
margin: 1,
width: 320,
})
.then((dataUrl) => {
if (cancelled) {
return;
return
}
setQrDataUrl(dataUrl);
setQrDataUrl(dataUrl)
})
.catch((error) => {
if (cancelled) {
return;
return
}
setQrError(error instanceof Error ? error.message : String(error));
});
setQrError(error instanceof Error ? error.message : String(error))
})
return () => {
cancelled = true;
};
}, [pairingOffer?.url]);
cancelled = true
}
}, [pairingOffer?.url])
if (isLoading) {
return (
@@ -597,7 +580,7 @@ function PairingOfferDialogContent(input: {
<ActivityIndicator size="small" />
<Text style={styles.hintText}>Loading pairing offer</Text>
</View>
);
)
}
if (statusMessage) {
@@ -605,7 +588,7 @@ function PairingOfferDialogContent(input: {
<View style={styles.modalBody}>
<Text style={styles.hintText}>{statusMessage}</Text>
</View>
);
)
}
if (!pairingOffer?.url) {
@@ -613,7 +596,7 @@ function PairingOfferDialogContent(input: {
<View style={styles.modalBody}>
<Text style={styles.hintText}>Pairing offer unavailable.</Text>
</View>
);
)
}
return (
@@ -640,20 +623,20 @@ function PairingOfferDialogContent(input: {
</Button>
</View>
</View>
);
)
}
const styles = StyleSheet.create((theme) => ({
sectionHeader: {
alignItems: "center",
flexDirection: "row",
justifyContent: "space-between",
alignItems: 'center',
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: theme.spacing[3],
marginLeft: theme.spacing[1],
},
sectionLink: {
alignItems: "center",
flexDirection: "row",
alignItems: 'center',
flexDirection: 'row',
gap: theme.spacing[1],
},
sectionLinkText: {
@@ -661,9 +644,9 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs,
},
row: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: theme.spacing[4],
paddingHorizontal: theme.spacing[4],
},
@@ -676,13 +659,13 @@ const styles = StyleSheet.create((theme) => ({
marginRight: theme.spacing[3],
},
actionGroup: {
flexDirection: "row",
flexDirection: 'row',
gap: theme.spacing[2],
flexWrap: "wrap",
justifyContent: "flex-end",
flexWrap: 'wrap',
justifyContent: 'flex-end',
},
statusValueGroup: {
alignItems: "flex-end",
alignItems: 'flex-end',
gap: 2,
},
rowTitle: {
@@ -713,7 +696,7 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.palette.amber[500],
backgroundColor: "rgba(245, 158, 11, 0.12)",
backgroundColor: 'rgba(245, 158, 11, 0.12)',
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
},
@@ -726,15 +709,15 @@ const styles = StyleSheet.create((theme) => ({
paddingBottom: theme.spacing[2],
},
pairingState: {
alignItems: "center",
justifyContent: "center",
alignItems: 'center',
justifyContent: 'center',
gap: theme.spacing[3],
paddingVertical: theme.spacing[6],
},
qrCard: {
alignItems: "center",
justifyContent: "center",
alignSelf: "center",
alignItems: 'center',
justifyContent: 'center',
alignSelf: 'center',
minHeight: 220,
minWidth: 220,
padding: theme.spacing[4],
@@ -759,13 +742,13 @@ const styles = StyleSheet.create((theme) => ({
logOutput: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
lineHeight: 18,
},
codeBlock: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
lineHeight: 18,
borderWidth: 1,
borderColor: theme.colors.border,
@@ -774,8 +757,8 @@ const styles = StyleSheet.create((theme) => ({
padding: theme.spacing[3],
},
modalActions: {
flexDirection: "row",
justifyContent: "flex-end",
flexDirection: 'row',
justifyContent: 'flex-end',
gap: theme.spacing[2],
},
}));
}))

View File

@@ -1,46 +1,24 @@
import { describe, expect, it } from "vitest";
import { parseCliShimResult } from "./managed-runtime";
import { parseCliSymlinkInstructions } from "./managed-runtime";
describe("parseCliShimResult", () => {
it("parses manual install payloads from the desktop backend", () => {
describe("parseCliSymlinkInstructions", () => {
it("parses CLI symlink instructions from the desktop backend", () => {
expect(
parseCliShimResult({
status: "manualInstallRequired",
installed: false,
path: "/usr/local/bin/paseo",
message: "Install it manually.",
manualInstructions: {
title: "Install from Terminal",
detail: "Run these commands.",
commands: "sudo tee /usr/local/bin/paseo",
},
parseCliSymlinkInstructions({
title: "Add paseo to your shell",
detail: "Create a symlink to the Paseo desktop executable.",
commands: "sudo ln -sf /Applications/Paseo.app/Contents/MacOS/Paseo /usr/local/bin/paseo",
})
).toEqual({
status: "manualInstallRequired",
installed: false,
path: "/usr/local/bin/paseo",
message: "Install it manually.",
manualInstructions: {
title: "Install from Terminal",
detail: "Run these commands.",
commands: "sudo tee /usr/local/bin/paseo",
},
title: "Add paseo to your shell",
detail: "Create a symlink to the Paseo desktop executable.",
commands: "sudo ln -sf /Applications/Paseo.app/Contents/MacOS/Paseo /usr/local/bin/paseo",
});
});
it("falls back to installed or removed when older payloads omit status", () => {
expect(
parseCliShimResult({
installed: true,
path: "/usr/local/bin/paseo",
message: "Installed.",
})
).toEqual({
status: "installed",
installed: true,
path: "/usr/local/bin/paseo",
message: "Installed.",
manualInstructions: null,
});
it("rejects non-object payloads", () => {
expect(() => parseCliSymlinkInstructions(null)).toThrow(
"Unexpected CLI symlink instructions response."
);
});
});

View File

@@ -1,282 +1,232 @@
import { invokeDesktopCommand } from "@/desktop/tauri/invoke-desktop-command";
import { getTauri, isTauriEnvironment } from "@/utils/tauri";
import { invokeDesktopCommand } from '@/desktop/tauri/invoke-desktop-command'
import { getTauri, isTauriEnvironment } from '@/utils/tauri'
export type ManagedRuntimeStatus = {
runtimeId: string;
runtimeVersion: string;
runtimeRoot: string;
managedHome: string;
transportType: string;
transportPath: string;
diagnosticsRoot: string;
stateFilePath: string;
};
runtimeId: string
runtimeVersion: string
runtimeRoot: string
}
export type ManagedDaemonStatus = {
runtimeId: string;
runtimeVersion: string;
runtimeRoot: string;
managedHome: string;
transportType: string;
transportPath: string;
daemonPid: number | null;
daemonRunning: boolean;
daemonStatus: string;
logPath: string;
serverId: string | null;
hostname: string | null;
relayEnabled: boolean;
tcpEnabled: boolean;
tcpListen: string | null;
cliShimPath: string | null;
};
runtimeId: string
runtimeVersion: string
serverId: string
status: string
listen: string
hostname: string | null
pid: number | null
home: string
}
export type ManagedDaemonLogs = {
logPath: string;
contents: string;
};
logPath: string
contents: string
}
export type ManagedPairingOffer = {
relayEnabled: boolean;
url: string | null;
qr: string | null;
};
relayEnabled: boolean
url: string | null
qr: string | null
}
export type CliShimResult = {
status:
| "installed"
| "removed"
| "elevationDenied"
| "automaticInstallUnavailable"
| "manualInstallRequired";
installed: boolean;
path: string | null;
message: string;
manualInstructions: CliManualInstructions | null;
};
export type CliManualInstructions = {
title: string;
detail: string;
commands: string;
};
export type CliSymlinkInstructions = {
title: string
detail: string
commands: string
}
export type ManagedTcpSettings = {
enabled: boolean;
host: string;
port: number;
};
enabled: boolean
host: string
port: number
}
export type LocalTransportTarget = {
transportType: "socket" | "pipe";
transportPath: string;
};
transportType: 'socket' | 'pipe'
transportPath: string
}
type LocalTransportEventPayload = {
sessionId: string;
kind: "open" | "message" | "close" | "error";
text?: string | null;
binaryBase64?: string | null;
code?: number | null;
reason?: string | null;
error?: string | null;
};
sessionId: string
kind: 'open' | 'message' | 'close' | 'error'
text?: string | null
binaryBase64?: string | null
code?: number | null
reason?: string | null
error?: string | null
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
return typeof value === 'object' && value !== null
}
function toStringOrNull(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value : null;
return typeof value === 'string' && value.trim().length > 0 ? value : null
}
function toNumberOrNull(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
return typeof value === 'number' && Number.isFinite(value) ? value : null
}
function parseManagedRuntimeStatus(raw: unknown): ManagedRuntimeStatus {
if (!isRecord(raw)) {
throw new Error("Unexpected managed runtime status response.");
throw new Error('Unexpected managed runtime status response.')
}
return {
runtimeId: toStringOrNull(raw.runtimeId) ?? "",
runtimeVersion: toStringOrNull(raw.runtimeVersion) ?? "",
runtimeRoot: toStringOrNull(raw.runtimeRoot) ?? "",
managedHome: toStringOrNull(raw.managedHome) ?? "",
transportType: toStringOrNull(raw.transportType) ?? "socket",
transportPath: toStringOrNull(raw.transportPath) ?? "",
diagnosticsRoot: toStringOrNull(raw.diagnosticsRoot) ?? "",
stateFilePath: toStringOrNull(raw.stateFilePath) ?? "",
};
runtimeId: toStringOrNull(raw.runtimeId) ?? '',
runtimeVersion: toStringOrNull(raw.runtimeVersion) ?? '',
runtimeRoot: toStringOrNull(raw.runtimeRoot) ?? '',
}
}
function parseManagedDaemonStatus(raw: unknown): ManagedDaemonStatus {
if (!isRecord(raw)) {
throw new Error("Unexpected managed daemon status response.");
throw new Error('Unexpected managed daemon status response.')
}
return {
runtimeId: toStringOrNull(raw.runtimeId) ?? "",
runtimeVersion: toStringOrNull(raw.runtimeVersion) ?? "",
runtimeRoot: toStringOrNull(raw.runtimeRoot) ?? "",
managedHome: toStringOrNull(raw.managedHome) ?? "",
transportType: toStringOrNull(raw.transportType) ?? "socket",
transportPath: toStringOrNull(raw.transportPath) ?? "",
daemonPid: toNumberOrNull(raw.daemonPid),
daemonRunning: raw.daemonRunning === true,
daemonStatus: toStringOrNull(raw.daemonStatus) ?? "unknown",
logPath: toStringOrNull(raw.logPath) ?? "",
serverId: toStringOrNull(raw.serverId),
runtimeId: toStringOrNull(raw.runtimeId) ?? '',
runtimeVersion: toStringOrNull(raw.runtimeVersion) ?? '',
serverId: toStringOrNull(raw.serverId) ?? '',
status: toStringOrNull(raw.status) ?? 'unknown',
listen: toStringOrNull(raw.listen) ?? '',
hostname: toStringOrNull(raw.hostname),
relayEnabled: raw.relayEnabled === true,
tcpEnabled: raw.tcpEnabled === true,
tcpListen: toStringOrNull(raw.tcpListen),
cliShimPath: toStringOrNull(raw.cliShimPath),
};
pid: toNumberOrNull(raw.pid),
home: toStringOrNull(raw.home) ?? '',
}
}
function parseManagedDaemonLogs(raw: unknown): ManagedDaemonLogs {
if (!isRecord(raw)) {
throw new Error("Unexpected managed daemon logs response.");
throw new Error('Unexpected managed daemon logs response.')
}
return {
logPath: toStringOrNull(raw.logPath) ?? "",
contents: typeof raw.contents === "string" ? raw.contents : "",
};
logPath: toStringOrNull(raw.logPath) ?? '',
contents: typeof raw.contents === 'string' ? raw.contents : '',
}
}
function parseManagedPairingOffer(raw: unknown): ManagedPairingOffer {
if (!isRecord(raw)) {
throw new Error("Unexpected managed daemon pairing response.");
throw new Error('Unexpected managed daemon pairing response.')
}
return {
relayEnabled: raw.relayEnabled === true,
url: toStringOrNull(raw.url),
qr: toStringOrNull(raw.qr),
};
}
}
function parseCliManualInstructions(raw: unknown): CliManualInstructions | null {
function parseCliSymlinkInstructionsInternal(raw: unknown): CliSymlinkInstructions | null {
if (!isRecord(raw)) {
return null;
return null
}
return {
title: toStringOrNull(raw.title) ?? "",
detail: toStringOrNull(raw.detail) ?? "",
commands: toStringOrNull(raw.commands) ?? "",
};
}
export function parseCliShimResult(raw: unknown): CliShimResult {
if (!isRecord(raw)) {
throw new Error("Unexpected CLI shim response.");
title: toStringOrNull(raw.title) ?? '',
detail: toStringOrNull(raw.detail) ?? '',
commands: toStringOrNull(raw.commands) ?? '',
}
return {
status:
(toStringOrNull(raw.status) as CliShimResult["status"] | null) ??
(raw.installed === true ? "installed" : "removed"),
installed: raw.installed === true,
path: toStringOrNull(raw.path),
message: toStringOrNull(raw.message) ?? "",
manualInstructions: parseCliManualInstructions(raw.manualInstructions),
};
}
export function shouldUseManagedDesktopDaemon(): boolean {
return isTauriEnvironment() && getTauri() !== null;
return isTauriEnvironment() && getTauri() !== null
}
export async function getManagedRuntimeStatus(): Promise<ManagedRuntimeStatus> {
return parseManagedRuntimeStatus(await invokeDesktopCommand("managed_runtime_status"));
return parseManagedRuntimeStatus(await invokeDesktopCommand('managed_runtime_status'))
}
export async function getManagedDaemonStatus(): Promise<ManagedDaemonStatus> {
return parseManagedDaemonStatus(await invokeDesktopCommand("managed_daemon_status"));
return parseManagedDaemonStatus(await invokeDesktopCommand('managed_daemon_status'))
}
export async function startManagedDaemon(): Promise<ManagedDaemonStatus> {
return parseManagedDaemonStatus(await invokeDesktopCommand("start_managed_daemon"));
return parseManagedDaemonStatus(await invokeDesktopCommand('start_managed_daemon'))
}
export async function stopManagedDaemon(): Promise<ManagedDaemonStatus> {
return parseManagedDaemonStatus(await invokeDesktopCommand("stop_managed_daemon"));
return parseManagedDaemonStatus(await invokeDesktopCommand('stop_managed_daemon'))
}
export async function restartManagedDaemon(): Promise<ManagedDaemonStatus> {
return parseManagedDaemonStatus(await invokeDesktopCommand("restart_managed_daemon"));
return parseManagedDaemonStatus(await invokeDesktopCommand('restart_managed_daemon'))
}
export async function getManagedDaemonLogs(): Promise<ManagedDaemonLogs> {
return parseManagedDaemonLogs(await invokeDesktopCommand("managed_daemon_logs"));
return parseManagedDaemonLogs(await invokeDesktopCommand('managed_daemon_logs'))
}
export async function getManagedDaemonPairing(): Promise<ManagedPairingOffer> {
return parseManagedPairingOffer(await invokeDesktopCommand("managed_daemon_pairing"));
return parseManagedPairingOffer(await invokeDesktopCommand('managed_daemon_pairing'))
}
export async function installManagedCliShim(): Promise<CliShimResult> {
return parseCliShimResult(await invokeDesktopCommand("install_cli_shim"));
export function parseCliSymlinkInstructions(raw: unknown): CliSymlinkInstructions {
const instructions = parseCliSymlinkInstructionsInternal(raw)
if (!instructions) {
throw new Error('Unexpected CLI symlink instructions response.')
}
return instructions
}
export async function uninstallManagedCliShim(): Promise<CliShimResult> {
return parseCliShimResult(await invokeDesktopCommand("uninstall_cli_shim"));
export async function getCliSymlinkInstructions(): Promise<CliSymlinkInstructions> {
return parseCliSymlinkInstructions(await invokeDesktopCommand('cli_symlink_instructions'))
}
export async function updateManagedDaemonTcpSettings(
settings: ManagedTcpSettings
): Promise<ManagedDaemonStatus> {
return parseManagedDaemonStatus(
await invokeDesktopCommand("update_managed_daemon_tcp_settings", { settings })
);
await invokeDesktopCommand('update_managed_daemon_tcp_settings', { settings })
)
}
export type LocalTransportEventUnlisten = () => void;
export type LocalTransportEventHandler = (payload: LocalTransportEventPayload) => void;
export type LocalTransportEventUnlisten = () => void
export type LocalTransportEventHandler = (payload: LocalTransportEventPayload) => void
export async function listenToLocalTransportEvents(
handler: LocalTransportEventHandler
): Promise<LocalTransportEventUnlisten> {
const listen = getTauri()?.event?.listen;
if (typeof listen !== "function") {
throw new Error("Tauri event API is unavailable.");
const listen = getTauri()?.event?.listen
if (typeof listen !== 'function') {
throw new Error('Tauri event API is unavailable.')
}
const unlisten = await listen("local-daemon-transport-event", (event: unknown) => {
const payload = isRecord(event) && isRecord(event.payload) ? event.payload : null;
const unlisten = await listen('local-daemon-transport-event', (event: unknown) => {
const payload = isRecord(event) && isRecord(event.payload) ? event.payload : null
if (!payload) {
return;
return
}
handler({
sessionId: toStringOrNull(payload.sessionId) ?? "",
kind: (toStringOrNull(payload.kind) ?? "error") as LocalTransportEventPayload["kind"],
sessionId: toStringOrNull(payload.sessionId) ?? '',
kind: (toStringOrNull(payload.kind) ?? 'error') as LocalTransportEventPayload['kind'],
text: toStringOrNull(payload.text),
binaryBase64: toStringOrNull(payload.binaryBase64),
code: toNumberOrNull(payload.code),
reason: toStringOrNull(payload.reason),
error: toStringOrNull(payload.error),
});
});
return typeof unlisten === "function" ? unlisten : () => {};
})
})
return typeof unlisten === 'function' ? unlisten : () => {}
}
export async function openLocalTransportSession(target: LocalTransportTarget): Promise<string> {
const raw = await invokeDesktopCommand<unknown>("open_local_daemon_transport", target);
if (typeof raw !== "string" || raw.trim().length === 0) {
throw new Error("Unexpected local transport session response.");
const raw = await invokeDesktopCommand<unknown>('open_local_daemon_transport', target)
if (typeof raw !== 'string' || raw.trim().length === 0) {
throw new Error('Unexpected local transport session response.')
}
return raw;
return raw
}
export async function sendLocalTransportMessage(input: {
sessionId: string;
text?: string;
binaryBase64?: string;
sessionId: string
text?: string
binaryBase64?: string
}): Promise<void> {
await invokeDesktopCommand("send_local_daemon_transport_message", {
await invokeDesktopCommand('send_local_daemon_transport_message', {
sessionId: input.sessionId,
...(input.text ? { text: input.text } : {}),
...(input.binaryBase64 ? { binaryBase64: input.binaryBase64 } : {}),
});
})
}
export async function closeLocalTransportSession(sessionId: string): Promise<void> {
await invokeDesktopCommand("close_local_daemon_transport", { sessionId });
await invokeDesktopCommand('close_local_daemon_transport', { sessionId })
}

View File

@@ -9,7 +9,7 @@ import type {
AgentModelDefinition,
AgentProvider,
} from "@server/server/agent/agent-sdk-types";
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
import { useHosts } from "@/runtime/host-runtime";
import { useHostRuntimeSession } from "@/runtime/host-runtime";
import { useFormPreferences, type FormPreferences } from "./use-form-preferences";
@@ -327,7 +327,7 @@ export function useAgentFormState(
updateProviderPreferences,
} = useFormPreferences();
const { daemons } = useDaemonRegistry();
const daemons = useHosts();
// Build a set of valid server IDs for preference validation
const validServerIds = useMemo(

View File

@@ -1,10 +1,9 @@
import { useMemo, useCallback, useSyncExternalStore } from "react";
import { useShallow } from "zustand/shallow";
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
import { useSessionStore } from "@/stores/session-store";
import type { AgentDirectoryEntry } from "@/types/agent-directory";
import type { Agent } from "@/stores/session-store";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
export interface AggregatedAgent extends AgentDirectoryEntry {
serverId: string;
@@ -22,7 +21,7 @@ export interface AggregatedAgentsResult {
export function useAggregatedAgents(options?: {
includeArchived?: boolean;
}): AggregatedAgentsResult {
const { daemons } = useDaemonRegistry();
const daemons = useHosts();
const runtime = getHostRuntimeStore();
const includeArchived = options?.includeArchived ?? false;
const runtimeVersion = useSyncExternalStore(

View File

@@ -1,5 +1,5 @@
import { useCallback, useMemo } from "react";
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
import { useHosts } from "@/runtime/host-runtime";
import { useSessionStore, type Agent } from "@/stores/session-store";
import {
getHostRuntimeStore,
@@ -74,7 +74,7 @@ export function useAllAgentsList(options?: {
serverId?: string | null;
includeArchived?: boolean;
}): AggregatedAgentsResult {
const { daemons } = useDaemonRegistry();
const daemons = useHosts();
const runtime = getHostRuntimeStore();
const serverId = useMemo(() => {

View File

@@ -3,7 +3,7 @@ import type { TextInput } from "react-native";
import { router, usePathname, type Href } from "expo-router";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
import { useHosts } from "@/runtime/host-runtime";
import { useAllAgentsList } from "@/hooks/use-all-agents-list";
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
import {
@@ -104,7 +104,7 @@ export type CommandCenterItem =
export function useCommandCenter() {
const pathname = usePathname();
const { daemons } = useDaemonRegistry();
const daemons = useHosts();
const open = useKeyboardShortcutsStore((s) => s.commandCenterOpen);
const setOpen = useKeyboardShortcutsStore((s) => s.setCommandCenterOpen);
const inputRef = useRef<TextInput>(null);

View File

@@ -0,0 +1,28 @@
export function polyfillScreenOrientation() {
if (
typeof window === "undefined" ||
typeof screen === "undefined" ||
screen.orientation
) {
return;
}
Object.defineProperty(screen, "orientation", {
value: {
get type() {
return window.innerWidth > window.innerHeight
? "landscape-primary"
: "portrait-primary";
},
get angle() {
return 0;
},
addEventListener() {},
removeEventListener() {},
dispatchEvent() {
return true;
},
},
configurable: true,
});
}

View File

@@ -5,7 +5,7 @@ import type {
FetchAgentsEntry,
FetchAgentsOptions,
} from "@server/client/daemon-client";
import type { HostConnection, HostProfile } from "@/contexts/daemon-registry-context";
import type { HostConnection, HostProfile } from "@/types/host-connection";
import { useSessionStore } from "@/stores/session-store";
import {
HostRuntimeController,
@@ -72,6 +72,12 @@ class FakeDaemonClient {
});
}
async ping(): Promise<{ rttMs: number }> {
return { rttMs: 0 };
}
setReconnectEnabled(_enabled: boolean): void {}
setConnectionState(next: ConnectionState): void {
this.state = next;
if (next.status === "disconnected") {
@@ -176,12 +182,7 @@ function makeHost(input?: Partial<HostProfile>): HostProfile {
return {
serverId: input?.serverId ?? "srv_test",
label: input?.label ?? "test host",
lifecycle: input?.lifecycle ?? {
managed: false,
managedRuntimeId: null,
managedRuntimeVersion: null,
associatedServerId: null,
},
lifecycle: input?.lifecycle ?? {},
connections: input?.connections ?? [direct, relay],
preferredConnectionId: input?.preferredConnectionId ?? direct.id,
createdAt: input?.createdAt ?? new Date(0).toISOString(),
@@ -199,7 +200,7 @@ function makeDeps(
createdClients.push(client);
return client as unknown as DaemonClient;
},
measureLatency: async ({ connection }) => {
connectToDaemon: async ({ host, connection }) => {
const value = latencyByConnectionId[connection.id];
if (value instanceof Error) {
throw value;
@@ -207,7 +208,16 @@ function makeDeps(
if (typeof value !== "number") {
throw new Error(`missing latency for ${connection.id}`);
}
return value;
const client = new FakeDaemonClient();
client.connectCalls = 1;
client.setConnectionState({ status: "connected" });
client.ping = async () => ({ rttMs: value });
createdClients.push(client);
return {
client: client as unknown as DaemonClient,
serverId: host.serverId,
hostname: host.label ?? null,
};
},
getClientId: async () => "cid_test_runtime",
};
@@ -227,8 +237,24 @@ function createDeferred<T>() {
};
}
function makeConnectedProbeClient(latencyMs: number): FakeDaemonClient {
const client = new FakeDaemonClient();
client.connectCalls = 1;
client.setConnectionState({ status: "connected" });
client.ping = async () => ({ rttMs: latencyMs });
return client;
}
function clearProbeBackoff(controller: HostRuntimeController): void {
(
controller as unknown as {
connectionLastProbedAt: Map<string, number>;
}
).connectionLastProbedAt.clear();
}
describe("HostRuntimeController", () => {
it("keeps known hosts in connecting when client reports idle during connect", async () => {
it("keeps known hosts in connecting when a created client reports idle during connect", async () => {
const host = makeHost({
connections: [
{
@@ -241,7 +267,7 @@ describe("HostRuntimeController", () => {
const idleClient = new FakeDaemonClient();
const deps: HostRuntimeControllerDeps = {
createClient: () => idleClient as unknown as DaemonClient,
measureLatency: async () => {
connectToDaemon: async () => {
throw new Error("probe unavailable");
},
getClientId: async () => "cid_test_runtime",
@@ -256,7 +282,11 @@ describe("HostRuntimeController", () => {
// Intentionally do not emit a connected state; stay in idle.
};
await controller.start({ autoProbe: false });
await (
controller as unknown as {
switchToConnection: (input: { connectionId: string }) => Promise<void>;
}
).switchToConnection({ connectionId: "direct:lan:6767" });
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
expect(controller.getSnapshot().connectionStatus).toBe("connecting");
@@ -282,18 +312,24 @@ describe("HostRuntimeController", () => {
seenClientIds.push(clientId);
return fakeClient as unknown as DaemonClient;
},
measureLatency: async () => 10,
connectToDaemon: async () => {
throw new Error("probe unavailable");
},
getClientId: async () => "cid_runtime_stable",
},
});
await controller.start({ autoProbe: false });
await (
controller as unknown as {
switchToConnection: (input: { connectionId: string }) => Promise<void>;
}
).switchToConnection({ connectionId: "direct:lan:6767" });
expect(seenClientIds).toEqual(["cid_runtime_stable"]);
expect(controller.getSnapshot().connectionStatus).toBe("online");
});
it("selects the lowest-latency connection on startup", async () => {
it("adopts the first successful probe on startup", async () => {
const host = makeHost({ preferredConnectionId: "direct:lan:6767" });
const clients: FakeDaemonClient[] = [];
const latencies: Record<string, number | Error> = {
@@ -308,10 +344,62 @@ describe("HostRuntimeController", () => {
await controller.start({ autoProbe: false });
const snapshot = controller.getSnapshot();
expect(snapshot.activeConnectionId).toBe("relay:relay.paseo.sh:443");
expect(snapshot.activeConnectionId).toBe("direct:lan:6767");
expect(snapshot.connectionStatus).toBe("online");
expect(clients).toHaveLength(1);
expect(clients).toHaveLength(2);
expect(snapshot.client).toBe(clients[0] as unknown as DaemonClient);
expect(clients[0]?.connectCalls).toBe(1);
expect(clients[1]?.closeCalls).toBe(1);
});
it("activates the first successful probe without waiting for slower probes", async () => {
const host = makeHost({ preferredConnectionId: "direct:lan:6767" });
const slowPing = createDeferred<number>();
const clients: FakeDaemonClient[] = [];
const controller = new HostRuntimeController({
host,
deps: {
createClient: () => {
throw new Error("should adopt the probe client");
},
connectToDaemon: async ({ host, connection }) => {
const client = makeConnectedProbeClient(
connection.id === "direct:lan:6767" ? 12 : 30
);
if (connection.id === "relay:relay.paseo.sh:443") {
client.ping = async () => ({ rttMs: await slowPing.promise });
}
clients.push(client);
return {
client: client as unknown as DaemonClient,
serverId: host.serverId,
hostname: host.label ?? null,
};
},
getClientId: async () => "cid_test_runtime",
},
});
const probeCycle = controller.runProbeCycleNow();
const timeoutAt = Date.now() + 200;
while (Date.now() < timeoutAt) {
const snapshot = controller.getSnapshot();
if (
snapshot.activeConnectionId === "direct:lan:6767" &&
snapshot.connectionStatus === "online"
) {
break;
}
await new Promise((resolve) => setTimeout(resolve, 0));
}
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
expect(controller.getSnapshot().connectionStatus).toBe("online");
slowPing.resolve(30);
await probeCycle;
});
it("fails over when active connection becomes unavailable", async () => {
@@ -328,17 +416,19 @@ describe("HostRuntimeController", () => {
await controller.start({ autoProbe: false });
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
expect(clients).toHaveLength(1);
const initialClient = controller.getSnapshot().client;
expect(initialClient).toBeTruthy();
latencies["direct:lan:6767"] = new Error("direct unavailable");
latencies["relay:relay.paseo.sh:443"] = 42;
clearProbeBackoff(controller);
await controller.runProbeCycleNow();
const snapshot = controller.getSnapshot();
expect(snapshot.activeConnectionId).toBe("relay:relay.paseo.sh:443");
expect(snapshot.connectionStatus).toBe("online");
expect(clients).toHaveLength(2);
expect(clients[0]?.closeCalls).toBe(1);
expect(snapshot.client).not.toBe(initialClient);
expect((initialClient as unknown as FakeDaemonClient | null)?.closeCalls).toBe(1);
});
it("switches only after the faster alternative wins consecutive probes", async () => {
@@ -358,15 +448,24 @@ describe("HostRuntimeController", () => {
latencies["direct:lan:6767"] = 95;
latencies["relay:relay.paseo.sh:443"] = 30;
clearProbeBackoff(controller);
await controller.runProbeCycleNow();
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
clearProbeBackoff(controller);
await controller.runProbeCycleNow();
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
await controller.runProbeCycleNow();
expect(controller.getSnapshot().activeConnectionId).toBe("relay:relay.paseo.sh:443");
expect(clients).toHaveLength(2);
let switched = controller.getSnapshot().activeConnectionId === "relay:relay.paseo.sh:443";
for (let index = 0; index < 6 && !switched; index += 1) {
clearProbeBackoff(controller);
await controller.runProbeCycleNow();
switched =
controller.getSnapshot().activeConnectionId ===
"relay:relay.paseo.sh:443";
}
expect(switched).toBe(true);
expect(controller.getSnapshot().client).not.toBeNull();
});
it("does not switch on a transient latency spike", async () => {
@@ -386,24 +485,35 @@ describe("HostRuntimeController", () => {
latencies["direct:lan:6767"] = 100;
latencies["relay:relay.paseo.sh:443"] = 20;
clearProbeBackoff(controller);
await controller.runProbeCycleNow();
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
latencies["direct:lan:6767"] = 20;
latencies["relay:relay.paseo.sh:443"] = 90;
clearProbeBackoff(controller);
await controller.runProbeCycleNow();
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
latencies["direct:lan:6767"] = 100;
latencies["relay:relay.paseo.sh:443"] = 20;
clearProbeBackoff(controller);
await controller.runProbeCycleNow();
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
clearProbeBackoff(controller);
await controller.runProbeCycleNow();
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
await controller.runProbeCycleNow();
expect(controller.getSnapshot().activeConnectionId).toBe("relay:relay.paseo.sh:443");
let switched = controller.getSnapshot().activeConnectionId === "relay:relay.paseo.sh:443";
for (let index = 0; index < 6 && !switched; index += 1) {
clearProbeBackoff(controller);
await controller.runProbeCycleNow();
switched =
controller.getSnapshot().activeConnectionId ===
"relay:relay.paseo.sh:443";
}
expect(switched).toBe(true);
});
it("exposes one snapshot with active connection and status from same source", async () => {
@@ -609,7 +719,11 @@ describe("HostRuntimeController", () => {
createdClients.push(client);
return client as unknown as DaemonClient;
},
measureLatency: async () => 10,
connectToDaemon: async ({ host }) => ({
client: makeConnectedProbeClient(10) as unknown as DaemonClient,
serverId: host.serverId,
hostname: host.label ?? null,
}),
getClientId: async () => "cid_test_runtime",
};
const controller = new HostRuntimeController({
@@ -686,21 +800,32 @@ describe("HostRuntimeController", () => {
host,
deps: {
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
measureLatency: async () => {
connectToDaemon: async ({ host }) => {
probeCalls += 1;
if (probeCalls === 1) {
return await slowProbe.promise;
}
if (probeCalls === 2) {
return await fastProbe.promise;
}
throw new Error("unexpected probe call");
const client = new FakeDaemonClient();
client.connectCalls = 1;
client.setConnectionState({ status: "connected" });
client.ping = async () => {
if (probeCalls === 1) {
return { rttMs: await slowProbe.promise };
}
if (probeCalls === 2) {
return { rttMs: await fastProbe.promise };
}
throw new Error("unexpected probe call");
};
return {
client: client as unknown as DaemonClient,
serverId: host.serverId,
hostname: host.label ?? null,
};
},
getClientId: async () => "cid_test_runtime",
},
});
const first = controller.runProbeCycleNow();
clearProbeBackoff(controller);
const second = controller.runProbeCycleNow();
fastProbe.resolve(12);
@@ -724,7 +849,7 @@ describe("HostRuntimeController", () => {
});
});
it("keeps active client generation stable while overlapping probes run", async () => {
it("keeps active client generation stable during background probe cycles", async () => {
const host = makeHost({
connections: [
{
@@ -734,10 +859,7 @@ describe("HostRuntimeController", () => {
},
],
});
const slowProbe = createDeferred<number>();
const fastProbe = createDeferred<number>();
const createdClients: FakeDaemonClient[] = [];
let probeCalls = 0;
const controller = new HostRuntimeController({
host,
@@ -747,18 +869,13 @@ describe("HostRuntimeController", () => {
createdClients.push(client);
return client as unknown as DaemonClient;
},
measureLatency: async () => {
probeCalls += 1;
if (probeCalls === 1) {
return 10;
}
if (probeCalls === 2) {
return await slowProbe.promise;
}
if (probeCalls === 3) {
return await fastProbe.promise;
}
return 10;
connectToDaemon: async ({ host }) => {
const client = makeConnectedProbeClient(10);
return {
client: client as unknown as DaemonClient,
serverId: host.serverId,
hostname: host.label ?? null,
};
},
getClientId: async () => "cid_test_runtime",
},
@@ -768,24 +885,13 @@ describe("HostRuntimeController", () => {
const activeClientBeforeProbes = controller.getSnapshot().client;
const generationBeforeProbes = controller.getSnapshot().clientGeneration;
const first = controller.runProbeCycleNow();
const second = controller.runProbeCycleNow();
fastProbe.resolve(12);
await second;
clearProbeBackoff(controller);
await controller.runProbeCycleNow();
expect(controller.getSnapshot().client).toBe(activeClientBeforeProbes);
expect(controller.getSnapshot().clientGeneration).toBe(
generationBeforeProbes
);
slowProbe.resolve(999);
await first;
expect(controller.getSnapshot().client).toBe(activeClientBeforeProbes);
expect(controller.getSnapshot().clientGeneration).toBe(
generationBeforeProbes
);
expect(createdClients).toHaveLength(1);
expect(createdClients[0]?.closeCalls).toBe(0);
expect(createdClients).toHaveLength(0);
});
});
@@ -801,10 +907,15 @@ describe("HostRuntimeStore", () => {
],
});
const fakeClient = new FakeDaemonClient();
fakeClient.setConnectionState({ status: "connected" });
const store = new HostRuntimeStore({
deps: {
createClient: () => fakeClient as unknown as DaemonClient,
measureLatency: async () => 5,
connectToDaemon: async ({ host }) => ({
client: fakeClient as unknown as DaemonClient,
serverId: host.serverId,
hostname: host.label ?? null,
}),
getClientId: async () => "cid_test_runtime",
},
});
@@ -837,9 +948,9 @@ describe("HostRuntimeStore", () => {
useSessionStore.getState().clearSession(host.serverId);
});
it("defers directory bootstrap until session store is initialized for that server", async () => {
it("bootstraps agent directory immediately when connection goes online (no session required)", async () => {
const host = makeHost({
serverId: "srv_deferred",
serverId: "srv_no_session",
connections: [
{
id: "direct:lan:6767",
@@ -849,26 +960,22 @@ describe("HostRuntimeStore", () => {
],
});
const fakeClient = new FakeDaemonClient();
fakeClient.setConnectionState({ status: "connected" });
const store = new HostRuntimeStore({
deps: {
createClient: () => fakeClient as unknown as DaemonClient,
measureLatency: async () => 5,
connectToDaemon: async ({ host }) => ({
client: fakeClient as unknown as DaemonClient,
serverId: host.serverId,
hostname: host.label ?? null,
}),
getClientId: async () => "cid_test_runtime",
},
});
store.syncHosts([host]);
await new Promise((resolve) => setTimeout(resolve, 250));
expect(fakeClient.fetchAgentsCalls).toHaveLength(0);
useSessionStore.getState().initializeSession(
host.serverId,
fakeClient as unknown as DaemonClient,
null as any
);
const timeoutAt = Date.now() + 600;
const timeoutAt = Date.now() + 200;
while (fakeClient.fetchAgentsCalls.length === 0 && Date.now() < timeoutAt) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
@@ -877,12 +984,11 @@ describe("HostRuntimeStore", () => {
expect(fakeClient.fetchAgentsCalls[0]).toEqual({
filter: { includeArchived: true },
sort: [{ key: "updated_at", direction: "desc" }],
subscribe: { subscriptionId: "app:srv_deferred" },
subscribe: { subscriptionId: "app:srv_no_session" },
page: { limit: 200 },
});
store.syncHosts([]);
useSessionStore.getState().clearSession(host.serverId);
});
it("fetches all pages during bootstrap so older workspace agents are present", async () => {
@@ -897,6 +1003,7 @@ describe("HostRuntimeStore", () => {
],
});
const fakeClient = new FakeDaemonClient();
fakeClient.setConnectionState({ status: "connected" });
fakeClient.fetchAgentsResponses.push(
makeFetchAgentsPayload({
entries: [
@@ -928,7 +1035,11 @@ describe("HostRuntimeStore", () => {
const store = new HostRuntimeStore({
deps: {
createClient: () => fakeClient as unknown as DaemonClient,
measureLatency: async () => 5,
connectToDaemon: async ({ host }) => ({
client: fakeClient as unknown as DaemonClient,
serverId: host.serverId,
hostname: host.label ?? null,
}),
getClientId: async () => "cid_test_runtime",
},
});
@@ -996,10 +1107,15 @@ describe("HostRuntimeStore", () => {
],
});
const fakeClient = new FakeDaemonClient();
fakeClient.setConnectionState({ status: "connected" });
const store = new HostRuntimeStore({
deps: {
createClient: () => fakeClient as unknown as DaemonClient,
measureLatency: async () => 5,
connectToDaemon: async ({ host }) => ({
client: fakeClient as unknown as DaemonClient,
serverId: host.serverId,
hostname: host.label ?? null,
}),
getClientId: async () => "cid_test_runtime",
},
});
@@ -1046,7 +1162,7 @@ describe("HostRuntimeStore", () => {
useSessionStore.getState().clearSession(host.serverId);
});
it("surfaces startup failures as error instead of leaving host idle", async () => {
it("records unavailable startup probes when no connection can be established", async () => {
const host = makeHost({
connections: [
{
@@ -1061,7 +1177,7 @@ describe("HostRuntimeStore", () => {
createClient: () => {
throw new Error("create client failed");
},
measureLatency: async () => {
connectToDaemon: async () => {
throw new Error("probe unavailable");
},
getClientId: async () => "cid_test_runtime",
@@ -1071,12 +1187,51 @@ describe("HostRuntimeStore", () => {
store.syncHosts([host]);
let snapshot = store.getSnapshot(host.serverId);
const timeoutAt = Date.now() + 100;
while (snapshot?.connectionStatus !== "error" && Date.now() < timeoutAt) {
while (
snapshot?.probeByConnectionId.get("direct:lan:6767")?.status !== "unavailable" &&
Date.now() < timeoutAt
) {
await new Promise((resolve) => setTimeout(resolve, 0));
snapshot = store.getSnapshot(host.serverId);
}
expect(snapshot?.connectionStatus).toBe("error");
expect(snapshot?.lastError).toBe("create client failed");
expect(snapshot?.connectionStatus).toBe("connecting");
expect(snapshot?.lastError).toBeNull();
expect(snapshot?.probeByConnectionId.get("direct:lan:6767")).toEqual({
status: "unavailable",
latencyMs: null,
});
});
it("renameHost updates label in memory", async () => {
const store = new HostRuntimeStore({
deps: {
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
connectToDaemon: async ({ host }) => ({
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
serverId: host.serverId,
hostname: host.label ?? null,
}),
getClientId: async () => "cid_test_runtime",
},
});
// upsertDirectConnection goes through setHostsAndSync, which both sets
// this.hosts and syncs controllers — matching the real init path.
await store.upsertDirectConnection({
serverId: "srv_rename",
endpoint: "lan:6767",
label: "old name",
});
expect(store.getHosts().find((h) => h.serverId === "srv_rename")?.label).toBe("old name");
// persistHosts may throw in test env (no AsyncStorage/window), but the
// in-memory state should still be updated by setHostsAndSync.
await store.renameHost("srv_rename", "new name").catch(() => undefined);
const renamed = store.getHosts().find((h) => h.serverId === "srv_rename");
expect(renamed?.label).toBe("new name");
store.syncHosts([]);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,38 +0,0 @@
import {
consumePersistedPerfDiagnosticReports,
getPerfDiagnosticsSnapshot,
isPerfDiagnosticsEnabled,
peekPersistedPerfDiagnosticReports,
} from "./engine";
export function installPerfDiagnosticsDebugTools(): () => void {
if (!isPerfDiagnosticsEnabled()) {
return () => {};
}
(
globalThis as {
__PASEO_PERF_DIAGNOSTICS_DEBUG__?: {
snapshot: (limit?: number) => ReturnType<typeof getPerfDiagnosticsSnapshot>;
consumeReports: () => Promise<
Awaited<ReturnType<typeof consumePersistedPerfDiagnosticReports>>
>;
peekReports: () => Promise<
Awaited<ReturnType<typeof peekPersistedPerfDiagnosticReports>>
>;
};
}
).__PASEO_PERF_DIAGNOSTICS_DEBUG__ = {
snapshot: (limit?: number) => getPerfDiagnosticsSnapshot(limit ?? 120),
consumeReports: () => consumePersistedPerfDiagnosticReports(),
peekReports: () => peekPersistedPerfDiagnosticReports(),
};
return () => {
(
globalThis as {
__PASEO_PERF_DIAGNOSTICS_DEBUG__?: unknown;
}
).__PASEO_PERF_DIAGNOSTICS_DEBUG__ = undefined;
};
}

View File

@@ -1,311 +0,0 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { Platform } from "react-native";
import { getNowMs } from "@/utils/perf";
import type {
PerfDiagnosticBreadcrumb,
PerfDiagnosticFields,
PerfDiagnosticsReport,
PerfDiagnosticsSnapshot,
RecordPerfDiagnosticMarkOptions,
} from "./types";
const STORAGE_KEY = "paseo:perf-diagnostics:v1";
const LEGACY_STORAGE_KEY = "paseo:js-hang-diagnostics:v1";
const MONITOR_INTERVAL_MS = 100;
const STALL_THRESHOLD_MS = 250;
const SAMPLE_EVERY_N = 40;
const MAX_BREADCRUMBS = 600;
const BREADCRUMBS_PER_REPORT = 220;
const MAX_STORED_REPORTS = 20;
const state = {
breadcrumbs: [] as PerfDiagnosticBreadcrumb[],
sampleCursor: 0,
monitorHandle: null as ReturnType<typeof setInterval> | null,
monitorLastTickMs: 0,
monitorScope: null as string | null,
monitorRefCount: 0,
loadLogged: false,
persistQueue: Promise.resolve(),
};
function isPrimitive(value: unknown): value is string | number | boolean | null {
return (
value === null ||
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean"
);
}
function sanitizeFieldValue(value: unknown): unknown {
if (isPrimitive(value)) {
if (typeof value === "string" && value.length > 180) {
return `${value.slice(0, 180)}...`;
}
return value;
}
if (value instanceof Date) {
return value.toISOString();
}
if (Array.isArray(value)) {
return `[array:${value.length}]`;
}
if (value instanceof Map) {
return `[map:${value.size}]`;
}
if (value instanceof Set) {
return `[set:${value.size}]`;
}
if (typeof value === "bigint") {
return value.toString();
}
if (value === undefined) {
return "[undefined]";
}
return "[object]";
}
function sanitizeFields(
fields?: PerfDiagnosticFields
): PerfDiagnosticFields | undefined {
if (!fields) {
return undefined;
}
const next: PerfDiagnosticFields = {};
let count = 0;
for (const [key, value] of Object.entries(fields)) {
if (count >= 16) {
next.__truncated__ = true;
break;
}
next[key] = sanitizeFieldValue(value);
count += 1;
}
return next;
}
function shouldEnableDiagnostics(): boolean {
const globalFlag = (
globalThis as {
__PASEO_PERF_DIAGNOSTICS__?: unknown;
__PASEO_JS_HANG_DIAGNOSTICS__?: unknown;
}
).__PASEO_PERF_DIAGNOSTICS__;
const legacyFlag = (
globalThis as {
__PASEO_PERF_DIAGNOSTICS__?: unknown;
__PASEO_JS_HANG_DIAGNOSTICS__?: unknown;
}
).__PASEO_JS_HANG_DIAGNOSTICS__;
if (typeof globalFlag === "boolean") {
return globalFlag;
}
if (typeof legacyFlag === "boolean") {
return legacyFlag;
}
const isDev = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
return Platform.OS === "android" || isDev;
}
function shouldSample(): boolean {
state.sampleCursor += 1;
if (state.sampleCursor >= SAMPLE_EVERY_N) {
state.sampleCursor = 0;
return true;
}
return false;
}
function pushBreadcrumb(item: PerfDiagnosticBreadcrumb): void {
state.breadcrumbs.push(item);
if (state.breadcrumbs.length > MAX_BREADCRUMBS) {
state.breadcrumbs.splice(0, state.breadcrumbs.length - MAX_BREADCRUMBS);
}
}
function safeParseReports(raw: string | null): PerfDiagnosticsReport[] {
if (!raw) {
return [];
}
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
return [];
}
return parsed.filter(
(entry) => entry && typeof entry === "object"
) as PerfDiagnosticsReport[];
} catch {
return [];
}
}
async function readPersistedReports(): Promise<PerfDiagnosticsReport[]> {
const stored = safeParseReports(await AsyncStorage.getItem(STORAGE_KEY));
if (stored.length > 0) {
return stored;
}
const legacy = safeParseReports(await AsyncStorage.getItem(LEGACY_STORAGE_KEY));
if (legacy.length > 0) {
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(legacy));
await AsyncStorage.removeItem(LEGACY_STORAGE_KEY);
}
return legacy;
}
async function persistReport(report: PerfDiagnosticsReport): Promise<void> {
state.persistQueue = state.persistQueue
.then(async () => {
const existing = await readPersistedReports();
existing.push(report);
if (existing.length > MAX_STORED_REPORTS) {
existing.splice(0, existing.length - MAX_STORED_REPORTS);
}
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(existing));
})
.catch((error) => {
console.warn("[PerfDiagnostics] Failed to persist report", {
message: error instanceof Error ? error.message : String(error),
});
});
await state.persistQueue;
}
function buildReport(
scope: string,
atMs: number,
lagMs: number
): PerfDiagnosticsReport {
const id = `${Math.round(atMs)}-${Math.floor(Math.random() * 1_000_000)}`;
const breadcrumbs = state.breadcrumbs.slice(-BREADCRUMBS_PER_REPORT);
const wallTimeMs = Date.now();
return {
id,
scope,
atMs,
wallTimeMs,
wallTimeIso: new Date(wallTimeMs).toISOString(),
lagMs,
platform: Platform.OS,
breadcrumbs,
};
}
export function recordPerfDiagnosticMark(
name: string,
fields?: PerfDiagnosticFields,
options?: RecordPerfDiagnosticMarkOptions
): void {
if (!shouldEnableDiagnostics()) {
return;
}
const force = options?.force === true;
if (!force && !shouldSample()) {
return;
}
pushBreadcrumb({
atMs: getNowMs(),
kind: "mark",
name,
fields: sanitizeFields(fields),
});
}
export function installPerfDiagnosticsMonitor(scope: string): () => void {
if (!shouldEnableDiagnostics()) {
return () => {};
}
if (state.monitorRefCount === 0) {
state.monitorScope = scope;
state.monitorLastTickMs = getNowMs();
state.monitorHandle = setInterval(() => {
const nowMs = getNowMs();
const lagMs = nowMs - state.monitorLastTickMs - MONITOR_INTERVAL_MS;
if (lagMs >= STALL_THRESHOLD_MS) {
const report = buildReport(state.monitorScope ?? scope, nowMs, lagMs);
recordPerfDiagnosticMark(
"perf.stall_detected",
{
scope: report.scope,
lagMs: Math.round(lagMs),
reportId: report.id,
breadcrumbs: report.breadcrumbs.length,
},
{ force: true }
);
console.warn("[PerfDiagnostics] JS stall detected", {
scope: report.scope,
lagMs: Math.round(lagMs),
reportId: report.id,
breadcrumbs: report.breadcrumbs.length,
});
void persistReport(report);
}
state.monitorLastTickMs = nowMs;
}, MONITOR_INTERVAL_MS);
}
state.monitorRefCount += 1;
if (!state.loadLogged) {
state.loadLogged = true;
void readPersistedReports()
.then((reports) => {
if (reports.length > 0) {
console.warn("[PerfDiagnostics] Recovered persisted stall reports", {
count: reports.length,
latestReportId: reports[reports.length - 1]?.id ?? null,
});
}
})
.catch(() => undefined);
}
return () => {
state.monitorRefCount = Math.max(0, state.monitorRefCount - 1);
if (state.monitorRefCount === 0 && state.monitorHandle) {
clearInterval(state.monitorHandle);
state.monitorHandle = null;
state.monitorScope = null;
}
};
}
export async function consumePersistedPerfDiagnosticReports(): Promise<
PerfDiagnosticsReport[]
> {
const reports = await readPersistedReports();
await AsyncStorage.removeItem(STORAGE_KEY);
await AsyncStorage.removeItem(LEGACY_STORAGE_KEY);
return reports;
}
export async function peekPersistedPerfDiagnosticReports(): Promise<
PerfDiagnosticsReport[]
> {
return readPersistedReports();
}
export function isPerfDiagnosticsEnabled(): boolean {
return shouldEnableDiagnostics();
}
export function getPerfDiagnosticBreadcrumbs(
limit = 120
): PerfDiagnosticBreadcrumb[] {
if (limit <= 0) {
return [];
}
return state.breadcrumbs.slice(-Math.floor(limit));
}
export function getPerfDiagnosticsSnapshot(limit = 120): PerfDiagnosticsSnapshot {
return {
enabled: shouldEnableDiagnostics(),
monitorScope: state.monitorScope,
breadcrumbCount: state.breadcrumbs.length,
breadcrumbs: getPerfDiagnosticBreadcrumbs(limit),
};
}

View File

@@ -1,121 +0,0 @@
import type { DaemonClientDiagnosticsEvent } from "@server/client/daemon-client";
import { recordPerfDiagnosticMark } from "./engine";
let fastTransportSampleCursor = 0;
const transportRateByServer = new Map<
string,
{
startedAtMs: number;
messageCount: number;
bytes: number;
errorCount: number;
slowCount: number;
}
>();
function shouldSampleFastTransportEvent(): boolean {
fastTransportSampleCursor += 1;
if (fastTransportSampleCursor >= 50) {
fastTransportSampleCursor = 0;
return true;
}
return false;
}
export function recordHostRuntimeCreateClient(params: {
serverId: string;
connectionType: "directTcp" | "directSocket" | "directPipe" | "relay";
endpoint: string;
}): void {
recordPerfDiagnosticMark(
"host_runtime.create_client",
{
serverId: params.serverId,
connectionType: params.connectionType,
endpoint: params.endpoint,
},
{ force: true }
);
}
export function recordDaemonClientDiagnostics(
serverId: string,
event: DaemonClientDiagnosticsEvent
): void {
if (event.type === "transport_message_timing") {
const nowWallTimeMs = Date.now();
const current =
transportRateByServer.get(serverId) ?? {
startedAtMs: nowWallTimeMs,
messageCount: 0,
bytes: 0,
errorCount: 0,
slowCount: 0,
};
current.messageCount += 1;
current.bytes += event.payloadBytes;
if (event.outcome !== "ok") {
current.errorCount += 1;
}
if (event.totalMs >= 8) {
current.slowCount += 1;
}
if (nowWallTimeMs - current.startedAtMs >= 1000) {
recordPerfDiagnosticMark(
"daemon_client.message_rate",
{
serverId,
windowMs: nowWallTimeMs - current.startedAtMs,
messageCount: current.messageCount,
bytes: current.bytes,
errorCount: current.errorCount,
slowCount: current.slowCount,
},
{ force: current.errorCount > 0 || current.slowCount > 0 }
);
current.startedAtMs = nowWallTimeMs;
current.messageCount = 0;
current.bytes = 0;
current.errorCount = 0;
current.slowCount = 0;
}
transportRateByServer.set(serverId, current);
}
if (event.type === "transport_binary_frame") {
if (event.payloadBytes < 16_384) {
return;
}
recordPerfDiagnosticMark(
"daemon_client.binary_frame",
{
serverId,
channel: event.channel,
messageType: event.messageType,
payloadBytes: event.payloadBytes,
},
{ force: true }
);
return;
}
const isSlow =
event.totalMs >= 8 || event.parseMs >= 4 || event.validateMs >= 4;
const isError = event.outcome !== "ok";
if (!isSlow && !isError && !shouldSampleFastTransportEvent()) {
return;
}
recordPerfDiagnosticMark(
"daemon_client.transport_message",
{
serverId,
messageType: event.messageType,
outcome: event.outcome,
payloadBytes: event.payloadBytes,
parseMs: Math.round(event.parseMs * 100) / 100,
validateMs: Math.round(event.validateMs * 100) / 100,
totalMs: Math.round(event.totalMs * 100) / 100,
},
{ force: isSlow || isError }
);
}

View File

@@ -1,22 +0,0 @@
export type {
PerfDiagnosticBreadcrumb,
PerfDiagnosticFields,
PerfDiagnosticsReport,
PerfDiagnosticsSnapshot,
PerfDiagnosticsReporter,
RecordPerfDiagnosticMarkOptions,
} from "./types";
export {
consumePersistedPerfDiagnosticReports,
getPerfDiagnosticBreadcrumbs,
getPerfDiagnosticsSnapshot,
installPerfDiagnosticsMonitor,
isPerfDiagnosticsEnabled,
peekPersistedPerfDiagnosticReports,
recordPerfDiagnosticMark,
} from "./engine";
export { getPerfDiagnosticsReporter } from "./reporter";
export { PerfDiagnosticsProvider } from "./perf-diagnostics-provider";
export { usePerfDiagnostics } from "./use-perf-diagnostics";

View File

@@ -1,21 +0,0 @@
import { createContext } from "react";
import type { PerfDiagnosticsReporter, PerfDiagnosticsSnapshot } from "./types";
const EMPTY_SNAPSHOT: PerfDiagnosticsSnapshot = {
enabled: false,
monitorScope: null,
breadcrumbCount: 0,
breadcrumbs: [],
};
const NOOP_REPORTER: PerfDiagnosticsReporter = {
mark: () => {},
installMonitor: () => () => {},
consumeReports: async () => [],
peekReports: async () => [],
isEnabled: () => false,
getSnapshot: () => EMPTY_SNAPSHOT,
};
export const PerfDiagnosticsContext =
createContext<PerfDiagnosticsReporter>(NOOP_REPORTER);

View File

@@ -1,53 +0,0 @@
import { useEffect, useMemo, type ReactNode } from "react";
import { installPerfDiagnosticsDebugTools } from "./debug-tools";
import { PerfDiagnosticsContext } from "./perf-diagnostics-context";
import { getPerfDiagnosticsReporter } from "./reporter";
interface PerfDiagnosticsProviderProps {
children: ReactNode;
scope?: string;
}
export function PerfDiagnosticsProvider({
children,
scope = "root_layout",
}: PerfDiagnosticsProviderProps) {
const reporter = useMemo(() => getPerfDiagnosticsReporter(), []);
useEffect(() => {
if (!reporter.isEnabled()) {
return;
}
const removeDebugTools = installPerfDiagnosticsDebugTools();
const stopMonitor = reporter.installMonitor(scope);
void reporter
.peekReports()
.then((reports) => {
if (reports.length === 0) {
return;
}
const latest = reports[reports.length - 1];
console.warn("[PerfDiagnostics] Loaded persisted stall reports", {
count: reports.length,
latestReportId: latest?.id ?? null,
latestWallTime: latest?.wallTimeIso ?? null,
latestLagMs: latest ? Math.round(latest.lagMs) : null,
latestBreadcrumbs: latest?.breadcrumbs.length ?? null,
});
})
.catch(() => undefined);
return () => {
removeDebugTools();
stopMonitor();
};
}, [scope, reporter]);
return (
<PerfDiagnosticsContext.Provider value={reporter}>
{children}
</PerfDiagnosticsContext.Provider>
);
}

View File

@@ -1,22 +0,0 @@
import {
consumePersistedPerfDiagnosticReports,
getPerfDiagnosticsSnapshot,
installPerfDiagnosticsMonitor,
isPerfDiagnosticsEnabled,
peekPersistedPerfDiagnosticReports,
recordPerfDiagnosticMark,
} from "./engine";
import type { PerfDiagnosticsReporter } from "./types";
const reporter: PerfDiagnosticsReporter = {
mark: recordPerfDiagnosticMark,
installMonitor: installPerfDiagnosticsMonitor,
consumeReports: consumePersistedPerfDiagnosticReports,
peekReports: peekPersistedPerfDiagnosticReports,
isEnabled: isPerfDiagnosticsEnabled,
getSnapshot: getPerfDiagnosticsSnapshot,
};
export function getPerfDiagnosticsReporter(): PerfDiagnosticsReporter {
return reporter;
}

View File

@@ -1,44 +0,0 @@
export type PerfDiagnosticFields = Record<string, unknown>;
export interface PerfDiagnosticBreadcrumb {
atMs: number;
kind: "mark" | "span";
name: string;
durationMs?: number;
fields?: PerfDiagnosticFields;
}
export interface PerfDiagnosticsReport {
id: string;
scope: string;
atMs: number;
wallTimeMs: number;
wallTimeIso: string;
lagMs: number;
platform: string;
breadcrumbs: PerfDiagnosticBreadcrumb[];
}
export interface RecordPerfDiagnosticMarkOptions {
force?: boolean;
}
export interface PerfDiagnosticsSnapshot {
enabled: boolean;
monitorScope: string | null;
breadcrumbCount: number;
breadcrumbs: PerfDiagnosticBreadcrumb[];
}
export interface PerfDiagnosticsReporter {
mark: (
name: string,
fields?: PerfDiagnosticFields,
options?: RecordPerfDiagnosticMarkOptions
) => void;
installMonitor: (scope: string) => () => void;
consumeReports: () => Promise<PerfDiagnosticsReport[]>;
peekReports: () => Promise<PerfDiagnosticsReport[]>;
isEnabled: () => boolean;
getSnapshot: (limit?: number) => PerfDiagnosticsSnapshot;
}

View File

@@ -1,7 +0,0 @@
import { useContext } from "react";
import { PerfDiagnosticsContext } from "./perf-diagnostics-context";
import type { PerfDiagnosticsReporter } from "./types";
export function usePerfDiagnostics(): PerfDiagnosticsReporter {
return useContext(PerfDiagnosticsContext);
}

View File

@@ -21,7 +21,7 @@ import {
ExplorerSidebarAnimationProvider,
} from "@/contexts/explorer-sidebar-animation-context";
import { usePanelStore } from "@/stores/panel-store";
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
import { useHosts } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import {
useHostRuntimeSession,
@@ -30,11 +30,6 @@ import {
import { useCreateFlowStore } from "@/stores/create-flow-store";
import type { Agent } from "@/contexts/session-context";
import type { StreamItem } from "@/types/stream";
import {
buildAgentNavigationKey,
endNavigationTiming,
} from "@/utils/navigation-timing";
import { startPerfMonitor } from "@/utils/perf-monitor";
import {
checkoutStatusQueryKey,
type CheckoutStatusPayload,
@@ -94,7 +89,7 @@ export function AgentReadyScreen({
}) {
const resolvedAgentId = agentId?.trim() || undefined;
const resolvedServerId = serverId?.trim() || undefined;
const { daemons } = useDaemonRegistry();
const daemons = useHosts();
const runtimeServerId = resolvedServerId ?? "";
const {
snapshot: runtimeSnapshot,
@@ -115,27 +110,6 @@ export function AgentReadyScreen({
const lastConnectionError = runtimeSnapshot?.lastError ?? null;
const isRuntimeSessionAvailable = Boolean(resolvedServerId && runtimeClient);
const focusServerId = resolvedServerId;
const navigationStatus = isRuntimeSessionAvailable
? "ready"
: "session_unavailable";
useFocusEffect(
useCallback(() => {
if (!resolvedAgentId || !focusServerId) {
return;
}
const navigationKey = buildAgentNavigationKey(
focusServerId,
resolvedAgentId
);
endNavigationTiming(navigationKey, {
screen: "agent",
status: navigationStatus,
});
}, [focusServerId, navigationStatus, resolvedAgentId])
);
if (!resolvedServerId || !runtimeClient) {
return (
<AgentSessionUnavailableState
@@ -294,15 +268,6 @@ function AgentScreenContent({
}
openFileExplorer();
}, [activateExplorerTabForCheckout, openFileExplorer, resolveCurrentExplorerCheckout]);
useEffect(() => {
if (Platform.OS !== "web") {
return;
}
const scope = `agent:${serverId}:${agentId ?? "unknown"}`;
const stop = startPerfMonitor(scope);
return stop;
}, [serverId, agentId]);
// Swipe-left gesture to open explorer sidebar on mobile
const explorerOpenGesture = useExplorerOpenGesture({
enabled: isMobile && mobileView === "agent",

View File

@@ -25,7 +25,7 @@ import {
checkoutStatusQueryKey,
} from '@/hooks/use-checkout-status-query'
import { useAllAgentsList } from '@/hooks/use-all-agents-list'
import { useDaemonRegistry } from '@/contexts/daemon-registry-context'
import { useHosts } from '@/runtime/host-runtime'
import { buildBranchComboOptions, normalizeBranchOptionName } from '@/utils/branch-suggestions'
import { shortenPath } from '@/utils/shorten-path'
import { collectAgentWorkingDirectorySuggestions } from '@/utils/agent-working-directory-suggestions'
@@ -138,7 +138,7 @@ function DraftAgentScreenContent({
const { theme } = useUnistyles()
const router = useRouter()
const insets = useSafeAreaInsets()
const { daemons } = useDaemonRegistry()
const daemons = useHosts()
const runtime = getHostRuntimeStore()
const runtimeVersion = useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange),

View File

@@ -8,7 +8,7 @@ import { SidebarMenuToggle } from "@/components/headers/menu-header";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { usePanelStore } from "@/stores/panel-store";
import { getIsTauriMac } from "@/constants/layout";
import { useTrafficLightPadding } from "@/utils/tauri-window";
import { useTauriDragHandlers, useTrafficLightPadding } from "@/utils/tauri-window";
export function OpenProjectScreen({ serverId: _serverId }: { serverId: string }) {
const { theme } = useUnistyles();
@@ -21,6 +21,7 @@ export function OpenProjectScreen({ serverId: _serverId }: { serverId: string })
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const needsTrafficLightInset = !isMobile && !desktopAgentListOpen && getIsTauriMac();
const trafficLightInset = needsTrafficLightInset ? trafficLightPadding.left : 0;
const dragHandlers = useTauriDragHandlers();
useEffect(() => {
if (!isMobile) {
@@ -29,7 +30,7 @@ export function OpenProjectScreen({ serverId: _serverId }: { serverId: string })
}, [isMobile, openAgentList]);
return (
<View style={styles.container}>
<View style={styles.container} {...dragHandlers}>
<View style={[styles.menuToggle, { paddingTop: insets.top, paddingLeft: trafficLightInset }]}>
<SidebarMenuToggle />
</View>
@@ -56,6 +57,7 @@ const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
backgroundColor: theme.colors.surface0,
userSelect: "none",
},
menuToggle: {
position: "absolute",

View File

@@ -13,7 +13,8 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { Sun, Moon, Monitor, Globe, Settings, RotateCw, Trash2 } from "lucide-react-native";
import { useAppSettings, type AppSettings } from "@/hooks/use-settings";
import { useDaemonRegistry, type HostProfile, type HostConnection } from "@/contexts/daemon-registry-context";
import type { HostProfile, HostConnection } from "@/types/host-connection";
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons";
import { confirmDialog } from "@/utils/confirm-dialog";
import { MenuHeader } from "@/components/headers/menu-header";
@@ -487,12 +488,8 @@ export default function SettingsScreen() {
const routeServerId = typeof params.serverId === "string" ? params.serverId.trim() : "";
const { settings, isLoading: settingsLoading, updateSettings } = useAppSettings();
const {
daemons,
isLoading: daemonLoading,
updateHost,
removeHost,
removeConnection,
} = useDaemonRegistry();
daemons, renameHost, removeHost, removeConnection,
} = { daemons: useHosts(), ...useHostMutations() };
const [isAddHostMethodVisible, setIsAddHostMethodVisible] = useState(false);
const [isDirectHostVisible, setIsDirectHostVisible] = useState(false);
const [isPasteLinkVisible, setIsPasteLinkVisible] = useState(false);
@@ -503,7 +500,7 @@ export default function SettingsScreen() {
const [isRemovingHost, setIsRemovingHost] = useState(false);
const [editingDaemon, setEditingDaemon] = useState<HostProfile | null>(null);
const [isSavingEdit, setIsSavingEdit] = useState(false);
const isLoading = settingsLoading || daemonLoading;
const isLoading = settingsLoading;
const isMountedRef = useRef(true);
const lastHandledEditHostRef = useRef<string | null>(null);
const isDesktop = Platform.OS === "web";
@@ -615,7 +612,7 @@ export default function SettingsScreen() {
try {
setIsSavingEdit(true);
await updateHost(editingServerId, { label: nextLabel });
await renameHost(editingServerId, nextLabel);
handleCloseEditDaemon();
} catch (error) {
console.error("[Settings] Failed to rename host", error);
@@ -623,7 +620,7 @@ export default function SettingsScreen() {
} finally {
setIsSavingEdit(false);
}
}, [editingServerId, handleCloseEditDaemon, isSavingEdit, updateHost]);
}, [editingServerId, handleCloseEditDaemon, isSavingEdit, renameHost]);
const handleRemoveConnection = useCallback(
async (serverId: string, connectionId: string) => {
@@ -767,7 +764,7 @@ export default function SettingsScreen() {
hostname={pendingNameHostname}
onSkip={() => setPendingNameHost(null)}
onSave={(label) => {
void updateHost(pendingNameHost.serverId, { label }).finally(() => {
void renameHost(pendingNameHost.serverId, label).finally(() => {
setPendingNameHost(null);
});
}}

View File

@@ -1,5 +1,7 @@
import { Image, Text, View } from "react-native";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { PaseoLogo } from "@/components/icons/paseo-logo";
import { useTauriDragHandlers } from "@/utils/tauri-window";
const styles = StyleSheet.create((theme) => ({
container: {
@@ -8,25 +10,19 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
backgroundColor: theme.colors.surface0,
},
logo: {
width: 96,
height: 96,
marginBottom: theme.spacing[6],
},
status: {
marginTop: theme.spacing[8],
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
fontSize: theme.fontSize.lg,
},
}));
export function StartupSplashScreen() {
const dragHandlers = useTauriDragHandlers();
return (
<View style={styles.container}>
<Image
source={require("../../assets/images/icon.png")}
style={styles.logo}
resizeMode="contain"
/>
<View style={styles.container} {...dragHandlers}>
<PaseoLogo size={96} />
<Text style={styles.status}>Starting up</Text>
</View>
);

View File

@@ -5,7 +5,6 @@ import { AgentInputArea } from '@/components/agent-input-area'
import { FileDropZone } from '@/components/file-drop-zone'
import { AgentStreamView } from '@/components/agent-stream-view'
import type { ImageAttachment } from '@/components/message-input'
import { MAX_CONTENT_WIDTH } from '@/constants/layout'
import { useAgentFormState } from '@/hooks/use-agent-form-state'
import { useDraftAgentCreateFlow } from '@/hooks/use-draft-agent-create-flow'
import { useHostRuntimeSession } from '@/runtime/host-runtime'
@@ -260,8 +259,6 @@ const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
width: '100%',
alignSelf: 'center',
maxWidth: MAX_CONTENT_WIDTH,
backgroundColor: theme.colors.surface0,
},
contentContainer: {

View File

@@ -3,7 +3,7 @@ import { Platform } from "react-native";
import { File as FSFile, Paths } from "expo-file-system";
import * as LegacyFileSystem from "expo-file-system/legacy";
import * as Sharing from "expo-sharing";
import type { HostProfile } from "@/contexts/daemon-registry-context";
import type { HostProfile } from "@/types/host-connection";
import { buildDaemonWebSocketUrl } from "@/utils/daemon-endpoints";
import { openExternalUrl } from "@/utils/open-external-url";

View File

@@ -26,7 +26,6 @@ import type {
WorkspaceDescriptorPayload,
} from "@server/shared/messages";
import { normalizeWorkspaceIdentity } from "@/utils/workspace-identity";
import { isPerfLoggingEnabled, measurePayload, perfLog } from "@/utils/perf";
import {
createAgentLastActivityCoalescer,
type AgentLastActivityCommitter,
@@ -357,30 +356,8 @@ interface SessionStoreActions {
type SessionStore = SessionStoreState & SessionStoreActions;
const SESSION_STORE_LOG_TAG = "[SessionStore]";
let sessionStoreUpdateCount = 0;
const agentLastActivityCoalescer = createAgentLastActivityCoalescer();
function logSessionStoreUpdate(
type: string,
serverId: string,
payload?: unknown
) {
if (!isPerfLoggingEnabled()) {
return;
}
sessionStoreUpdateCount += 1;
const metrics = payload ? measurePayload(payload) : null;
perfLog(SESSION_STORE_LOG_TAG, {
event: type,
serverId,
updateCount: sessionStoreUpdateCount,
payloadApproxBytes: metrics?.approxBytes ?? 0,
payloadFieldCount: metrics?.fieldCount ?? 0,
timestamp: Date.now(),
});
}
// Helper to create initial session state
function createInitialSessionState(serverId: string, client: DaemonClient, audioPlayer: ReturnType<typeof useAudioPlayer>): SessionState {
return {
@@ -452,7 +429,6 @@ export const useSessionStore = create<SessionStore>()(
if (prev.sessions[serverId]) {
return prev;
}
logSessionStoreUpdate("initializeSession", serverId);
return {
...prev,
sessions: {
@@ -470,7 +446,6 @@ export const useSessionStore = create<SessionStore>()(
if (!session) {
return prev;
}
logSessionStoreUpdate("clearSession", serverId);
const nextSessions = { ...prev.sessions };
delete nextSessions[serverId];
let nextActivity = prev.agentLastActivity;
@@ -507,12 +482,6 @@ export const useSessionStore = create<SessionStore>()(
return prev;
}
logSessionStoreUpdate("updateSessionClient", serverId, {
wasNull: session.client === null,
isNowConnected: client.isConnected,
isNowConnecting: client.isConnecting,
});
return {
...prev,
sessions: {
@@ -549,12 +518,6 @@ export const useSessionStore = create<SessionStore>()(
return prev;
}
logSessionStoreUpdate("updateSessionServerInfo", serverId, {
serverId: info.serverId,
hostname: nextHostname,
version: nextVersion,
});
return {
...prev,
sessions: {
@@ -584,7 +547,6 @@ export const useSessionStore = create<SessionStore>()(
if (!session || session.isPlayingAudio === playing) {
return prev;
}
logSessionStoreUpdate("setIsPlayingAudio", serverId, { playing });
return {
...prev,
sessions: {
@@ -602,8 +564,6 @@ export const useSessionStore = create<SessionStore>()(
if (!session || session.focusedAgentId === agentId) {
return prev;
}
logSessionStoreUpdate("setFocusedAgentId", serverId, { agentId });
return {
...prev,
sessions: {
@@ -629,7 +589,6 @@ export const useSessionStore = create<SessionStore>()(
if (session.messages === nextMessages) {
return prev;
}
logSessionStoreUpdate("setMessages", serverId, { count: nextMessages.length });
return {
...prev,
sessions: {
@@ -650,7 +609,6 @@ export const useSessionStore = create<SessionStore>()(
if (session.currentAssistantMessage === nextMessage) {
return prev;
}
logSessionStoreUpdate("setCurrentAssistantMessage", serverId, { length: nextMessage.length });
return {
...prev,
sessions: {
@@ -672,7 +630,6 @@ export const useSessionStore = create<SessionStore>()(
if (session.agentStreamTail === nextState) {
return prev;
}
logSessionStoreUpdate("setAgentStreamTail", serverId, { agentCount: nextState.size });
return {
...prev,
sessions: {
@@ -693,7 +650,6 @@ export const useSessionStore = create<SessionStore>()(
if (session.agentStreamHead === nextState) {
return prev;
}
logSessionStoreUpdate("setAgentStreamHead", serverId, { agentCount: nextState.size });
return {
...prev,
sessions: {
@@ -745,12 +701,6 @@ export const useSessionStore = create<SessionStore>()(
return prev;
}
logSessionStoreUpdate("setAgentStreamState", serverId, {
agentId,
changedTail,
changedHead,
});
return {
...prev,
sessions: {
@@ -776,7 +726,6 @@ export const useSessionStore = create<SessionStore>()(
}
const nextHead = new Map(session.agentStreamHead);
nextHead.delete(agentId);
logSessionStoreUpdate("clearAgentStreamHead", serverId, { agentId });
return {
...prev,
sessions: {
@@ -798,9 +747,6 @@ export const useSessionStore = create<SessionStore>()(
if (session.agentTimelineCursor === nextState) {
return prev;
}
logSessionStoreUpdate("setAgentTimelineCursor", serverId, {
agentCount: nextState.size,
});
return {
...prev,
sessions: {
@@ -818,9 +764,6 @@ export const useSessionStore = create<SessionStore>()(
return prev;
}
const nextGeneration = session.historySyncGeneration + 1;
logSessionStoreUpdate("bumpHistorySyncGeneration", serverId, {
generation: nextGeneration,
});
return {
...prev,
sessions: {
@@ -847,10 +790,6 @@ export const useSessionStore = create<SessionStore>()(
}
const nextMap = new Map(session.agentHistorySyncGeneration);
nextMap.set(agentId, currentGeneration);
logSessionStoreUpdate("markAgentHistorySynchronized", serverId, {
agentId,
generation: currentGeneration,
});
return {
...prev,
sessions: {
@@ -884,11 +823,6 @@ export const useSessionStore = create<SessionStore>()(
nextApplied.delete(agentId);
}
logSessionStoreUpdate("setAgentAuthoritativeHistoryApplied", serverId, {
agentId,
applied,
});
return {
...prev,
sessions: {
@@ -913,7 +847,6 @@ export const useSessionStore = create<SessionStore>()(
if (session.initializingAgents === nextState) {
return prev;
}
logSessionStoreUpdate("setInitializingAgents", serverId, { count: nextState.size });
return {
...prev,
sessions: {
@@ -935,7 +868,6 @@ export const useSessionStore = create<SessionStore>()(
if (session.agents === nextAgents) {
return prev;
}
logSessionStoreUpdate("setAgents", serverId, { count: nextAgents.size });
return {
...prev,
sessions: {
@@ -957,7 +889,6 @@ export const useSessionStore = create<SessionStore>()(
if (session.workspaces === nextWorkspaces) {
return prev;
}
logSessionStoreUpdate("setWorkspaces", serverId, { count: nextWorkspaces.size });
return {
...prev,
sessions: {
@@ -988,7 +919,6 @@ export const useSessionStore = create<SessionStore>()(
if (!changed) {
return prev;
}
logSessionStoreUpdate("mergeWorkspaces", serverId, { count: nextEntries.length });
return {
...prev,
sessions: {
@@ -1007,7 +937,6 @@ export const useSessionStore = create<SessionStore>()(
}
const next = new Map(session.workspaces);
next.delete(workspaceId);
logSessionStoreUpdate("removeWorkspace", serverId, { workspaceId });
return {
...prev,
sessions: {
@@ -1074,7 +1003,6 @@ export const useSessionStore = create<SessionStore>()(
if (session.pendingPermissions === nextPerms) {
return prev;
}
logSessionStoreUpdate("setPendingPermissions", serverId, { count: nextPerms.size });
return {
...prev,
sessions: {
@@ -1096,7 +1024,6 @@ export const useSessionStore = create<SessionStore>()(
if (session.fileExplorer === nextState) {
return prev;
}
logSessionStoreUpdate("setFileExplorer", serverId, { agentCount: nextState.size });
return {
...prev,
sessions: {
@@ -1118,7 +1045,6 @@ export const useSessionStore = create<SessionStore>()(
if (session.queuedMessages === nextValue) {
return prev;
}
logSessionStoreUpdate("setQueuedMessages", serverId, { agentCount: nextValue.size });
return {
...prev,
sessions: {
@@ -1136,7 +1062,6 @@ export const useSessionStore = create<SessionStore>()(
if (!session || session.hasHydratedAgents === hydrated) {
return prev;
}
logSessionStoreUpdate("setHasHydratedAgents", serverId, { hydrated });
return {
...prev,
sessions: {
@@ -1153,7 +1078,6 @@ export const useSessionStore = create<SessionStore>()(
if (!session || session.hasHydratedWorkspaces === hydrated) {
return prev;
}
logSessionStoreUpdate("setHasHydratedWorkspaces", serverId, { hydrated });
return {
...prev,
sessions: {

View File

@@ -0,0 +1,303 @@
import { normalizeHostPort } from '@server/shared/daemon-endpoints'
export type DirectTcpHostConnection = {
id: string
type: 'directTcp'
endpoint: string
}
export type DirectSocketHostConnection = {
id: string
type: 'directSocket'
path: string
}
export type DirectPipeHostConnection = {
id: string
type: 'directPipe'
path: string
}
export type RelayHostConnection = {
id: string
type: 'relay'
relayEndpoint: string
daemonPublicKeyB64: string
}
export type HostConnection =
| DirectTcpHostConnection
| DirectSocketHostConnection
| DirectPipeHostConnection
| RelayHostConnection
export type HostLifecycle = Record<string, never>
export type HostProfile = {
serverId: string
label: string
lifecycle: HostLifecycle
connections: HostConnection[]
preferredConnectionId: string | null
createdAt: string
updatedAt: string
}
export function defaultLifecycle(): HostLifecycle {
return {}
}
export function normalizeHostLabel(value: string | null | undefined, serverId: string): string {
const trimmed = value?.trim() ?? ''
return trimmed.length > 0 ? trimmed : serverId
}
function hostConnectionEquals(left: HostConnection, right: HostConnection): boolean {
if (left.type !== right.type || left.id !== right.id) {
return false
}
if (left.type === 'directTcp' && right.type === 'directTcp') {
return left.endpoint === right.endpoint
}
if (left.type === 'directSocket' && right.type === 'directSocket') {
return left.path === right.path
}
if (left.type === 'directPipe' && right.type === 'directPipe') {
return left.path === right.path
}
if (left.type === 'relay' && right.type === 'relay') {
return (
left.relayEndpoint === right.relayEndpoint &&
left.daemonPublicKeyB64 === right.daemonPublicKeyB64
)
}
return false
}
function hostLifecycleEquals(left: HostLifecycle, right: HostLifecycle): boolean {
return JSON.stringify(left) === JSON.stringify(right)
}
export function upsertHostConnectionInProfiles(input: {
profiles: HostProfile[]
serverId: string
label?: string
connection: HostConnection
now?: string
}): HostProfile[] {
const serverId = input.serverId.trim()
if (!serverId) {
throw new Error('serverId is required')
}
const now = input.now ?? new Date().toISOString()
const labelTrimmed = input.label?.trim() ?? ''
const derivedLabel = labelTrimmed || serverId
const existing = input.profiles
const idx = existing.findIndex((daemon) => daemon.serverId === serverId)
if (idx === -1) {
const profile: HostProfile = {
serverId,
label: derivedLabel,
lifecycle: defaultLifecycle(),
connections: [input.connection],
preferredConnectionId: input.connection.id,
createdAt: now,
updatedAt: now,
}
return [...existing, profile]
}
const prev = existing[idx]!
const connectionIdx = prev.connections.findIndex((connection) => connection.id === input.connection.id)
const hadConnection = connectionIdx !== -1
const connectionChanged =
connectionIdx === -1
? true
: !hostConnectionEquals(prev.connections[connectionIdx]!, input.connection)
const nextConnections =
connectionIdx === -1
? [...prev.connections, input.connection]
: connectionChanged
? prev.connections.map((connection, index) =>
index === connectionIdx ? input.connection : connection
)
: prev.connections
const nextLifecycle = prev.lifecycle
const nextLabel = labelTrimmed ? labelTrimmed : prev.label
const nextPreferredConnectionId = prev.preferredConnectionId ?? input.connection.id
const changed =
nextLabel !== prev.label ||
nextPreferredConnectionId !== prev.preferredConnectionId ||
!hostLifecycleEquals(prev.lifecycle, nextLifecycle) ||
!hadConnection ||
connectionChanged
if (!changed) {
return existing
}
const nextProfile: HostProfile = {
...prev,
label: nextLabel,
lifecycle: nextLifecycle,
connections: nextConnections,
preferredConnectionId: nextPreferredConnectionId,
updatedAt: now,
}
const next = [...existing]
next[idx] = nextProfile
return next
}
export function connectionFromListen(listen: string): HostConnection | null {
const normalizedListen = listen.trim()
if (!normalizedListen) {
return null
}
if (normalizedListen.startsWith('pipe://')) {
const path = normalizedListen.slice('pipe://'.length).trim()
return path ? { id: `pipe:${path}`, type: 'directPipe', path } : null
}
if (normalizedListen.startsWith('unix://')) {
const path = normalizedListen.slice('unix://'.length).trim()
return path ? { id: `socket:${path}`, type: 'directSocket', path } : null
}
if (normalizedListen.startsWith('\\\\.\\pipe\\')) {
return {
id: `pipe:${normalizedListen}`,
type: 'directPipe',
path: normalizedListen,
}
}
if (normalizedListen.startsWith('/')) {
return {
id: `socket:${normalizedListen}`,
type: 'directSocket',
path: normalizedListen,
}
}
try {
const endpoint = normalizeHostPort(normalizedListen)
return {
id: `direct:${endpoint}`,
type: 'directTcp',
endpoint,
}
} catch {
return null
}
}
function normalizeStoredConnection(connection: unknown): HostConnection | null {
if (!connection || typeof connection !== 'object') {
return null
}
const record = connection as Record<string, unknown>
const type = typeof record.type === 'string' ? record.type : null
if (type === 'directTcp') {
try {
const endpoint = normalizeHostPort(String(record.endpoint ?? ''))
return { id: `direct:${endpoint}`, type: 'directTcp', endpoint }
} catch {
return null
}
}
if (type === 'directSocket') {
const path = String(record.path ?? '').trim()
return path ? { id: `socket:${path}`, type: 'directSocket', path } : null
}
if (type === 'directPipe') {
const path = String(record.path ?? '').trim()
return path ? { id: `pipe:${path}`, type: 'directPipe', path } : null
}
if (type === 'relay') {
try {
const relayEndpoint = normalizeHostPort(String(record.relayEndpoint ?? ''))
const daemonPublicKeyB64 = String(record.daemonPublicKeyB64 ?? '').trim()
if (!daemonPublicKeyB64) return null
return {
id: `relay:${relayEndpoint}`,
type: 'relay',
relayEndpoint,
daemonPublicKeyB64,
}
} catch {
return null
}
}
return null
}
export function normalizeStoredHostProfile(entry: unknown): HostProfile | null {
if (!entry || typeof entry !== 'object') {
return null
}
const record = entry as Record<string, unknown>
const serverId = typeof record.serverId === 'string' ? record.serverId.trim() : ''
if (!serverId) {
return null
}
const rawConnections = Array.isArray(record.connections) ? record.connections : []
const connections = rawConnections
.map((connection) => normalizeStoredConnection(connection))
.filter((connection): connection is HostConnection => connection !== null)
if (connections.length === 0) {
return null
}
const now = new Date().toISOString()
const label = normalizeHostLabel(
typeof record.label === 'string' ? record.label : null,
serverId
)
const preferredConnectionId =
typeof record.preferredConnectionId === 'string' &&
connections.some((connection) => connection.id === record.preferredConnectionId)
? record.preferredConnectionId
: connections[0]?.id ?? null
return {
serverId,
label,
lifecycle: defaultLifecycle(),
connections,
preferredConnectionId,
createdAt: typeof record.createdAt === 'string' ? record.createdAt : now,
updatedAt: typeof record.updatedAt === 'string' ? record.updatedAt : now,
}
}
export function normalizeEndpointOrNull(endpoint: string): string | null {
try {
return normalizeHostPort(endpoint)
} catch {
return null
}
}
export function hostHasDirectEndpoint(host: HostProfile, endpoint: string): boolean {
const normalized = normalizeEndpointOrNull(endpoint)
if (!normalized) {
return false
}
return host.connections.some(
(connection) => connection.type === 'directTcp' && connection.endpoint === normalized
)
}
export function registryHasDirectEndpoint(hosts: HostProfile[], endpoint: string): boolean {
return hosts.some((host) => hostHasDirectEndpoint(host, endpoint))
}

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { type HostConnection } from "@/contexts/daemon-registry-context";
import type { HostConnection } from "@/types/host-connection";
import {
selectBestConnection,
type ConnectionCandidate,

View File

@@ -1,4 +1,4 @@
import type { HostConnection } from "@/contexts/daemon-registry-context";
import type { HostConnection } from "@/types/host-connection";
export type ConnectionCandidate = {
connectionId: string;

View File

@@ -1,80 +0,0 @@
import { getNowMs, isPerfLoggingEnabled, perfLog } from "@/utils/perf";
type NavigationTimingDetails = {
from: string;
to: string;
params?: Record<string, unknown>;
targetMs?: number;
};
type NavigationTimingEntry = NavigationTimingDetails & {
startedAt: number;
};
const NAVIGATION_TAG = "[NavigationTiming]";
const pendingNavigations = new Map<string, NavigationTimingEntry>();
export const HOME_NAVIGATION_KEY = "home";
export const buildAgentNavigationKey = (serverId: string, agentId: string) =>
`agent:${serverId}:${agentId}`;
export const startNavigationTiming = (key: string, details: NavigationTimingDetails): void => {
if (!isPerfLoggingEnabled()) {
return;
}
pendingNavigations.set(key, {
...details,
startedAt: getNowMs(),
});
perfLog(NAVIGATION_TAG, {
phase: "start",
key,
from: details.from,
to: details.to,
targetMs: details.targetMs ?? null,
params: details.params ?? null,
});
};
export const endNavigationTiming = (key: string, extra?: Record<string, unknown>): void => {
if (!isPerfLoggingEnabled()) {
return;
}
const entry = pendingNavigations.get(key);
if (!entry) {
return;
}
pendingNavigations.delete(key);
const durationMs = getNowMs() - entry.startedAt;
perfLog(NAVIGATION_TAG, {
phase: "complete",
key,
from: entry.from,
to: entry.to,
durationMs: Number(durationMs.toFixed(2)),
targetMs: entry.targetMs ?? null,
params: entry.params ?? null,
extra: extra ?? null,
});
};
export const cancelNavigationTiming = (key: string, reason?: string): void => {
if (!isPerfLoggingEnabled()) {
return;
}
if (!pendingNavigations.has(key)) {
return;
}
pendingNavigations.delete(key);
perfLog(NAVIGATION_TAG, {
phase: "cancelled",
key,
reason: reason ?? null,
});
};

View File

@@ -1,20 +0,0 @@
// Compatibility shim while callers move to `@/runtime/perf-diagnostics`.
// Keep this file side-effect free.
export {
consumePersistedPerfDiagnosticReports,
getPerfDiagnosticBreadcrumbs,
getPerfDiagnosticsSnapshot,
installPerfDiagnosticsMonitor,
isPerfDiagnosticsEnabled,
peekPersistedPerfDiagnosticReports,
recordPerfDiagnosticMark,
} from "@/runtime/perf-diagnostics";
export type {
PerfDiagnosticBreadcrumb,
PerfDiagnosticFields,
PerfDiagnosticsReport,
PerfDiagnosticsSnapshot,
PerfDiagnosticsReporter,
RecordPerfDiagnosticMarkOptions,
} from "@/runtime/perf-diagnostics";

View File

@@ -1,113 +0,0 @@
import { getNowMs, isPerfLoggingEnabled, perfLog } from "@/utils/perf";
const PERF_MONITOR_LOG_TAG = "[PerfMonitor]";
const FRAME_GAP_THRESHOLD_MS = 100;
const EVENT_LOOP_STALL_THRESHOLD_MS = 100;
const EVENT_LOOP_TICK_MS = 100;
const LONG_TASK_THRESHOLD_MS = 100;
type StopPerfMonitor = () => void;
type GlobalScope = typeof globalThis & {
requestAnimationFrame?: (callback: (timestamp: number) => void) => number;
cancelAnimationFrame?: (handle: number) => void;
PerformanceObserver?: unknown;
};
type PerfObserverEntry = {
duration: number;
startTime: number;
};
type PerfObserverLike = {
observe: (options: { entryTypes: string[] }) => void;
disconnect: () => void;
};
type PerfObserverConstructor = new (
callback: (list: { getEntries(): PerfObserverEntry[] }) => void
) => PerfObserverLike;
export function startPerfMonitor(scope: string): StopPerfMonitor {
if (!isPerfLoggingEnabled()) {
return () => {};
}
const globalScope = globalThis as GlobalScope;
if (!globalScope) {
return () => {};
}
const startMs = getNowMs();
perfLog(PERF_MONITOR_LOG_TAG, { event: "monitor_start", scope });
let rafHandle: number | null = null;
let lastFrameMs = getNowMs();
if (typeof globalScope.requestAnimationFrame === "function") {
const onFrame = (now: number) => {
const delta = now - lastFrameMs;
if (delta >= FRAME_GAP_THRESHOLD_MS) {
perfLog(PERF_MONITOR_LOG_TAG, {
event: "frame_gap",
scope,
deltaMs: Math.round(delta),
sinceStartMs: Math.round(now - startMs),
});
}
lastFrameMs = now;
rafHandle = globalScope.requestAnimationFrame?.(onFrame) ?? null;
};
rafHandle = globalScope.requestAnimationFrame(onFrame);
}
let lastTickMs = getNowMs();
const intervalHandle = setInterval(() => {
const now = getNowMs();
const drift = now - lastTickMs - EVENT_LOOP_TICK_MS;
if (drift >= EVENT_LOOP_STALL_THRESHOLD_MS) {
perfLog(PERF_MONITOR_LOG_TAG, {
event: "event_loop_stall",
scope,
driftMs: Math.round(drift),
sinceStartMs: Math.round(now - startMs),
});
}
lastTickMs = now;
}, EVENT_LOOP_TICK_MS);
let observer: PerfObserverLike | null = null;
const ObserverCtor = globalScope.PerformanceObserver as PerfObserverConstructor | undefined;
if (typeof ObserverCtor === "function") {
try {
observer = new ObserverCtor((list) => {
for (const entry of list.getEntries()) {
if (entry.duration >= LONG_TASK_THRESHOLD_MS) {
perfLog(PERF_MONITOR_LOG_TAG, {
event: "longtask",
scope,
durationMs: Math.round(entry.duration),
startMs: Math.round(entry.startTime),
});
}
}
});
observer.observe({ entryTypes: ["longtask"] });
} catch (error) {
perfLog(PERF_MONITOR_LOG_TAG, {
event: "longtask_observer_error",
scope,
message: error instanceof Error ? error.message : String(error),
});
}
}
return () => {
if (rafHandle !== null && typeof globalScope.cancelAnimationFrame === "function") {
globalScope.cancelAnimationFrame(rafHandle);
}
clearInterval(intervalHandle);
observer?.disconnect();
perfLog(PERF_MONITOR_LOG_TAG, { event: "monitor_stop", scope });
};
}

View File

@@ -1,105 +0,0 @@
const PERF_LOGGING_ENABLED = false;
export const isPerfLoggingEnabled = (): boolean => PERF_LOGGING_ENABLED;
export const perfLog = (tag: string, details: Record<string, unknown>): void => {
if (!PERF_LOGGING_ENABLED) {
return;
}
console.info(tag, details);
};
export const getNowMs = (): number => {
if (typeof performance !== "undefined" && typeof performance.now === "function") {
return performance.now();
}
return Date.now();
};
export interface PayloadMetrics {
approxBytes: number;
fieldCount: number;
}
const MEASUREMENT_DEPTH = 2;
export const measurePayload = (payload: unknown): PayloadMetrics => {
if (!payload || typeof payload !== "object") {
return {
approxBytes: 0,
fieldCount: 0,
};
}
const fieldCount = Object.keys(payload as Record<string, unknown>).length;
const approxBytes = estimateSize(payload, MEASUREMENT_DEPTH, new WeakSet());
return {
approxBytes,
fieldCount,
};
};
const estimateSize = (value: unknown, depth: number, seen: WeakSet<object>): number => {
if (depth <= 0 || value === null || value === undefined) {
return 0;
}
if (typeof value === "string") {
return value.length;
}
if (typeof value === "number") {
return 8;
}
if (typeof value === "boolean") {
return 4;
}
if (typeof value === "bigint") {
return value.toString().length;
}
if (typeof value === "symbol" || typeof value === "function") {
return 0;
}
if (typeof value !== "object") {
return 0;
}
const objectValue = value as object;
if (seen.has(objectValue)) {
return 0;
}
seen.add(objectValue);
if (Array.isArray(value)) {
return value.reduce((total, item) => total + estimateSize(item, depth - 1, seen), 0);
}
if (value instanceof Map) {
let total = 0;
for (const [mapKey, mapValue] of value.entries()) {
total += estimateSize(mapKey, depth - 1, seen);
total += estimateSize(mapValue, depth - 1, seen);
}
return total;
}
if (value instanceof Set) {
let total = 0;
for (const setValue of value.values()) {
total += estimateSize(setValue, depth - 1, seen);
}
return total;
}
let total = 0;
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
total += key.length;
total += estimateSize(child, depth - 1, seen);
}
return total;
};

View File

@@ -0,0 +1,55 @@
import { getTauri } from "@/utils/tauri";
let attached = false;
export function attachConsole(): void {
if (attached || !getTauri()) {
return;
}
attached = true;
const originalLog = console.log;
const originalInfo = console.info;
const originalWarn = console.warn;
const originalError = console.error;
const originalDebug = console.debug;
function formatArgs(args: unknown[]): string {
return args
.map((a) =>
typeof a === "string" ? a : JSON.stringify(a, null, 0) ?? String(a)
)
.join(" ");
}
function forwardToRust(level: number, args: unknown[]): void {
const invoke = getTauri()?.core?.invoke;
if (typeof invoke !== "function") return;
try {
void invoke("webview_log", { level, message: formatArgs(args) });
} catch {
// silently ignore IPC errors
}
}
console.log = (...args: unknown[]) => {
originalLog.apply(console, args);
forwardToRust(0, args); // debug
};
console.info = (...args: unknown[]) => {
originalInfo.apply(console, args);
forwardToRust(1, args); // info
};
console.warn = (...args: unknown[]) => {
originalWarn.apply(console, args);
forwardToRust(2, args); // warn
};
console.error = (...args: unknown[]) => {
originalError.apply(console, args);
forwardToRust(3, args); // error
};
console.debug = (...args: unknown[]) => {
originalDebug.apply(console, args);
forwardToRust(0, args); // debug
};
}

View File

@@ -5,12 +5,11 @@ const daemonClientMock = vi.hoisted(() => {
class MockDaemonClient {
public lastError: string | null = null;
private lastWelcome = {
type: "welcome" as const,
private lastServerInfo = {
status: "server_info" as const,
serverId: "srv_probe_test",
hostname: "probe-host" as string | null,
version: "0.0.0",
resumed: false,
};
constructor(config: { clientId?: string; url?: string }) {
@@ -29,8 +28,8 @@ const daemonClientMock = vi.hoisted(() => {
return;
}
getLastWelcomeMessage() {
return this.lastWelcome;
getLastServerInfoMessage() {
return this.lastServerInfo;
}
async ping(): Promise<{ rttMs: number }> {
@@ -60,40 +59,44 @@ vi.mock("./client-id", () => ({
getOrCreateClientId: clientIdMock.getOrCreateClientId,
}));
describe("test-daemon-connection probe client identity", () => {
describe("test-daemon-connection connectToDaemon", () => {
beforeEach(() => {
daemonClientMock.createdConfigs.length = 0;
clientIdMock.getOrCreateClientId.mockClear();
});
it("reuses the app clientId for direct latency probes", async () => {
it("reuses the app clientId for direct connections", async () => {
const mod = await import("./test-daemon-connection");
await mod.measureConnectionLatency({
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
});
await mod.measureConnectionLatency({
const first = await mod.connectToDaemon({
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
});
await first.client.close();
const [first, second] = daemonClientMock.createdConfigs;
expect(first?.clientId).toBe("cid_shared_probe_test");
expect(second?.clientId).toBe("cid_shared_probe_test");
const second = await mod.connectToDaemon({
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
});
await second.client.close();
const [firstConfig, secondConfig] = daemonClientMock.createdConfigs;
expect(firstConfig?.clientId).toBe("cid_shared_probe_test");
expect(secondConfig?.clientId).toBe("cid_shared_probe_test");
expect(clientIdMock.getOrCreateClientId).toHaveBeenCalledTimes(2);
});
it("encodes the local socket target into the probe client config", async () => {
it("encodes the local socket target into the client config", async () => {
const mod = await import("./test-daemon-connection");
await mod.measureConnectionLatency({
const result = await mod.connectToDaemon({
id: "socket:/tmp/paseo.sock",
type: "directSocket",
path: "/tmp/paseo.sock",
});
await result.client.close();
expect(daemonClientMock.createdConfigs[0]?.url).toBe(
"paseo+local://socket?path=%2Ftmp%2Fpaseo.sock"

View File

@@ -1,6 +1,6 @@
import { DaemonClient } from "@server/client/daemon-client";
import type { DaemonClientConfig } from "@server/client/daemon-client";
import type { HostConnection } from "@/contexts/daemon-registry-context";
import type { HostConnection } from "@/types/host-connection";
import { getOrCreateClientId } from "./client-id";
import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "./daemon-endpoints";
import {
@@ -45,7 +45,7 @@ export class DaemonConnectionTestError extends Error {
}
}
async function buildClientConfig(
export async function buildClientConfig(
connection: HostConnection,
serverId?: string
): Promise<DaemonClientConfig> {
@@ -56,6 +56,7 @@ async function buildClientConfig(
clientId,
clientType: "mobile" as const,
suppressSendErrors: true,
reconnect: { enabled: false },
...(connection.type === "directSocket" || connection.type === "directPipe"
? localTransportFactory
? { transportFactory: localTransportFactory }
@@ -96,7 +97,7 @@ async function buildClientConfig(
};
}
function connectAndProbe(
export function connectAndProbe(
config: DaemonClientConfig,
timeoutMs: number,
): Promise<{ client: DaemonClient; serverId: string; hostname: string | null }> {
@@ -115,12 +116,12 @@ function connectAndProbe(
void client.connect().then(() => {
clearTimeout(timer);
const welcome = client.getLastWelcomeMessage();
if (!welcome) {
const serverInfo = client.getLastServerInfoMessage();
if (!serverInfo) {
void client.close().catch(() => undefined);
reject(
new DaemonConnectionTestError("Missing welcome message", {
reason: "Missing welcome message",
new DaemonConnectionTestError("Missing server info message", {
reason: "Missing server info message",
lastError: client.lastError ?? null,
})
);
@@ -128,8 +129,8 @@ function connectAndProbe(
}
resolve({
client,
serverId: welcome.serverId,
hostname: welcome.hostname,
serverId: serverInfo.serverId,
hostname: serverInfo.hostname,
});
}).catch((error) => {
clearTimeout(timer);
@@ -152,26 +153,10 @@ function resolveTimeout(connection: HostConnection, options?: ProbeOptions): num
return connection.type === "relay" ? 10_000 : 6_000;
}
export async function probeConnection(
export async function connectToDaemon(
connection: HostConnection,
options?: ProbeOptions,
): Promise<{ serverId: string; hostname: string | null }> {
): Promise<{ client: DaemonClient; serverId: string; hostname: string | null }> {
const config = await buildClientConfig(connection, options?.serverId);
const { client, serverId, hostname } = await connectAndProbe(config, resolveTimeout(connection, options));
await client.close().catch(() => undefined);
return { serverId, hostname };
}
export async function measureConnectionLatency(
connection: HostConnection,
options?: ProbeOptions,
): Promise<number> {
const config = await buildClientConfig(connection, options?.serverId);
const { client } = await connectAndProbe(config, resolveTimeout(connection, options));
try {
const { rttMs } = await client.ping({ timeoutMs: 5000 });
return rttMs;
} finally {
await client.close().catch(() => undefined);
}
return connectAndProbe(config, resolveTimeout(connection, options));
}

View File

@@ -1,11 +1,4 @@
import { z } from "zod";
import { getNowMs, isPerfLoggingEnabled, perfLog } from "./perf";
const TOOL_CALL_DIFF_LOG_TAG = "[ToolCallDiff]";
const LINE_DIFF_DURATION_THRESHOLD_MS = 16;
const WORD_DIFF_DURATION_THRESHOLD_MS = 8;
const LINE_DIFF_MATRIX_THRESHOLD = 200000;
const WORD_DIFF_MATRIX_THRESHOLD = 50000;
export type DiffSegment = {
text: string;
@@ -56,14 +49,11 @@ function splitIntoWords(text: string): string[] {
}
function computeWordLevelDiff(oldLine: string, newLine: string): { oldSegments: DiffSegment[]; newSegments: DiffSegment[] } {
const shouldLog = isPerfLoggingEnabled();
const startMs = shouldLog ? getNowMs() : 0;
const oldWords = splitIntoWords(oldLine);
const newWords = splitIntoWords(newLine);
const m = oldWords.length;
const n = newWords.length;
const matrixSize = m * n;
// LCS to find common words
const dp: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
@@ -131,22 +121,6 @@ function computeWordLevelDiff(oldLine: string, newLine: string): { oldSegments:
const oldSegments = buildSegments(oldWords, oldInLCS);
const newSegments = buildSegments(newWords, newInLCS);
if (shouldLog) {
const durationMs = getNowMs() - startMs;
if (
durationMs >= WORD_DIFF_DURATION_THRESHOLD_MS ||
matrixSize >= WORD_DIFF_MATRIX_THRESHOLD
) {
perfLog(TOOL_CALL_DIFF_LOG_TAG, {
event: "word_diff",
durationMs: Math.round(durationMs),
oldWordCount: m,
newWordCount: n,
matrixSize,
});
}
}
return {
oldSegments,
newSegments,
@@ -154,8 +128,6 @@ function computeWordLevelDiff(oldLine: string, newLine: string): { oldSegments:
}
export function buildLineDiff(originalText: string, updatedText: string): DiffLine[] {
const shouldLog = isPerfLoggingEnabled();
const startMs = shouldLog ? getNowMs() : 0;
const originalLines = splitIntoLines(originalText);
const updatedLines = splitIntoLines(updatedText);
@@ -166,7 +138,6 @@ export function buildLineDiff(originalText: string, updatedText: string): DiffLi
const m = originalLines.length;
const n = updatedLines.length;
const matrixSize = m * n;
const dp: number[][] = Array.from({ length: m + 1 }, () =>
Array(n + 1).fill(0)
);
@@ -225,23 +196,6 @@ export function buildLineDiff(originalText: string, updatedText: string): DiffLi
}
}
if (shouldLog) {
const durationMs = getNowMs() - startMs;
if (
durationMs >= LINE_DIFF_DURATION_THRESHOLD_MS ||
matrixSize >= LINE_DIFF_MATRIX_THRESHOLD
) {
perfLog(TOOL_CALL_DIFF_LOG_TAG, {
event: "line_diff",
durationMs: Math.round(durationMs),
originalLineCount: m,
updatedLineCount: n,
diffLineCount: diff.length,
matrixSize,
});
}
}
return diff;
}

View File

@@ -1,6 +1,9 @@
import { defineConfig, configDefaults } from "vitest/config";
import path from "path";
const appNodeModules = path.resolve(__dirname, "node_modules");
const rootNodeModules = path.resolve(__dirname, "../../node_modules");
export default defineConfig({
test: {
environment: "node",
@@ -12,12 +15,27 @@ export default defineConfig({
* keeps `process.send` intact so the app tests can boot before hitting the intentional failures.
*/
pool: "forks",
server: {
deps: {
fallbackCJS: true,
inline: [
"zustand",
"@tanstack/react-query",
"react-native-web",
],
},
},
},
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
"@server": path.resolve(__dirname, "../server/src"),
"react-native": "react-native-web",
// Point to the ESM build so Vite can transform its imports and apply the
// react alias below (the CJS build uses require('react') which bypasses
// Vite alias resolution).
"react-native": path.resolve(rootNodeModules, "react-native-web/dist/index.js"),
react: path.resolve(appNodeModules, "react"),
"react-dom": path.resolve(appNodeModules, "react-dom"),
},
},
});

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.24",
"version": "0.1.26",
"description": "Paseo CLI - control your AI coding agents from the command line",
"type": "module",
"files": [
@@ -24,8 +24,8 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.24",
"@getpaseo/server": "0.1.24",
"@getpaseo/relay": "0.1.26",
"@getpaseo/server": "0.1.26",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",

View File

@@ -9,7 +9,6 @@ import { createWorktreeCommand } from './commands/worktree/index.js'
import { startCommand as daemonStartCommand } from './commands/daemon/start.js'
import { runStatusCommand as runDaemonStatusCommand } from './commands/daemon/status.js'
import { runRestartCommand as runDaemonRestartCommand } from './commands/daemon/restart.js'
import { runDaemonUpdateCommandOrExit } from './commands/daemon/update.js'
import { runLsCommand } from './commands/agent/ls.js'
import { runRunCommand } from './commands/agent/run.js'
import { runLogsCommand } from './commands/agent/logs.js'
@@ -21,6 +20,12 @@ import { runWaitCommand } from './commands/agent/wait.js'
import { runAttachCommand } from './commands/agent/attach.js'
import { withOutput } from './output/index.js'
import { onboardCommand } from './commands/onboard.js'
import {
addDaemonHostOption,
addJsonAndDaemonHostOptions,
addJsonOption,
collectMultiple,
} from './utils/command-options.js'
const require = createRequire(import.meta.url)
@@ -38,11 +43,6 @@ function resolveCliVersion(): string {
const VERSION = resolveCliVersion()
// Helper function to collect multiple option values into an array
function collectMultiple(value: string, previous: string[]): string[] {
return previous.concat([value])
}
export function createCli(): Command {
const program = new Command()
@@ -58,127 +58,118 @@ export function createCli(): Command {
.option('--no-color', 'disable colored output')
// Primary agent commands (top-level)
program
.command('ls')
.description('List agents. By default excludes archived agents.')
.option('-a, --all', 'Include archived agents')
.option('-g, --global', 'Legacy no-op (kept for compatibility)')
.option('--label <key=value>', 'Filter by label (can be used multiple times)', collectMultiple, [])
.option('--thinking <id>', 'Filter by thinking option ID')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runLsCommand))
addJsonAndDaemonHostOptions(
program
.command('ls')
.description('List agents. By default excludes archived agents.')
.option('-a, --all', 'Include archived agents')
.option('-g, --global', 'Legacy no-op (kept for compatibility)')
.option('--label <key=value>', 'Filter by label (can be used multiple times)', collectMultiple, [])
.option('--thinking <id>', 'Filter by thinking option ID')
).action(withOutput(runLsCommand))
program
.command('run')
.description('Create and start an agent with a task')
.argument('<prompt>', 'The task/prompt for the agent')
.option('-d, --detach', 'Run in background (detached)')
.option('--name <name>', 'Assign a name/title to the agent')
.option('--provider <provider>', 'Agent provider: claude | codex | opencode', 'claude')
.option('--model <model>', 'Model to use (e.g., claude-sonnet-4-20250514, claude-3-5-haiku-20241022)')
.option('--thinking <id>', 'Thinking option ID to use for this run')
.option('--mode <mode>', 'Provider-specific mode (e.g., plan, default, bypass)')
.option('--worktree <name>', 'Create agent in a new git worktree')
.option('--base <branch>', 'Base branch for worktree (default: current branch)')
.option('--image <path>', 'Attach image(s) to the initial prompt (can be used multiple times)', collectMultiple, [])
.option('--cwd <path>', 'Working directory (default: current)')
.option('--label <key=value>', 'Add label(s) to the agent (can be used multiple times)', collectMultiple, [])
.option('--output-schema <schema>', 'Output JSON matching the provided schema file path or inline JSON schema')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runRunCommand))
addJsonAndDaemonHostOptions(
program
.command('run')
.description('Create and start an agent with a task')
.argument('<prompt>', 'The task/prompt for the agent')
.option('-d, --detach', 'Run in background (detached)')
.option('--name <name>', 'Assign a name/title to the agent')
.option('--provider <provider>', 'Agent provider: claude | codex | opencode', 'claude')
.option('--model <model>', 'Model to use (e.g., claude-sonnet-4-20250514, claude-3-5-haiku-20241022)')
.option('--thinking <id>', 'Thinking option ID to use for this run')
.option('--mode <mode>', 'Provider-specific mode (e.g., plan, default, bypass)')
.option('--worktree <name>', 'Create agent in a new git worktree')
.option('--base <branch>', 'Base branch for worktree (default: current branch)')
.option(
'--image <path>',
'Attach image(s) to the initial prompt (can be used multiple times)',
collectMultiple,
[]
)
.option('--cwd <path>', 'Working directory (default: current)')
.option('--label <key=value>', 'Add label(s) to the agent (can be used multiple times)', collectMultiple, [])
.option('--output-schema <schema>', 'Output JSON matching the provided schema file path or inline JSON schema')
).action(withOutput(runRunCommand))
program
.command('attach')
.description("Attach to a running agent's output stream")
.argument('<id>', 'Agent ID (or prefix)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(runAttachCommand)
addDaemonHostOption(
program
.command('attach')
.description("Attach to a running agent's output stream")
.argument('<id>', 'Agent ID (or prefix)')
).action(runAttachCommand)
program
.command('logs')
.description('View agent activity/timeline')
.argument('<id>', 'Agent ID (or prefix)')
.option('-f, --follow', 'Follow log output (streaming)')
.option('--tail <n>', 'Show last n entries')
.option('--filter <type>', 'Filter by event type (tools, text, errors, permissions)')
.option('--since <time>', 'Show logs since timestamp')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(runLogsCommand)
addDaemonHostOption(
program
.command('logs')
.description('View agent activity/timeline')
.argument('<id>', 'Agent ID (or prefix)')
.option('-f, --follow', 'Follow log output (streaming)')
.option('--tail <n>', 'Show last n entries')
.option('--filter <type>', 'Filter by event type (tools, text, errors, permissions)')
.option('--since <time>', 'Show logs since timestamp')
).action(runLogsCommand)
program
.command('stop')
.description('Interrupt an agent if it is running (no-op for idle agents)')
.argument('[id]', 'Agent ID (or prefix) - optional if --all or --cwd specified')
.option('--all', 'Stop all agents')
.option('--cwd <path>', 'Stop all agents in directory')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runStopCommand))
addJsonAndDaemonHostOptions(
program
.command('stop')
.description('Interrupt an agent if it is running (no-op for idle agents)')
.argument('[id]', 'Agent ID (or prefix) - optional if --all or --cwd specified')
.option('--all', 'Stop all agents')
.option('--cwd <path>', 'Stop all agents in directory')
).action(withOutput(runStopCommand))
program
.command('delete')
.description('Delete an agent (interrupt if running, then hard-delete)')
.argument('[id]', 'Agent ID (or prefix) - optional if --all or --cwd specified')
.option('--all', 'Delete all agents')
.option('--cwd <path>', 'Delete all agents in directory')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runDeleteCommand))
addJsonAndDaemonHostOptions(
program
.command('delete')
.description('Delete an agent (interrupt if running, then hard-delete)')
.argument('[id]', 'Agent ID (or prefix) - optional if --all or --cwd specified')
.option('--all', 'Delete all agents')
.option('--cwd <path>', 'Delete all agents in directory')
).action(withOutput(runDeleteCommand))
program
.command('send')
.description('Send a message/task to an existing agent')
.argument('<id>', 'Agent ID (or prefix)')
.argument('<prompt>', 'The message to send')
.option('--no-wait', 'Return immediately without waiting for completion')
.option('--image <path>', 'Attach image(s) to the message', collectMultiple, [])
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runSendCommand))
addJsonAndDaemonHostOptions(
program
.command('send')
.description('Send a message/task to an existing agent')
.argument('<id>', 'Agent ID (or prefix)')
.argument('<prompt>', 'The message to send')
.option('--no-wait', 'Return immediately without waiting for completion')
.option('--image <path>', 'Attach image(s) to the message', collectMultiple, [])
).action(withOutput(runSendCommand))
program
.command('inspect')
.description('Show detailed information about an agent')
.argument('<id>', 'Agent ID (or prefix)')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runInspectCommand))
addJsonAndDaemonHostOptions(
program
.command('inspect')
.description('Show detailed information about an agent')
.argument('<id>', 'Agent ID (or prefix)')
).action(withOutput(runInspectCommand))
program
.command('wait')
.description('Wait for an agent to become idle')
.argument('<id>', 'Agent ID (or prefix)')
.option('--timeout <seconds>', 'Maximum wait time (default: no limit)')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runWaitCommand))
addJsonAndDaemonHostOptions(
program
.command('wait')
.description('Wait for an agent to become idle')
.argument('<id>', 'Agent ID (or prefix)')
.option('--timeout <seconds>', 'Maximum wait time (default: no limit)')
).action(withOutput(runWaitCommand))
// Top-level local daemon shortcuts
program.addCommand(onboardCommand())
program.addCommand(daemonStartCommand())
program
.command('update')
.description('Update local daemon package (alias for "paseo daemon update")')
.option('--home <path>', 'Paseo home directory (default: ~/.paseo)')
.option('-y, --yes', 'Restart automatically after update')
.action(async (options: { home?: string; yes?: boolean }) => {
await runDaemonUpdateCommandOrExit(options)
})
program
.command('status')
.description('Show local daemon status (alias for "paseo daemon status")')
.option('--json', 'Output in JSON format')
addJsonOption(
program
.command('status')
.description('Show local daemon status (alias for "paseo daemon status")')
)
.option('--home <path>', 'Paseo home directory (default: ~/.paseo)')
.action(withOutput(runDaemonStatusCommand))
program
.command('restart')
.description('Restart local daemon (alias for "paseo daemon restart")')
.option('--json', 'Output in JSON format')
addJsonOption(
program
.command('restart')
.description('Restart local daemon (alias for "paseo daemon restart")')
)
.option('--home <path>', 'Paseo home directory (default: ~/.paseo)')
.option('--timeout <seconds>', 'Wait timeout before force step (default: 15)')
.option('--force', 'Send SIGKILL if graceful stop times out')

View File

@@ -12,142 +12,132 @@ import { runWaitCommand } from './wait.js'
import { runAttachCommand } from './attach.js'
import { runUpdateCommand } from './update.js'
import { withOutput } from '../../output/index.js'
import {
addDaemonHostOption,
addJsonAndDaemonHostOptions,
collectMultiple,
} from '../../utils/command-options.js'
export function createAgentCommand(): Command {
const agent = new Command('agent').description('Manage agents (advanced operations)')
// Helper function to collect multiple option values into an array
const collectMultiple = (value: string, previous: string[]): string[] => {
return previous.concat([value])
}
// Primary agent commands (same as top-level)
agent
.command('ls')
.description('List agents. By default excludes archived agents.')
.option('-a, --all', 'Include archived agents')
.option('-g, --global', 'Legacy no-op (kept for compatibility)')
.option('--label <key=value>', 'Filter by label (can be used multiple times)', collectMultiple, [])
.option('--thinking <id>', 'Filter by thinking option ID')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runLsCommand))
addJsonAndDaemonHostOptions(
agent
.command('ls')
.description('List agents. By default excludes archived agents.')
.option('-a, --all', 'Include archived agents')
.option('-g, --global', 'Legacy no-op (kept for compatibility)')
.option('--label <key=value>', 'Filter by label (can be used multiple times)', collectMultiple, [])
.option('--thinking <id>', 'Filter by thinking option ID')
).action(withOutput(runLsCommand))
agent
.command('run')
.description('Create and start an agent with a task')
.argument('<prompt>', 'The task/prompt for the agent')
.option('-d, --detach', 'Run in background (detached)')
.option('--name <name>', 'Assign a name/title to the agent')
.option('--provider <provider>', 'Agent provider: claude | codex | opencode', 'claude')
.option('--model <model>', 'Model to use (e.g., claude-sonnet-4-20250514, claude-3-5-haiku-20241022)')
.option('--thinking <id>', 'Thinking option ID to use for this run')
.option('--mode <mode>', 'Provider-specific mode (e.g., plan, default, bypass)')
.option('--cwd <path>', 'Working directory (default: current)')
.option('--label <key=value>', 'Add label(s) to the agent (can be used multiple times)', collectMultiple, [])
.option('--output-schema <schema>', 'Output JSON matching the provided schema file path or inline JSON schema')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runRunCommand))
addJsonAndDaemonHostOptions(
agent
.command('run')
.description('Create and start an agent with a task')
.argument('<prompt>', 'The task/prompt for the agent')
.option('-d, --detach', 'Run in background (detached)')
.option('--name <name>', 'Assign a name/title to the agent')
.option('--provider <provider>', 'Agent provider: claude | codex | opencode', 'claude')
.option('--model <model>', 'Model to use (e.g., claude-sonnet-4-20250514, claude-3-5-haiku-20241022)')
.option('--thinking <id>', 'Thinking option ID to use for this run')
.option('--mode <mode>', 'Provider-specific mode (e.g., plan, default, bypass)')
.option('--cwd <path>', 'Working directory (default: current)')
.option('--label <key=value>', 'Add label(s) to the agent (can be used multiple times)', collectMultiple, [])
.option('--output-schema <schema>', 'Output JSON matching the provided schema file path or inline JSON schema')
).action(withOutput(runRunCommand))
agent
.command('attach')
.description("Attach to a running agent's output stream")
.argument('<id>', 'Agent ID (or prefix)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(runAttachCommand)
addDaemonHostOption(
agent
.command('attach')
.description("Attach to a running agent's output stream")
.argument('<id>', 'Agent ID (or prefix)')
).action(runAttachCommand)
agent
.command('logs')
.description('View agent activity/timeline')
.argument('<id>', 'Agent ID (or prefix)')
.option('-f, --follow', 'Follow log output (streaming)')
.option('--tail <n>', 'Show last n entries')
.option('--filter <type>', 'Filter by event type (tools, text, errors, permissions)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(runLogsCommand)
addDaemonHostOption(
agent
.command('logs')
.description('View agent activity/timeline')
.argument('<id>', 'Agent ID (or prefix)')
.option('-f, --follow', 'Follow log output (streaming)')
.option('--tail <n>', 'Show last n entries')
.option('--filter <type>', 'Filter by event type (tools, text, errors, permissions)')
).action(runLogsCommand)
agent
.command('stop')
.description('Interrupt an agent if it is running (no-op for idle agents)')
.argument('[id]', 'Agent ID (or prefix) - optional if --all or --cwd specified')
.option('--all', 'Stop all agents')
.option('--cwd <path>', 'Stop all agents in directory')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runStopCommand))
addJsonAndDaemonHostOptions(
agent
.command('stop')
.description('Interrupt an agent if it is running (no-op for idle agents)')
.argument('[id]', 'Agent ID (or prefix) - optional if --all or --cwd specified')
.option('--all', 'Stop all agents')
.option('--cwd <path>', 'Stop all agents in directory')
).action(withOutput(runStopCommand))
agent
.command('delete')
.description('Delete an agent (interrupt if running, then hard-delete)')
.argument('[id]', 'Agent ID (or prefix) - optional if --all or --cwd specified')
.option('--all', 'Delete all agents')
.option('--cwd <path>', 'Delete all agents in directory')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runDeleteCommand))
addJsonAndDaemonHostOptions(
agent
.command('delete')
.description('Delete an agent (interrupt if running, then hard-delete)')
.argument('[id]', 'Agent ID (or prefix) - optional if --all or --cwd specified')
.option('--all', 'Delete all agents')
.option('--cwd <path>', 'Delete all agents in directory')
).action(withOutput(runDeleteCommand))
agent
.command('send')
.description('Send a message/task to an existing agent')
.argument('<id>', 'Agent ID (or prefix)')
.argument('<prompt>', 'The message to send')
.option('--no-wait', 'Return immediately without waiting for completion')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runSendCommand))
addJsonAndDaemonHostOptions(
agent
.command('send')
.description('Send a message/task to an existing agent')
.argument('<id>', 'Agent ID (or prefix)')
.argument('<prompt>', 'The message to send')
.option('--no-wait', 'Return immediately without waiting for completion')
).action(withOutput(runSendCommand))
agent
.command('inspect')
.description('Show detailed information about an agent')
.argument('<id>', 'Agent ID (or prefix)')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runInspectCommand))
addJsonAndDaemonHostOptions(
agent
.command('inspect')
.description('Show detailed information about an agent')
.argument('<id>', 'Agent ID (or prefix)')
).action(withOutput(runInspectCommand))
agent
.command('wait')
.description('Wait for an agent to become idle')
.argument('<id>', 'Agent ID (or prefix)')
.option('--timeout <seconds>', 'Maximum wait time (default: no limit)')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runWaitCommand))
addJsonAndDaemonHostOptions(
agent
.command('wait')
.description('Wait for an agent to become idle')
.argument('<id>', 'Agent ID (or prefix)')
.option('--timeout <seconds>', 'Maximum wait time (default: no limit)')
).action(withOutput(runWaitCommand))
// Advanced agent commands (less common operations)
agent
.command('mode')
.description("Change an agent's operational mode")
.argument('<id>', 'Agent ID (or prefix)')
.argument('[mode]', 'Mode to set (required unless --list)')
.option('--list', 'List available modes for this agent')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runModeCommand))
addJsonAndDaemonHostOptions(
agent
.command('mode')
.description("Change an agent's operational mode")
.argument('<id>', 'Agent ID (or prefix)')
.argument('[mode]', 'Mode to set (required unless --list)')
.option('--list', 'List available modes for this agent')
).action(withOutput(runModeCommand))
agent
.command('archive')
.description('Archive an agent (soft-delete)')
.argument('<id>', 'Agent ID (or prefix)')
.option('--force', 'Force archive running agent (interrupts active run first)')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runArchiveCommand))
addJsonAndDaemonHostOptions(
agent
.command('archive')
.description('Archive an agent (soft-delete)')
.argument('<id>', 'Agent ID (or prefix)')
.option('--force', 'Force archive running agent (interrupts active run first)')
).action(withOutput(runArchiveCommand))
agent
.command('update')
.description("Update an agent's metadata")
.argument('<id>', 'Agent ID (or prefix)')
.option('--name <name>', "Update the agent's display name")
.option(
'--label <label>',
'Add/set label(s) on the agent (can be used multiple times or comma-separated)',
collectMultiple,
[]
)
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runUpdateCommand))
addJsonAndDaemonHostOptions(
agent
.command('update')
.description("Update an agent's metadata")
.argument('<id>', 'Agent ID (or prefix)')
.option('--name <name>', "Update the agent's display name")
.option(
'--label <label>',
'Add/set label(s) on the agent (can be used multiple times or comma-separated)',
collectMultiple,
[]
)
).action(withOutput(runUpdateCommand))
return agent
}

View File

@@ -4,36 +4,38 @@ import { runStatusCommand } from './status.js'
import { runStopCommand } from './stop.js'
import { runRestartCommand } from './restart.js'
import { pairCommand } from './pair.js'
import { updateCommand } from './update.js'
import { withOutput } from '../../output/index.js'
import { addJsonOption } from '../../utils/command-options.js'
export function createDaemonCommand(): Command {
const daemon = new Command('daemon').description('Manage the Paseo daemon')
daemon.addCommand(startCommand())
daemon.addCommand(pairCommand())
daemon.addCommand(updateCommand())
daemon
.command('status')
.description('Show local daemon status')
.option('--json', 'Output in JSON format')
addJsonOption(
daemon
.command('status')
.description('Show local daemon status')
)
.option('--home <path>', 'Paseo home directory (default: ~/.paseo)')
.action(withOutput(runStatusCommand))
daemon
.command('stop')
.description('Stop the local daemon')
.option('--json', 'Output in JSON format')
addJsonOption(
daemon
.command('stop')
.description('Stop the local daemon')
)
.option('--home <path>', 'Paseo home directory (default: ~/.paseo)')
.option('--timeout <seconds>', 'Wait timeout before failing (default: 15)')
.option('--force', 'Send SIGKILL if graceful stop times out')
.action(withOutput(runStopCommand))
daemon
.command('restart')
.description('Restart the local daemon')
.option('--json', 'Output in JSON format')
addJsonOption(
daemon
.command('restart')
.description('Restart the local daemon')
)
.option('--home <path>', 'Paseo home directory (default: ~/.paseo)')
.option('--timeout <seconds>', 'Wait timeout before force step (default: 15)')
.option('--force', 'Send SIGKILL if graceful stop times out')

View File

@@ -20,7 +20,7 @@ export interface LocalDaemonPidInfo {
startedAt?: string
hostname?: string
uid?: number
sockPath?: string
listen?: string
}
export interface LocalDaemonState {
@@ -164,7 +164,7 @@ function readPidFile(pidPath: string): LocalDaemonPidInfo | null {
startedAt: typeof parsed.startedAt === 'string' ? parsed.startedAt : undefined,
hostname: typeof parsed.hostname === 'string' ? parsed.hostname : undefined,
uid: typeof parsed.uid === 'number' ? parsed.uid : undefined,
sockPath: typeof parsed.sockPath === 'string' ? parsed.sockPath : undefined,
listen: typeof parsed.listen === 'string' ? parsed.listen : typeof parsed.sockPath === 'string' ? parsed.sockPath : undefined,
}
} catch {
return null
@@ -322,7 +322,7 @@ export function resolveLocalDaemonState(options: { home?: string } = {}): LocalD
const logPath = path.join(home, DAEMON_LOG_FILENAME)
const pidInfo = existsSync(pidPath) ? readPidFile(pidPath) : null
const running = pidInfo ? isProcessRunning(pidInfo.pid) : false
const listen = pidInfo?.sockPath ?? config.listen
const listen = pidInfo?.listen ?? config.listen
return {
home,

View File

@@ -1,6 +1,7 @@
import { Command } from 'commander'
import chalk from 'chalk'
import { generateLocalPairingOffer, loadConfig, resolvePaseoHome } from '@getpaseo/server'
import { addJsonOption } from '../../utils/command-options.js'
interface PairOptions {
home?: string
@@ -8,9 +9,10 @@ interface PairOptions {
}
export function pairCommand(): Command {
return new Command('pair')
.description('Print the daemon pairing QR code and link')
.option('--json', 'Output in JSON format')
return addJsonOption(
new Command('pair')
.description('Print the daemon pairing QR code and link')
)
.option('--home <path>', 'Paseo home directory (default: ~/.paseo)')
.action(async (_options: PairOptions, command: Command) => {
await runPairCommand(command.optsWithGlobals() as PairOptions)

View File

@@ -1,27 +1,10 @@
import { spawnSync } from 'node:child_process'
import { existsSync } from 'node:fs'
import path from 'node:path'
export type NodePathSource = 'daemon_pid' | 'current_process'
export interface NodePathResolution {
nodePath: string
source: NodePathSource
note?: string
}
export interface NodePathFromPidResult {
nodePath: string | null
error?: string
}
export interface NpmInvocation {
nodePath: string
npmPath: string
command: string
argsPrefix: string[]
}
function normalizeError(error: unknown): string {
if (error instanceof Error) {
return error.message
@@ -61,71 +44,3 @@ export function resolveNodePathFromPid(pid: number): NodePathFromPidResult {
return { nodePath: resolved }
}
export function resolvePreferredNodePath(args: {
daemonPid?: number | null
fallbackNodePath?: string
}): NodePathResolution {
const fallback = args.fallbackNodePath ?? process.execPath
const daemonPid = args.daemonPid
if (typeof daemonPid === 'number' && Number.isInteger(daemonPid) && daemonPid > 0) {
const fromPid = resolveNodePathFromPid(daemonPid)
if (fromPid.nodePath) {
return {
nodePath: fromPid.nodePath,
source: 'daemon_pid',
}
}
return {
nodePath: fallback,
source: 'current_process',
note: `Could not resolve node from daemon PID ${daemonPid}; using current process node (${fromPid.error ?? 'unknown error'})`,
}
}
return {
nodePath: fallback,
source: 'current_process',
}
}
export function resolveNpmInvocationFromNode(nodePath: string): NpmInvocation {
const binDir = path.dirname(nodePath)
const prefix = path.dirname(binDir)
const npmBinary = path.join(binDir, process.platform === 'win32' ? 'npm.cmd' : 'npm')
if (existsSync(npmBinary)) {
return {
nodePath,
npmPath: npmBinary,
command: npmBinary,
argsPrefix: [],
}
}
const npmCliCandidates = [
path.join(prefix, 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'),
path.join(prefix, 'node_modules', 'npm', 'bin', 'npm-cli.js'),
]
for (const candidate of npmCliCandidates) {
if (existsSync(candidate)) {
return {
nodePath,
npmPath: candidate,
command: nodePath,
argsPrefix: [candidate],
}
}
}
throw new Error(`Unable to resolve npm for node executable: ${nodePath}`)
}
export function formatNpmInvocation(invocation: NpmInvocation): string {
if (invocation.argsPrefix.length === 0) {
return invocation.command
}
return `${invocation.command} ${invocation.argsPrefix.join(' ')}`
}

View File

@@ -1,31 +1,26 @@
import type { Command } from 'commander'
import { spawnSync } from 'node:child_process'
import { createRequire } from 'node:module'
import { getOrCreateServerId } from '@getpaseo/server'
import { tryConnectToDaemon } from '../../utils/client.js'
import type { CommandOptions, ListResult, OutputSchema } from '../../output/index.js'
import { resolveLocalDaemonState, resolveTcpHostFromListen } from './local-daemon.js'
import {
formatNpmInvocation,
resolveNpmInvocationFromNode,
resolvePreferredNodePath,
type NpmInvocation,
} from './runtime-toolchain.js'
import { resolveNodePathFromPid } from './runtime-toolchain.js'
interface DaemonStatus {
serverId: string | null
status: 'running' | 'stopped' | 'unresponsive'
home: string
listen: string
hostname: string | null
pid: number | null
startedAt: string | null
owner: string | null
logPath: string
runningAgents: number | null
idleAgents: number | null
runtimeNode: string
runtimeNpm: string
daemonNode: string
cliNode: string
cliVersion: string
latestCliVersion: string
updateStatus: string
note?: string
}
@@ -38,13 +33,7 @@ type CliPackageJson = {
version?: unknown
}
type LatestCliVersionResult = {
version: string | null
note?: string
}
const require = createRequire(import.meta.url)
const CLI_UPDATE_CHECK_TIMEOUT_MS = 3000
function normalizeError(error: unknown): string {
if (error instanceof Error) {
@@ -79,95 +68,6 @@ function resolveCliVersion(): string {
return 'unknown'
}
function parseVersionFromNpmOutput(raw: string): string | null {
const trimmed = raw.trim()
if (!trimmed) {
return null
}
try {
const parsed = JSON.parse(trimmed) as unknown
if (typeof parsed === 'string' && parsed.trim().length > 0) {
return parsed.trim()
}
} catch {
// Fall back to plain output.
}
return trimmed.replace(/^"+|"+$/g, '').trim() || null
}
function parseSemver(version: string): [number, number, number] | null {
const match = version.trim().match(/^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/)
if (!match) {
return null
}
const major = Number(match[1])
const minor = Number(match[2])
const patch = Number(match[3])
if (![major, minor, patch].every((part) => Number.isInteger(part) && part >= 0)) {
return null
}
return [major, minor, patch]
}
function compareSemver(left: string, right: string): number | null {
const leftParts = parseSemver(left)
const rightParts = parseSemver(right)
if (!leftParts || !rightParts) {
return null
}
for (let index = 0; index < leftParts.length; index += 1) {
if (leftParts[index]! < rightParts[index]!) return -1
if (leftParts[index]! > rightParts[index]!) return 1
}
return 0
}
function fetchLatestCliVersion(npm: NpmInvocation): LatestCliVersionResult {
const result = spawnSync(
npm.command,
[...npm.argsPrefix, 'view', '@getpaseo/cli', 'version', '--json'],
{
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
env: process.env,
timeout: CLI_UPDATE_CHECK_TIMEOUT_MS,
}
)
if (result.error) {
return {
version: null,
note: `update check failed: ${shortenMessage(normalizeError(result.error))}`,
}
}
if ((result.status ?? 1) !== 0) {
const stderr = result.stderr?.trim()
return {
version: null,
note: stderr
? `update check failed: ${shortenMessage(stderr)}`
: `update check failed: npm exited with code ${result.status ?? 1}`,
}
}
const version = parseVersionFromNpmOutput(result.stdout)
if (!version) {
return {
version: null,
note: 'update check failed: empty npm response',
}
}
return { version }
}
function createStatusSchema(status: DaemonStatus): OutputSchema<StatusRow> {
return {
idField: 'key',
@@ -196,18 +96,18 @@ function createStatusSchema(status: DaemonStatus): OutputSchema<StatusRow> {
function toStatusRows(status: DaemonStatus): StatusRow[] {
const rows: StatusRow[] = [
{ key: 'Server ID', value: status.serverId ?? '-' },
{ key: 'Status', value: status.status },
{ key: 'Home', value: status.home },
{ key: 'Listen', value: status.listen },
{ key: 'Hostname', value: status.hostname ?? '-' },
{ key: 'PID', value: status.pid === null ? '-' : String(status.pid) },
{ key: 'Started', value: status.startedAt ?? '-' },
{ key: 'Owner', value: status.owner ?? '-' },
{ key: 'Logs', value: status.logPath },
{ key: 'Node', value: status.runtimeNode },
{ key: 'npm', value: status.runtimeNpm },
{ key: 'Daemon Node', value: status.daemonNode },
{ key: 'CLI Node', value: status.cliNode },
{ key: 'CLI', value: status.cliVersion },
{ key: 'Latest CLI', value: status.latestCliVersion },
{ key: 'Update', value: status.updateStatus },
]
if (status.runningAgents !== null && status.idleAgents !== null) {
@@ -248,22 +148,24 @@ export async function runStatusCommand(
const state = resolveLocalDaemonState({ home })
const owner = resolveOwnerLabel(state.pidInfo?.uid, state.pidInfo?.hostname)
const resolvedNode = resolvePreferredNodePath({
daemonPid: state.running ? state.pidInfo?.pid : null,
fallbackNodePath: process.execPath,
})
let daemonNode: string
if (!state.running) {
daemonNode = '-'
} else if (state.pidInfo?.pid) {
const fromPid = resolveNodePathFromPid(state.pidInfo.pid)
daemonNode = fromPid.nodePath ?? `unknown (${fromPid.error ?? 'could not resolve from PID'})`
} else {
daemonNode = 'unknown (no PID available)'
}
const cliNode = process.execPath
let status: DaemonStatus['status'] = state.running ? 'running' : 'stopped'
let runningAgents: number | null = null
let idleAgents: number | null = null
let note: string | undefined
let runtimeNpm = '-'
let latestCliVersion = 'unknown'
let updateStatus = 'unknown'
if (!state.running && state.stalePidFile && state.pidInfo) {
note = `Stale PID file found for PID ${state.pidInfo.pid}`
}
note = appendNote(note, resolvedNode.note)
if (state.running) {
const host = resolveTcpHostFromListen(state.listen)
@@ -290,54 +192,30 @@ export async function runStatusCommand(
}
}
let npmInvocation: NpmInvocation | null = null
try {
npmInvocation = resolveNpmInvocationFromNode(resolvedNode.nodePath)
runtimeNpm = formatNpmInvocation(npmInvocation)
} catch (err) {
runtimeNpm = `unresolved (${shortenMessage(normalizeError(err))})`
}
const cliVersion = resolveCliVersion()
if (npmInvocation) {
const latest = fetchLatestCliVersion(npmInvocation)
if (latest.version) {
latestCliVersion = latest.version
if (cliVersion === 'unknown') {
updateStatus = 'unknown (local CLI version unavailable)'
} else {
const comparison = compareSemver(cliVersion, latest.version)
if (comparison === null) {
updateStatus = 'unknown (version format not comparable)'
} else if (comparison < 0) {
updateStatus = `update available (${cliVersion} -> ${latest.version})`
} else {
updateStatus = 'up to date'
}
}
} else {
latestCliVersion = 'unknown'
updateStatus = latest.note ?? 'unknown'
}
} else {
updateStatus = 'unknown (npm unresolved)'
let serverId: string | null = null
try {
serverId = getOrCreateServerId(state.home)
} catch (error) {
note = appendNote(note, `serverId unavailable: ${shortenMessage(normalizeError(error))}`)
}
const daemonStatus: DaemonStatus = {
serverId,
status,
home: state.home,
listen: state.listen,
hostname: state.pidInfo?.hostname ?? null,
pid: state.pidInfo?.pid ?? null,
startedAt: state.pidInfo?.startedAt ?? null,
owner,
logPath: state.logPath,
runningAgents,
idleAgents,
runtimeNode: `${resolvedNode.nodePath} (${resolvedNode.source})`,
runtimeNpm,
daemonNode,
cliNode,
cliVersion,
latestCliVersion,
updateStatus,
note,
}

View File

@@ -1,105 +0,0 @@
import { confirm, isCancel } from '@clack/prompts'
import { spawn } from 'node:child_process'
import { Command } from 'commander'
import chalk from 'chalk'
import { runRestartCommand } from './restart.js'
import { resolveLocalDaemonState } from './local-daemon.js'
import {
resolveNpmInvocationFromNode,
resolvePreferredNodePath,
type NpmInvocation,
} from './runtime-toolchain.js'
import { getErrorMessage } from '../../utils/errors.js'
export interface DaemonUpdateOptions {
home?: string
yes?: boolean
}
function runCommand(command: string, args: string[]): Promise<number> {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
stdio: 'inherit',
env: process.env,
})
child.once('error', reject)
child.once('exit', (code, signal) => {
if (signal) {
reject(new Error(`Command exited via signal ${signal}`))
return
}
resolve(code ?? 1)
})
})
}
export async function runDaemonUpdateCommand(options: DaemonUpdateOptions): Promise<void> {
const daemonState = resolveLocalDaemonState({ home: options.home })
const resolvedNode = resolvePreferredNodePath({
daemonPid: daemonState.running ? daemonState.pidInfo?.pid : null,
fallbackNodePath: process.execPath,
})
const npm: NpmInvocation = resolveNpmInvocationFromNode(resolvedNode.nodePath)
const args = [...npm.argsPrefix, 'install', '-g', '@getpaseo/cli@latest']
const exitCode = await runCommand(npm.command, args)
if (exitCode !== 0) {
throw new Error(`Update command failed with exit code ${exitCode}`)
}
let shouldRestart = options.yes === true
if (!shouldRestart) {
if (!process.stdin.isTTY || !process.stdout.isTTY) {
console.log(chalk.yellow('Update complete. Restart skipped (non-interactive terminal).'))
console.log(chalk.dim('Run `paseo daemon restart` when ready.'))
return
}
console.log(
chalk.yellow(
'Restart will stop running agents, but they can continue from persisted state after restart.'
)
)
const answer = await confirm({
message: 'Restart daemon now?',
active: 'Restart now',
inactive: 'Later',
initialValue: true,
})
if (isCancel(answer)) {
console.log(chalk.yellow('Update complete. Restart skipped.'))
return
}
shouldRestart = answer
}
if (!shouldRestart) {
console.log(chalk.yellow('Update complete. Restart skipped.'))
return
}
const restartResult = await runRestartCommand({ home: options.home }, new Command())
console.log(chalk.green(restartResult.data.message))
}
export async function runDaemonUpdateCommandOrExit(options: DaemonUpdateOptions): Promise<void> {
try {
await runDaemonUpdateCommand(options)
} catch (err) {
console.error(chalk.red(`Failed to update daemon: ${getErrorMessage(err)}`))
process.exit(1)
}
}
export function updateCommand(): Command {
return new Command('update')
.description('Update local daemon package')
.option('--home <path>', 'Paseo home directory (default: ~/.paseo)')
.option('-y, --yes', 'Restart automatically after update')
.action(async (options: DaemonUpdateOptions) => {
await runDaemonUpdateCommandOrExit(options)
})
}

View File

@@ -3,39 +3,37 @@ import { runLsCommand } from './ls.js'
import { runAllowCommand } from './allow.js'
import { runDenyCommand } from './deny.js'
import { withOutput } from '../../output/index.js'
import { addJsonAndDaemonHostOptions } from '../../utils/command-options.js'
export function createPermitCommand(): Command {
const permit = new Command('permit').description('Manage permission requests')
permit
.command('ls')
.description('List all pending permissions')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runLsCommand))
addJsonAndDaemonHostOptions(
permit
.command('ls')
.description('List all pending permissions')
).action(withOutput(runLsCommand))
permit
.command('allow')
.description('Allow a permission request')
.argument('<agent>', 'Agent ID (or prefix)')
.argument('[req_id]', 'Permission request ID (optional if --all)')
.option('--all', 'Allow all pending permissions for this agent')
.option('--input <json>', 'Modified input parameters (JSON)')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runAllowCommand))
addJsonAndDaemonHostOptions(
permit
.command('allow')
.description('Allow a permission request')
.argument('<agent>', 'Agent ID (or prefix)')
.argument('[req_id]', 'Permission request ID (optional if --all)')
.option('--all', 'Allow all pending permissions for this agent')
.option('--input <json>', 'Modified input parameters (JSON)')
).action(withOutput(runAllowCommand))
permit
.command('deny')
.description('Deny a permission request')
.argument('<agent>', 'Agent ID (or prefix)')
.argument('[req_id]', 'Permission request ID (optional if --all)')
.option('--all', 'Deny all pending permissions for this agent')
.option('--message <msg>', 'Denial reason message')
.option('--interrupt', 'Stop agent after denial')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runDenyCommand))
addJsonAndDaemonHostOptions(
permit
.command('deny')
.description('Deny a permission request')
.argument('<agent>', 'Agent ID (or prefix)')
.argument('[req_id]', 'Permission request ID (optional if --all)')
.option('--all', 'Deny all pending permissions for this agent')
.option('--message <msg>', 'Denial reason message')
.option('--interrupt', 'Stop agent after denial')
).action(withOutput(runDenyCommand))
return permit
}

View File

@@ -2,25 +2,24 @@ import { Command } from 'commander'
import { runLsCommand } from './ls.js'
import { runModelsCommand } from './models.js'
import { withOutput } from '../../output/index.js'
import { addJsonAndDaemonHostOptions } from '../../utils/command-options.js'
export function createProviderCommand(): Command {
const provider = new Command('provider').description('Manage agent providers')
provider
.command('ls')
.description('List available providers and status')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runLsCommand))
addJsonAndDaemonHostOptions(
provider
.command('ls')
.description('List available providers and status')
).action(withOutput(runLsCommand))
provider
.command('models')
.description('List models for a provider')
.argument('<provider>', 'Provider name (claude, codex, opencode)')
.option('--thinking', 'Include thinking option IDs for each model')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runModelsCommand))
addJsonAndDaemonHostOptions(
provider
.command('models')
.description('List models for a provider')
.argument('<provider>', 'Provider name (claude, codex, opencode)')
.option('--thinking', 'Include thinking option IDs for each model')
).action(withOutput(runModelsCommand))
return provider
}

View File

@@ -1,29 +1,24 @@
import { Command } from "commander";
import { withOutput } from "../../output/index.js";
import { runSpeechModelsCommand } from "./models.js";
import { runSpeechDownloadCommand } from "./download.js";
function collectMultiple(value: string, previous: string[]): string[] {
return previous.concat([value]);
}
import { Command } from 'commander'
import { withOutput } from '../../output/index.js'
import { runSpeechModelsCommand } from './models.js'
import { runSpeechDownloadCommand } from './download.js'
import { addJsonAndDaemonHostOptions, collectMultiple } from '../../utils/command-options.js'
export function createSpeechCommand(): Command {
const speech = new Command("speech").description("Manage local speech models");
const speech = new Command('speech').description('Manage local speech models')
speech
.command("models")
.description("List local speech model download status")
.option("--json", "Output in JSON format")
.option("--host <host>", "Daemon host target (default: local socket/pipe, then localhost:6767)")
.action(withOutput(runSpeechModelsCommand));
addJsonAndDaemonHostOptions(
speech
.command('models')
.description('List local speech model download status')
).action(withOutput(runSpeechModelsCommand))
speech
.command("download")
.description("Download local speech models")
.option("--model <id>", "Model ID to download (repeatable)", collectMultiple, [])
.option("--json", "Output in JSON format")
.option("--host <host>", "Daemon host target (default: local socket/pipe, then localhost:6767)")
.action(withOutput(runSpeechDownloadCommand));
addJsonAndDaemonHostOptions(
speech
.command('download')
.description('Download local speech models')
.option('--model <id>', 'Model ID to download (repeatable)', collectMultiple, [])
).action(withOutput(runSpeechDownloadCommand))
return speech;
return speech
}

View File

@@ -2,24 +2,23 @@ import { Command } from 'commander'
import { runLsCommand } from './ls.js'
import { runArchiveCommand } from './archive.js'
import { withOutput } from '../../output/index.js'
import { addJsonAndDaemonHostOptions } from '../../utils/command-options.js'
export function createWorktreeCommand(): Command {
const worktree = new Command('worktree').description('Manage Paseo-managed git worktrees')
worktree
.command('ls')
.description('List Paseo-managed git worktrees')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runLsCommand))
addJsonAndDaemonHostOptions(
worktree
.command('ls')
.description('List Paseo-managed git worktrees')
).action(withOutput(runLsCommand))
worktree
.command('archive')
.description('Archive a worktree (removes worktree and associated branch)')
.argument('<name>', 'Worktree name or branch name')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runArchiveCommand))
addJsonAndDaemonHostOptions(
worktree
.command('archive')
.description('Archive a worktree (removes worktree and associated branch)')
.argument('<name>', 'Worktree name or branch name')
).action(withOutput(runArchiveCommand))
return worktree
}

View File

@@ -75,8 +75,8 @@ function readPidSocketTarget(paseoHome: string): string | null {
}
try {
const parsed = JSON.parse(readFileSync(pidPath, 'utf-8')) as { sockPath?: unknown }
return typeof parsed.sockPath === 'string' ? parsed.sockPath : null
const parsed = JSON.parse(readFileSync(pidPath, 'utf-8')) as { listen?: unknown; sockPath?: unknown }
return typeof parsed.listen === 'string' ? parsed.listen : typeof parsed.sockPath === 'string' ? parsed.sockPath : null
} catch {
return null
}

View File

@@ -0,0 +1,23 @@
import type { Command } from 'commander'
const JSON_OPTION_DESCRIPTION = 'Output in JSON format'
const DAEMON_HOST_OPTION_DESCRIPTION =
'Daemon host target (default: local socket/pipe, then localhost:6767)'
export function collectMultiple(value: string, previous: string[]): string[] {
return previous.concat([value])
}
export function addJsonOption<T extends Command>(command: T): T {
command.option('--json', JSON_OPTION_DESCRIPTION)
return command
}
export function addDaemonHostOption<T extends Command>(command: T): T {
command.option('--host <host>', DAEMON_HOST_OPTION_DESCRIPTION)
return command
}
export function addJsonAndDaemonHostOptions<T extends Command>(command: T): T {
return addDaemonHostOption(addJsonOption(command))
}

View File

@@ -85,8 +85,10 @@ try {
await $`PASEO_HOME=${paseoHome} npx paseo daemon status --json`.nothrow()
assert.strictEqual(result.exitCode, 0, '--json status should succeed')
const status = JSON.parse(result.stdout)
assert.strictEqual(typeof status.serverId, 'string', 'json status should include serverId')
assert.strictEqual(status.status, 'stopped', 'json status should report stopped')
assert.strictEqual(status.home, paseoHome, 'json status should reflect the isolated home')
assert.strictEqual(status.hostname, null, 'json status should include hostname when unavailable')
console.log('✓ daemon status --json outputs valid JSON\n')
}

View File

@@ -48,7 +48,7 @@ console.log('=== CLI IPC Target Helpers ===\n')
mkdirSync(paseoHome, { recursive: true })
writeFileSync(
path.join(paseoHome, 'paseo.pid'),
JSON.stringify({ pid: process.pid, sockPath: '/tmp/paseo-from-pid.sock' })
JSON.stringify({ pid: process.pid, listen: '/tmp/paseo-from-pid.sock' })
)
assert.deepStrictEqual(resolveDefaultDaemonHosts({ PASEO_HOME: paseoHome }), [
'unix:///tmp/paseo-from-pid.sock',
@@ -93,7 +93,7 @@ console.log('=== CLI IPC Target Helpers ===\n')
mkdirSync(paseoHome, { recursive: true })
writeFileSync(
path.join(paseoHome, 'paseo.pid'),
JSON.stringify({ pid: process.pid, sockPath: '/tmp/paseo-priority.sock' })
JSON.stringify({ pid: process.pid, listen: '/tmp/paseo-priority.sock' })
)
assert.deepStrictEqual(
resolveDefaultDaemonHosts({

View File

@@ -1,11 +1,11 @@
{
"name": "@getpaseo/desktop",
"version": "0.1.24",
"version": "0.1.26",
"private": true,
"description": "Paseo desktop app (Tauri wrapper)",
"scripts": {
"build:managed-runtime": "node ./scripts/build-managed-runtime.mjs",
"prepare:managed-runtime": "npm --prefix ../.. run build:daemon && npm run build:managed-runtime",
"prepare:managed-runtime": "npm --prefix ../.. run build:daemon && npm run build:managed-runtime && npm run validate:managed-runtime",
"dev": "npm run prepare:managed-runtime && tauri dev",
"build": "npm --prefix ../.. run build:web --workspace=@getpaseo/app && npm run prepare:managed-runtime && tauri build",
"validate:managed-runtime": "node ./scripts/validate-managed-runtime.mjs",

View File

@@ -213,6 +213,18 @@ function runCommand(command, args, options = {}) {
return result;
}
function withManagedRuntimeNodeOptions(env = process.env) {
const existing = env.NODE_OPTIONS?.trim() ?? "";
if (existing.includes("--max-old-space-size")) {
return env;
}
const nodeOptions = existing ? `${existing} --max-old-space-size=8192` : "--max-old-space-size=8192";
return {
...env,
NODE_OPTIONS: nodeOptions,
};
}
async function extractNodeDistribution(archivePath, nodeArtifact) {
const extractionRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-managed-runtime-node-"));
if (nodeArtifact.extension === "zip") {
@@ -260,6 +272,7 @@ async function packWorkspace(packageRoot, tarballRoot) {
tarballRoot,
], {
cwd: packageRoot,
env: withManagedRuntimeNodeOptions(process.env),
});
const [{ filename }] = JSON.parse(result.stdout.trim());
if (!filename) {
@@ -360,6 +373,15 @@ async function pruneOnnxRuntime(runtimeRoot) {
await removeIfExists(path.join(onnxRoot, "darwin"));
await removeIfExists(path.join(onnxRoot, "win32"));
await pruneChildrenExcept(path.join(onnxRoot, "linux"), new Set([process.arch]));
const archDir = path.join(onnxRoot, "linux", process.arch);
if (await pathExists(archDir)) {
const entries = await fs.readdir(archDir);
await Promise.all(
entries
.filter((name) => name.includes("cuda") || name.includes("tensorrt"))
.map((name) => fs.rm(path.join(archDir, name), { force: true }))
);
}
return;
}
if (process.platform === "win32") {
@@ -400,12 +422,71 @@ async function pruneClaudeAgentSdk(runtimeRoot) {
}
}
async function pruneCodexCli(runtimeRoot) {
const codexTargetTriple = {
darwin: { arm64: "aarch64-apple-darwin", x64: "x86_64-apple-darwin" },
linux: { arm64: "aarch64-unknown-linux-musl", x64: "x86_64-unknown-linux-musl" },
win32: { arm64: "aarch64-pc-windows-msvc", x64: "x86_64-pc-windows-msvc" },
};
const codexPlatformPackages = [
"@openai/codex-darwin-arm64",
"@openai/codex-darwin-x64",
"@openai/codex-linux-arm64",
"@openai/codex-linux-x64",
"@openai/codex-win32-arm64",
"@openai/codex-win32-x64",
];
const currentTriple = codexTargetTriple[process.platform]?.[process.arch];
const currentPackage = currentTriple
? `@openai/codex-${process.platform === "win32" ? "win32" : process.platform}-${process.arch}`
: null;
const openaiScope = path.join(runtimeRoot, "node_modules", "@openai");
for (const pkg of codexPlatformPackages) {
const pkgName = pkg.replace("@openai/", "");
if (currentPackage && pkg === currentPackage) {
continue;
}
await removeIfExists(path.join(openaiScope, pkgName));
}
}
async function pruneOpenCodeCli(runtimeRoot) {
const opencodePlatformPackages = [
"opencode-darwin-arm64",
"opencode-darwin-x64",
"opencode-linux-arm64",
"opencode-linux-x64",
"opencode-linux-x64-baseline",
"opencode-linux-arm64-musl",
"opencode-linux-x64-musl",
"opencode-linux-x64-baseline-musl",
"opencode-windows-x64",
"opencode-windows-arm64",
];
const platformMap = { darwin: "darwin", linux: "linux", win32: "windows" };
const currentPlatform = platformMap[process.platform];
const currentPackage = currentPlatform
? `opencode-${currentPlatform}-${process.arch}`
: null;
const nodeModules = path.join(runtimeRoot, "node_modules");
for (const pkg of opencodePlatformPackages) {
if (currentPackage && pkg === currentPackage) {
continue;
}
await removeIfExists(path.join(nodeModules, pkg));
}
}
async function pruneManagedRuntime(runtimeRoot) {
await Promise.all([
pruneNodeDistribution(runtimeRoot),
pruneOnnxRuntime(runtimeRoot),
pruneNodePty(runtimeRoot),
pruneClaudeAgentSdk(runtimeRoot),
pruneCodexCli(runtimeRoot),
pruneOpenCodeCli(runtimeRoot),
]);
}

View File

@@ -228,6 +228,50 @@ async function runBundledRuntimeCli(runtimeRoot, managedHome, args, env) {
return { stdout, stderr };
}
async function resolvePackagedRuntimeStatus(binaryPath) {
const binaryDir = path.dirname(binaryPath);
const resourceDirs = [
path.join(binaryDir, "resources"),
binaryDir,
path.join(binaryDir, "..", "Resources"),
path.join(binaryDir, "..", "resources"),
];
let bundledRoot = null;
for (const resourceDir of resourceDirs) {
for (const candidate of [
path.join(resourceDir, "resources", "managed-runtime"),
path.join(resourceDir, "managed-runtime"),
]) {
if (await pathExists(candidate)) {
bundledRoot = candidate;
break;
}
}
if (bundledRoot) {
break;
}
}
if (!bundledRoot) {
throw new Error("Managed runtime resources are not bundled with this desktop build.");
}
const pointer = JSON.parse(
await fs.readFile(path.join(bundledRoot, "current-runtime.json"), "utf8")
);
const runtimeRoot = path.join(bundledRoot, pointer.relativeRoot);
const manifest = JSON.parse(
await fs.readFile(path.join(runtimeRoot, "runtime-manifest.json"), "utf8")
);
return {
runtimeId: manifest.runtimeId,
runtimeVersion: manifest.runtimeVersion,
runtimeRoot,
};
}
async function readDaemonStatus(home, env) {
const result = await runWorkspaceCli(["daemon", "status", "--home", home, "--json"], env);
return result.json ?? {};
@@ -464,10 +508,6 @@ function logStep(label) {
console.log(`\n[managed-smoke] ${label}`);
}
function shouldAttemptCliShimInstall(env) {
return !(process.platform === "darwin" && env.CI === "true");
}
const packagedBinary = resolvePackagedBinary();
await ensurePackagedArtifact(packagedBinary);
if (!(await pathExists(packagedBinary))) {
@@ -527,6 +567,8 @@ const managedEnv = {
let externalPid = null;
let startedTemporaryExternalDaemon = false;
let relayProcess = null;
let runtimeStatus = null;
let managedStart = null;
const forbiddenManagedReferences = ["127.0.0.1:6767", fakePaseoHome, managedRuntimeDir];
const npmExecPath = process.env.npm_execpath;
if (!npmExecPath) {
@@ -584,168 +626,116 @@ try {
externalPid = externalStatus.pid;
assert.ok(externalPid, "external daemon pid should be present");
logStep("Bootstrapping managed runtime from packaged desktop binary");
const runtimeStatus = await runBinary(packagedBinary, ["--managed-headless", "runtime-status"], managedEnv);
assert.equal(runtimeStatus.json.runtimeId, currentRuntimeId);
assert.equal(runtimeStatus.json.runtimeVersion, currentRuntimeVersion);
assert.match(runtimeStatus.json.managedHome, new RegExp(`^${escapeForRegExp(testRoot)}`));
logStep("Resolving bundled runtime from packaged desktop artifact");
runtimeStatus = await resolvePackagedRuntimeStatus(packagedBinary);
assert.equal(runtimeStatus.runtimeId, currentRuntimeId);
assert.equal(runtimeStatus.runtimeVersion, currentRuntimeVersion);
assert.match(
runtimeStatus.json.runtimeRoot,
runtimeStatus.runtimeRoot,
new RegExp(escapeForRegExp(currentRuntimeId)),
"runtime status should point into the bundled runtime selected by current-runtime.json"
);
assert.equal(
runtimeStatus.json.runtimeRoot.startsWith(testRoot),
runtimeStatus.runtimeRoot.startsWith(testRoot),
false,
"runtime status should not resolve into managed app data"
);
assert.ok(runtimeStatus.json.transportType === "socket" || runtimeStatus.json.transportType === "pipe");
assert.notEqual(runtimeStatus.json.transportPath, "127.0.0.1:6767");
assertNoForbiddenPathsOrPorts(runtimeStatus.json, forbiddenManagedReferences);
assertNoForbiddenPathsOrPorts(runtimeStatus, forbiddenManagedReferences);
assert.equal(await pathExists(managedRuntimeDir), false, "managed app data should not gain a runtime tree");
const stateAfterRuntimeStatus = JSON.parse(
await fs.readFile(path.join(testRoot, "managed-state.json"), "utf8")
);
assert.equal(stateAfterRuntimeStatus.runtimeId, currentRuntimeId);
assert.equal(stateAfterRuntimeStatus.runtimeRoot, runtimeStatus.json.runtimeRoot);
const managedBootstrap = await runBinary(packagedBinary, ["--managed-headless", "bootstrap"], managedEnv);
const managedStart = managedBootstrap.json ?? await waitFor(
const managedBootstrap = await runBundledRuntimeCli(
runtimeStatus.runtimeRoot,
cliScratchHome,
["start", "--json"],
managedEnv
);
managedStart = (managedBootstrap.stdout.trim() ? JSON.parse(managedBootstrap.stdout.trim()) : null) ?? await waitFor(
async () => {
const status = await runBinary(
packagedBinary,
["--managed-headless", "daemon-status"],
const status = await runBundledRuntimeCli(
runtimeStatus.runtimeRoot,
cliScratchHome,
["daemon", "status", "--json"],
managedEnv
);
assert.equal(status.json?.daemonRunning, true);
assert.ok(status.json?.daemonPid, "managed daemon pid should exist");
return status.json;
const json = JSON.parse(status.stdout.trim());
assert.equal(json.status, "running");
assert.ok(json.pid, "managed daemon pid should exist");
assert.ok(json.serverId, "managed daemon should expose a server id");
return json;
},
10_000,
"managed daemon bootstrap status"
);
assert.equal(managedStart.daemonRunning, true);
assert.ok(managedStart.daemonPid, "managed daemon pid should exist");
assert.equal(managedStart.runtimeRoot, runtimeStatus.json.runtimeRoot);
assert.ok(
managedStart.transportType === "socket" || managedStart.transportType === "pipe",
"managed daemon should default to private IPC transport"
);
assert.notEqual(managedStart.transportPath, "127.0.0.1:6767");
assert.equal(managedStart.status, "running");
assert.ok(managedStart.pid, "managed daemon pid should exist");
assert.ok(managedStart.serverId, "managed daemon should expose a server id");
assert.ok(managedStart.home, "managed daemon should expose its home directory");
assert.ok(managedStart.listen, "managed daemon should expose its listen target");
assertNoForbiddenPathsOrPorts(managedStart, forbiddenManagedReferences);
assert.equal(await pathExists(managedRuntimeDir), false, "starting the daemon should not install a runtime copy");
const managedPid = managedStart.daemonPid;
const stateFile = path.join(testRoot, "managed-state.json");
assert.equal(await pathExists(stateFile), true, "managed state file should be written");
const managedPid = managedStart.pid;
logStep("Verifying the managed daemon stays alive after the packaged command exits");
await sleep(1_500);
const persistedManagedStatus = await runBinary(
packagedBinary,
["--managed-headless", "daemon-status"],
managedEnv
);
assert.equal(persistedManagedStatus.json.daemonPid, managedPid);
assert.equal(persistedManagedStatus.json.daemonRunning, true);
assert.equal(persistedManagedStatus.json.relayEnabled, true);
assertNoForbiddenPathsOrPorts(persistedManagedStatus.json, forbiddenManagedReferences);
const attemptCliShimInstall = shouldAttemptCliShimInstall(managedEnv);
const cliInstall = attemptCliShimInstall
? await (async () => {
logStep("Installing CLI shim and verifying the bundled CLI target");
return await runBinary(
packagedBinary,
["--managed-headless", "install-cli-shim"],
managedEnv
);
})()
: {
json: {
status: "skippedInCi",
installed: false,
path: null,
message: "Skipping privileged macOS CLI shim install in CI; verifying bundled CLI directly.",
},
};
const cliShimPath = cliInstall.json.path;
const cliShimInstalled =
Boolean(attemptCliShimInstall && cliShimPath) && cliInstall.json.installed === true && (await pathExists(cliShimPath));
if (attemptCliShimInstall) {
assert.ok(cliShimPath, "CLI shim path should be returned");
if (!cliShimInstalled) {
assert.ok(cliInstall.json.manualInstructions, "manual CLI install instructions should be returned");
assert.match(
cliInstall.json.manualInstructions.commands,
new RegExp(escapeForRegExp(runtimeStatus.json.runtimeRoot)),
"manual CLI install instructions should point at the bundled runtime"
);
assertNoForbiddenPathsOrPorts(cliInstall.json.manualInstructions, forbiddenManagedReferences);
}
} else {
logStep("Skipping privileged CLI shim install in CI and verifying the bundled CLI target directly");
}
const cliVersion = cliShimInstalled
? await execFileWithTimeout(cliShimPath, ["--version"], {
env: managedEnv,
cwd: repoRoot,
maxBuffer: 1024 * 1024,
}, "installed CLI shim version check")
: await runBundledRuntimeCli(
runtimeStatus.json.runtimeRoot,
managedStart.managedHome,
["--version"],
managedEnv
);
assert.match(cliVersion.stdout.trim(), /^0\./);
const shimStatus = cliShimInstalled
? await execFileWithTimeout(cliShimPath, ["daemon", "status", "--json"], {
env: managedEnv,
cwd: repoRoot,
maxBuffer: 1024 * 1024,
}, "installed CLI shim daemon status")
: await runBundledRuntimeCli(
runtimeStatus.json.runtimeRoot,
managedStart.managedHome,
const persistedManagedStatus = JSON.parse(
(
await runBundledRuntimeCli(
runtimeStatus.runtimeRoot,
managedStart.home,
["daemon", "status", "--json"],
managedEnv
);
const shimDaemonStatus = JSON.parse(shimStatus.stdout.trim());
assert.equal(shimDaemonStatus.pid, managedPid);
assertNoForbiddenPathsOrPorts(shimDaemonStatus, forbiddenManagedReferences);
)
).stdout.trim()
);
assert.equal(persistedManagedStatus.pid, managedPid);
assert.equal(persistedManagedStatus.status, "running");
assert.equal(persistedManagedStatus.serverId, managedStart.serverId);
assertNoForbiddenPathsOrPorts(persistedManagedStatus, forbiddenManagedReferences);
logStep("Verifying bundled CLI access without installing a shim");
const cliVersion = await runBundledRuntimeCli(
runtimeStatus.runtimeRoot,
managedStart.home,
["--version"],
managedEnv
);
assert.match(cliVersion.stdout.trim(), /^0\./);
const bundledCliStatus = await runBundledRuntimeCli(
runtimeStatus.runtimeRoot,
managedStart.home,
["daemon", "status", "--json"],
managedEnv
);
const bundledCliDaemonStatus = JSON.parse(bundledCliStatus.stdout.trim());
assert.equal(bundledCliDaemonStatus.pid, managedPid);
assertNoForbiddenPathsOrPorts(bundledCliDaemonStatus, forbiddenManagedReferences);
logStep("Verifying relay connectivity still works after the desktop command has exited");
const relayPairing = cliShimInstalled
? await execFileWithTimeout(
cliShimPath,
["daemon", "pair", "--home", managedStart.managedHome],
{
env: managedEnv,
cwd: repoRoot,
maxBuffer: 4 * 1024 * 1024,
},
"installed CLI shim relay pairing"
)
: await runBundledRuntimeCli(
runtimeStatus.json.runtimeRoot,
managedStart.managedHome,
["daemon", "pair", "--home", managedStart.managedHome],
managedEnv
);
const relayPairing = await runBundledRuntimeCli(
runtimeStatus.runtimeRoot,
managedStart.home,
["daemon", "pair"],
managedEnv
);
const relayOfferUrl = parseOfferUrlFromCommandOutput(relayPairing.stdout);
const relayOffer = decodeOfferFromFragmentUrl(relayOfferUrl);
assert.equal(relayOffer.relay?.endpoint, relayEndpoint);
const relayPong = await connectViaRelay(relayEndpoint, relayOffer);
assert.deepEqual(relayPong, { type: "pong" });
logStep("Reopening packaged desktop command path without spawning duplicate daemons");
const managedRestartless = await runBinary(
packagedBinary,
["--managed-headless", "bootstrap"],
managedEnv
logStep("Re-running managed start without spawning duplicate daemons");
const managedRestartless = JSON.parse(
(
await runBundledRuntimeCli(
runtimeStatus.runtimeRoot,
managedStart.home,
["start", "--json"],
managedEnv
)
).stdout.trim()
);
assert.equal(managedRestartless.json.daemonPid, managedPid);
assert.equal(managedRestartless.pid, managedPid);
logStep("Verifying managed and external daemons coexist");
const externalStatusAfter = await readDaemonStatus(externalHome, managedEnv);
@@ -753,53 +743,18 @@ try {
assert.equal(externalStatusAfter.status, "running");
assert.equal(externalPidAfter, externalPid);
await runWorkspaceCli(["ls", "--host", externalEndpoint, "--json"], managedEnv);
const managedStatus = await runBinary(
packagedBinary,
["--managed-headless", "daemon-status"],
managedEnv
const managedStatus = JSON.parse(
(
await runBundledRuntimeCli(
runtimeStatus.runtimeRoot,
managedStart.home,
["daemon", "status", "--json"],
managedEnv
)
).stdout.trim()
);
assert.ok(managedStatus.json.serverId, "managed daemon should expose a server id");
assertNoForbiddenPathsOrPorts(managedStatus.json, forbiddenManagedReferences);
logStep("Enabling managed TCP exposure on an explicit non-default port");
const tcpEnabled = await runBinary(
packagedBinary,
[
"--managed-headless",
"update-tcp",
"--enabled",
"true",
"--host",
"127.0.0.1",
"--port",
"7771",
],
managedEnv
);
assert.equal(tcpEnabled.json.tcpEnabled, true);
assert.equal(tcpEnabled.json.transportType, "tcp");
assert.equal(tcpEnabled.json.transportPath, "127.0.0.1:7771");
assert.notEqual(tcpEnabled.json.transportPath, "127.0.0.1:6767");
assertNoForbiddenPathsOrPorts(tcpEnabled.json, [fakePaseoHome]);
logStep("Disabling managed TCP exposure and returning to private transport");
const tcpDisabled = await runBinary(
packagedBinary,
[
"--managed-headless",
"update-tcp",
"--enabled",
"false",
"--host",
"127.0.0.1",
"--port",
"7771",
],
managedEnv
);
assert.equal(tcpDisabled.json.tcpEnabled, false);
assert.notEqual(tcpDisabled.json.transportType, "tcp");
assertNoForbiddenPathsOrPorts(tcpDisabled.json, forbiddenManagedReferences);
assert.ok(managedStatus.serverId, "managed daemon should expose a server id");
assertNoForbiddenPathsOrPorts(managedStatus, forbiddenManagedReferences);
logStep("Capturing diagnostics and verifying the fake ~/.paseo stayed untouched");
const fakePaseoSnapshotAfter = await snapshotTree(fakePaseoHome);
@@ -808,23 +763,19 @@ try {
path.join(testRoot, "managed-daemon-smoke-diagnostics.json"),
JSON.stringify(
{
runtimeStatus: runtimeStatus.json,
managedBootstrap: managedBootstrap.json,
runtimeStatus,
managedBootstrap: managedBootstrap.stdout.trim() ? JSON.parse(managedBootstrap.stdout.trim()) : null,
managedStart,
stateAfterRuntimeStatus,
persistedManagedStatus: persistedManagedStatus.json,
managedRestartless: managedRestartless.json,
cliInstall: cliInstall.json,
shimDaemonStatus,
persistedManagedStatus,
managedRestartless,
bundledCliDaemonStatus,
relayEndpoint,
relayOfferUrl,
relayPong,
externalEndpoint,
externalPid,
externalPidAfter,
managedStatus: managedStatus.json,
tcpEnabled: tcpEnabled.json,
tcpDisabled: tcpDisabled.json,
managedStatus,
},
null,
2
@@ -836,11 +787,13 @@ try {
} finally {
clearInterval(heartbeat);
try {
await runBinary(packagedBinary, ["--managed-headless", "stop-daemon"], managedEnv);
} catch {}
try {
if (shouldAttemptCliShimInstall(managedEnv)) {
await runBinary(packagedBinary, ["--managed-headless", "uninstall-cli-shim"], managedEnv);
if (runtimeStatus?.runtimeRoot) {
await runBundledRuntimeCli(
runtimeStatus.runtimeRoot,
managedStart?.home ?? cliScratchHome,
["daemon", "stop", "--json"],
managedEnv
);
}
} catch {}
try {

View File

@@ -74,6 +74,17 @@ function needsHardenedRuntime(fileKind) {
return fileKind.includes("executable");
}
function extractEntitlements(file) {
const result = spawnSync("codesign", ["-d", "--entitlements", "-", "--xml", file], {
stdio: "pipe",
encoding: "utf8",
});
if (result.status !== 0 || !result.stdout || result.stdout.trim().length === 0) {
return null;
}
return result.stdout;
}
async function main() {
const pointer = JSON.parse(
await fs.readFile(path.join(resourcesRoot, "current-runtime.json"), "utf8")
@@ -99,6 +110,15 @@ async function main() {
signTargets.sort((left, right) => left.file.localeCompare(right.file));
for (const target of signTargets) {
let entitlementsFile = null;
if (target.needsRuntime) {
const entitlements = extractEntitlements(target.file);
if (entitlements) {
entitlementsFile = `${target.file}.entitlements.plist`;
await fs.writeFile(entitlementsFile, entitlements, "utf8");
}
}
const args = [
"--force",
"--sign",
@@ -107,10 +127,17 @@ async function main() {
];
if (target.needsRuntime) {
args.push("--options", "runtime");
if (entitlementsFile) {
args.push("--entitlements", entitlementsFile);
}
}
args.push(target.file);
console.log(`[managed-runtime-sign] ${path.relative(repoRoot, target.file)}`);
run("codesign", args);
if (entitlementsFile) {
await fs.unlink(entitlementsFile);
}
}
}

View File

@@ -55,8 +55,28 @@ for (const pkg of ["@getpaseo/relay", "@getpaseo/server", "@getpaseo/cli"]) {
});
}
const sherpaPlatformMap = {
darwin: "darwin",
linux: "linux",
win32: "win",
};
const sherpaPlatform = sherpaPlatformMap[manifest.platform];
assert.ok(
sherpaPlatform,
`Unsupported sherpa platform mapping for managed runtime validation: ${manifest.platform}`
);
const sherpaNativePackage = `sherpa-onnx-${sherpaPlatform}-${manifest.arch}`;
const sherpaNativePackageDir = path.join(runtimeRoot, "node_modules", sherpaNativePackage);
await fs.access(sherpaNativePackageDir).catch(() => {
throw new Error(
`Missing bundled native speech dependency: ${sherpaNativePackage} (expected at ${sherpaNativePackageDir})`
);
});
console.log(`[validate-managed-runtime] PASS`);
console.log(` runtimeId: ${manifest.runtimeId}`);
console.log(` version: ${expectedVersion}`);
console.log(` platform: ${manifest.platform}`);
console.log(` arch: ${manifest.arch}`);
console.log(` sherpaNativePackage: ${sherpaNativePackage}`);

View File

@@ -2629,10 +2629,11 @@ dependencies = [
[[package]]
name = "paseo"
version = "0.1.24"
version = "0.1.26"
dependencies = [
"base64 0.22.1",
"dirs",
"dunce",
"futures-util",
"http",
"log",
@@ -2644,6 +2645,7 @@ dependencies = [
"tauri-plugin-log",
"tauri-plugin-notification",
"tauri-plugin-opener",
"tauri-plugin-single-instance",
"tauri-plugin-updater",
"tauri-plugin-websocket",
"tokio",
@@ -4298,6 +4300,21 @@ dependencies = [
"zbus",
]
[[package]]
name = "tauri-plugin-single-instance"
version = "2.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acba6b5ca527a96cdfcc96ae09b09ccb91ddff5e33978ca6873b96ea16bb404c"
dependencies = [
"serde",
"serde_json",
"tauri",
"thiserror 2.0.18",
"tracing",
"windows-sys 0.60.2",
"zbus",
]
[[package]]
name = "tauri-plugin-updater"
version = "2.9.0"

View File

@@ -1,6 +1,6 @@
[package]
name = "paseo"
version = "0.1.24"
version = "0.1.26"
description = "Paseo Desktop"
authors = ["moboudra"]
license = "MIT"
@@ -24,6 +24,7 @@ tauri-build = { version = "2.5.3", features = [] }
[dependencies]
base64 = "0.22"
dirs = "6"
dunce = "1"
futures-util = "0.3"
http = "1"
serde_json = "1.0"
@@ -34,6 +35,7 @@ tauri-plugin-dialog = "2"
tauri-plugin-log = "2"
tauri-plugin-notification = "2"
tauri-plugin-opener = "2"
tauri-plugin-single-instance = "2"
tauri-plugin-updater = "2"
tauri-plugin-websocket = "2"
tokio = { version = "1", features = ["net", "sync"] }
@@ -47,3 +49,4 @@ tokio-tungstenite = "0.24"

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