mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69d8427bd9 | ||
|
|
d8ccbd5c32 | ||
|
|
be93f8b240 | ||
|
|
b09a731d85 | ||
|
|
554017b0b8 | ||
|
|
9f089be946 | ||
|
|
213f155c9c | ||
|
|
9e6b45e2f0 | ||
|
|
355e56db53 | ||
|
|
a79134ec9e | ||
|
|
91dde29146 | ||
|
|
586b48e150 | ||
|
|
aeefa22ddf | ||
|
|
9f3ef07322 | ||
|
|
e2cb67462d | ||
|
|
b60d253926 | ||
|
|
ee156adffb | ||
|
|
c99b78f5b0 | ||
|
|
4c6d21af4a | ||
|
|
327b315610 | ||
|
|
78849fa0bf | ||
|
|
e5014a5f57 | ||
|
|
5bf698ff84 | ||
|
|
eb5f011161 | ||
|
|
acfb933ee8 | ||
|
|
c37684b246 | ||
|
|
e2068e3d72 | ||
|
|
443eb16e67 | ||
|
|
240dc26013 | ||
|
|
6b07555a46 | ||
|
|
b69bd5271b | ||
|
|
cf4cae2c7d | ||
|
|
d51f18a2f7 | ||
|
|
006db65f08 | ||
|
|
f21221c1e1 | ||
|
|
9faa88e13b | ||
|
|
faf1eed0ab |
60
.github/workflows/desktop-release.yml
vendored
60
.github/workflows/desktop-release.yml
vendored
@@ -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
2
.gitignore
vendored
@@ -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
|
||||
|
||||
24
CHANGELOG.md
24
CHANGELOG.md
@@ -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
281
CLAUDE.md
@@ -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
67
docs/ANDROID.md
Normal 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
184
docs/ARCHITECTURE.md
Normal 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
174
docs/CODING_STANDARDS.md
Normal 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
73
docs/DESIGN.md
Normal 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
130
docs/DEVELOPMENT.md
Normal 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
48
docs/RELEASE.md
Normal 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
123
docs/TESTING.md
Normal 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
313
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.24",
|
||||
"version": "0.1.26",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/server",
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
36
packages/app/src/app/index-startup.ts
Normal file
36
packages/app/src/app/index-startup.ts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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={{
|
||||
|
||||
5
packages/app/src/app/welcome.tsx
Normal file
5
packages/app/src/app/welcome.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { WelcomeScreen } from '@/components/welcome-screen'
|
||||
|
||||
export default function WelcomeRoute() {
|
||||
return <WelcomeScreen />
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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" })
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
},
|
||||
}));
|
||||
}))
|
||||
|
||||
@@ -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."
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
28
packages/app/src/polyfills/screen-orientation.ts
Normal file
28
packages/app/src/polyfills/screen-orientation.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
303
packages/app/src/types/host-connection.ts
Normal file
303
packages/app/src/types/host-connection.ts
Normal 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))
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { HostConnection } from "@/contexts/daemon-registry-context";
|
||||
import type { HostConnection } from "@/types/host-connection";
|
||||
|
||||
export type ConnectionCandidate = {
|
||||
connectionId: string;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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";
|
||||
@@ -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 });
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
55
packages/app/src/utils/tauri-attach-console.ts
Normal file
55
packages/app/src/utils/tauri-attach-console.ts
Normal 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
|
||||
};
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(' ')}`
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
23
packages/cli/src/utils/command-options.ts
Normal file
23
packages/cli/src/utils/command-options.ts
Normal 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))
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
19
packages/desktop/src-tauri/Cargo.lock
generated
19
packages/desktop/src-tauri/Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user