Compare commits

...
Author SHA1 Message Date
Mohamed Boudra 58d1767a64 Fix mobile sidebar reset after theme switch 2026-03-28 09:59:50 +07:00
Mohamed Boudra 0ab346e5a2 fix(desktop): restore windows daemon bootstrap logging 2026-03-27 22:48:27 +07:00
Mohamed Boudra a8c04c26b9 fix: hardcode paseo://app as allowed CORS origin in daemon bootstrap
The desktop app uses the paseo:// custom protocol scheme, but the
origin was only passed via PASEO_CORS_ORIGINS when the desktop started
the daemon itself. If the daemon was started by the CLI, the origin
was missing and the Electron renderer's WebSocket connection was
rejected.

Also removes file:// and null from allowed origins — any page loaded
via file:// could connect to the daemon, which is a security gap.
2026-03-27 21:40:02 +07:00
Mohamed Boudra e2b2b7c701 chore(release): cut 0.1.37 2026-03-27 21:11:53 +07:00
Mohamed Boudra af35cccb5f fix(release): skip release notes sync when no changelog entry exists
Draft releases don't have changelog entries, so the script should log
and continue rather than throwing.
2026-03-27 21:10:03 +07:00
github-actions[bot] 6342057a6f fix: update lockfile signatures and Nix hash 2026-03-27 14:00:20 +00:00
Mohamed Boudra 89923b5f76 fix(ci): default to draft release when no GitHub release exists
The fallback was "release", so pushing a tag without pre-creating a
draft release on GitHub caused electron-builder to create a published
release instead of a draft.
2026-03-27 20:59:07 +07:00
Mohamed Boudra 54b5a22688 fix(windows): broken PATH propagation, ps usage, and claude binary resolution
- sherpa-runtime-env: use case-insensitive key lookup when modifying PATH
  on plain env objects. On Windows, `{...process.env}` stores PATH as
  `Path` but `applySherpaLoaderEnv` used hardcoded `"PATH"`, creating a
  duplicate key that could shadow the real system PATH in child processes.

- runtime-toolchain: use `wmic` on Windows instead of Unix-only `ps -o`
  to resolve the node executable path from a PID.

- claude-agent: pass `findExecutable("claude")` as
  `pathToClaudeCodeExecutable` so the SDK uses the user's installed
  binary when available. Update spawn hook to only replace bare
  "node"/"bun" with process.execPath, preserving native binary paths.

- desktop/paseo.cmd: use ELECTRON_RUN_AS_NODE with node-entrypoint-runner
  for the Windows CLI wrapper.
2026-03-27 20:50:18 +07:00
Mohamed Boudra ab3443fd52 feat(desktop): hide native titlebar on Windows/Linux with overlay window controls
Use titleBarStyle: 'hidden' on all platforms. On Windows/Linux, enable
titleBarOverlay with dark theme colors for native min/max/close buttons.
Extend useTrafficLightPadding to return side ('left'|'right'|null) so
consumers apply padding on the correct side per platform.
2026-03-27 19:59:17 +07:00
Mohamed Boudra 95ad879f82 fix: show toast on dictation errors instead of silent console log 2026-03-27 19:27:29 +07:00
Mohamed Boudra 80e153f861 feat(desktop): add file logging with electron-log
Logs from both main and renderer processes now persist to
~/Library/Logs/Paseo/main.log (macOS) with automatic rotation.
Removes the unused webview_log IPC handler.
2026-03-27 19:22:40 +07:00
Mohamed Boudra dfa376f5ca docs: add 0.1.36 changelog entry 2026-03-27 18:48:06 +07:00
Mohamed Boudra 76c357c88c fix: run release:check before version bump to prevent dirty state on failure 2026-03-27 18:44:18 +07:00
github-actions[bot] 5adde123e3 fix: update lockfile signatures and Nix hash 2026-03-27 11:32:43 +00:00
Mohamed Boudra 57291b1fd9 chore(release): cut 0.1.36 2026-03-27 18:30:49 +07:00
Mohamed Boudra 89f285ffd0 docs: clarify draft release flow and changelog dependency 2026-03-27 18:29:53 +07:00
Mohamed Boudra e6c06e2263 Merge branch 'main' of github.com:getpaseo/paseo 2026-03-27 18:18:56 +07:00
Mohamed Boudra ca443b3ecf fix: handle Windows drive-letter paths across the codebase (#148)
* Add metrics collection and terminal performance tests

* fix: handle Windows drive-letter paths across the codebase

Windows paths like C:\Users\foo\project were broken in multiple places:
- agent-storage slugified D:\MyProject as D:-MyProject (illegal colon)
- terminal-manager rejected all non-/ paths as relative
- bootstrap parser misparsed drive colons as TCP host:port
- daemon/client connection helpers misclassified Windows paths
- CLI cwd filtering used hardcoded / separators
- checkout-git worktree detection used hardcoded / in path checks
- worktree archive used split("/").pop() instead of path.basename()

All path helpers now normalize separators and handle Windows
drive letters with case-insensitive comparison where needed.
2026-03-27 18:17:48 +07:00
Mohamed Boudra d8e9298222 Add metrics collection and terminal performance tests 2026-03-26 23:38:32 +07:00
Mohamed Boudra c667aca3e0 chore: remove stale agent-event-stream-redesign doc 2026-03-26 20:56:03 +07:00
Mohamed Boudra 7892b3cbe1 fix(nix): update stale npmDepsHash and auto-fix on all lockfile changes
Hash mismatch was hidden by continue-on-error. Now the Nix Build
workflow fails visibly, and fix-nix-hash runs on every push/PR that
touches the lockfile (not just Dependabot).
2026-03-26 20:19:12 +07:00
Mohamed Boudra f2000ff789 chore(release): cut 0.1.35 2026-03-26 18:14:00 +07:00
Mohamed Boudra 759815cfac docs: add v0.1.35 changelog 2026-03-26 18:13:33 +07:00
Mohamed Boudra 68df304867 refactor(app): move startup routing logic into welcome screen and simplify index route 2026-03-26 18:10:21 +07:00
Mohamed Boudra a2aac3797f fix(server): translate Codex file deletions as removed lines instead of added lines
Codex delete operations were displayed as edits with green (added) lines
because the translation pipeline didn't distinguish deletes from edits.
Now detects kind="delete" and *** Delete File directives, producing proper
unified diffs with removed lines and +++ /dev/null headers.
2026-03-26 17:53:57 +07:00
Mohamed Boudra f13178a9ad Replace mapfile with portable while-read loop in chat script
mapfile is a bash 4+ builtin and fails on macOS default bash 3.2
with "command not found".
2026-03-26 17:51:56 +07:00
Mohamed Boudra c1d71dfedc Fix queued prompt dispatch after idle transition 2026-03-26 15:14:05 +07:00
Mohamed Boudra 2e7bc49c4c Make mobile mockup horizontally scrollable on small screens
Prevents the 4-phone composite image from shrinking to illegibility
on mobile by setting a min-width and enabling horizontal scroll.
2026-03-26 15:12:53 +07:00
Mohamed Boudra 55dd3a9b1a Add Homebrew Cask install to downloads page, fix APK URL
- Extract CommandDialog shared component from landing page's ServerInstallButton
- Add Homebrew pill to macOS section on download page that opens install dialog
- Add ESC key support to close the command dialog
- Fix APK download URL (paseo-v${version}-android.apk)
2026-03-26 15:08:16 +07:00
Mohamed Boudra 5b3aea8bfe fix(server): surface opencode questions in permission UI 2026-03-26 14:49:52 +07:00
Mohamed Boudra e47eb64de0 Add privacy-first to README, tweak sponsor copy 2026-03-26 11:49:10 +07:00
Mohamed Boudra eada5eec53 Update landing page: mobile section, sponsor copy, muted color consistency 2026-03-26 11:46:16 +07:00
Mohamed Boudra 1ee7cace36 Update orchestration skills description 2026-03-26 11:20:41 +07:00
Mohamed Boudra 612731a5b5 Add syntax highlighting to skill examples 2026-03-26 11:18:36 +07:00
Mohamed Boudra f50252c3b1 Add descriptions to skill examples in README 2026-03-26 11:17:54 +07:00
Mohamed Boudra 3cd1552ddc Add CLI and orchestration skills sections to README 2026-03-26 11:13:59 +07:00
Mohamed Boudra caab3f9686 Update subtitle and mobile mockup 2026-03-26 11:05:53 +07:00
Mohamed Boudra bf8ba4da8f Update mockups and add mobile section to landing page 2026-03-26 10:59:59 +07:00
Mohamed Boudra 3fcc1ecedb Clarify getting started with two paths: desktop app and headless 2026-03-26 09:57:42 +07:00
Mohamed Boudra 46d7512cc4 fix(website): correct linux release asset links 2026-03-26 09:17:49 +07:00
Mohamed Boudra 00e172798b Update website download CLI command 2026-03-25 23:50:27 +07:00
José AlbornozandMohamed Boudra bb560809c7 Add support for Nix and NixOS (#130)
* fix: add missing resolved/integrity fields to package-lock.json

npm omits resolved URLs and integrity hashes for workspace-local
node_modules overrides. This breaks offline installers like Nix's
npm ci. Add the missing fields for 25 workspace-hoisted packages.

* feat: add Nix flake with package and NixOS module

Add a Nix flake that builds the Paseo daemon (server + CLI) and
provides a NixOS module for declarative deployment.

Package (nix/package.nix):
- Builds relay, server, and CLI workspaces
- Skips onnxruntime-node install script (sandbox-incompatible)
- Rebuilds only node-pty for native terminal support
- Source filter excludes app/website/desktop workspaces

NixOS module (nix/module.nix):
- Systemd service with configurable user, port, listen address
- allowedHosts for DNS rebinding protection
- relay.enable to toggle remote access via app.paseo.sh
- inheritUserEnvironment to expose user tools (git, ssh) to agents
- openFirewall and extra environment variables

ci: add Nix hash maintenance scripts and workflows

scripts/fix-lockfile.mjs:
  Adds missing resolved/integrity fields to package-lock.json for
  workspace-local overrides. Idempotent, uses `npm view`.

scripts/update-nix.sh:
  Runs fix-lockfile.mjs, prefetches deps, computes NAR hash, and
  updates npmDepsHash in nix/package.nix. Supports --check for CI.

.github/workflows/nix-build.yml:
  Builds the Nix package on push/PR and verifies the lockfile and
  hash are up to date.

.github/workflows/fix-nix-hash.yml:
  Auto-fixes lockfile signatures and Nix hash on dependabot PRs.

fix: update npmDepsHash after upstream sync

nix: allowlist workspace symlinks instead of blocklist

Prevents build failures when upstream adds new workspace packages.

* don't block PRs on nix failures

* better document npm workaround

* fix hash update script, and update hash

* integrate with npm run build:daemon

* ci: trigger nix build on highlight changes

* fix(nix): update npmDepsHash

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-03-25 23:42:44 +07:00
Mohamed Boudra 765a11c7ab fix(metadata): update project contact email 2026-03-25 23:36:55 +07:00
Mohamed Boudra 08e37540eb refactor(desktop): sync package metadata from root 2026-03-25 23:34:02 +07:00
Mohamed Boudra 1768e2787f fix(desktop): align website assets and linux package metadata 2026-03-25 23:31:41 +07:00
Mohamed Boudra 6d467d4659 refactor(website): extract shared download constants and icons into ~/downloads
Single source of truth for version, release URLs, store URLs, platform
detection, and shared icon components between landing page and /download.
2026-03-25 23:21:47 +07:00
Mohamed Boudra 701480c11c Add /download page and link in nav
Desktop (macOS, Windows, Linux with AppImage/DEB/RPM), Mobile (Android/iOS),
and Web & CLI sections. Simplify homepage download button to a single link
with "All download options" text link below.
2026-03-25 23:06:35 +07:00
Mohamed Boudra 9ce6b45cf9 fix(desktop): make mac artifact names explicit and add deb/rpm 2026-03-25 23:05:17 +07:00
Mohamed Boudra 7eebee2de0 Fix Claude model catalog CI assertion 2026-03-25 22:49:14 +07:00
Mohamed Boudra ff2fc72581 chore(release): cut 0.1.34 2026-03-25 22:33:28 +07:00
Mohamed Boudra cdd7071885 docs(changelog): draft 0.1.34 notes 2026-03-25 22:33:04 +07:00
Mohamed Boudra 30cba9550e Fix attach button trigger and Claude interrupt test race 2026-03-25 22:31:24 +07:00
Mohamed Boudra 1318372e81 Remove dead agent-prompt and terminal-mcp code
Delete unused system-prompt.ts, agent-prompt.md, and the entire
terminal-mcp/ directory (tmux-based MCP server) — nothing imports
any of these. The codebase uses src/terminal/ (node-pty) instead.
2026-03-25 21:54:34 +07:00
Mohamed Boudra 185990f46b Update project row workspace action icon 2026-03-25 21:01:32 +07:00
Mohamed Boudra 508cd524a8 Tighten Claude partial tool input parsing 2026-03-25 20:38:43 +07:00
106 changed files with 4040 additions and 4085 deletions
+3 -3
View File
@@ -124,7 +124,7 @@ jobs:
release_type="release"
fi
else
release_type="release"
release_type="draft"
fi
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
@@ -230,7 +230,7 @@ jobs:
release_type="release"
fi
else
release_type="release"
release_type="draft"
fi
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
@@ -339,7 +339,7 @@ jobs:
release_type="release"
fi
else
release_type="release"
release_type="draft"
fi
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
+45
View File
@@ -0,0 +1,45 @@
name: Fix Nix hash
on:
push:
branches: [main]
paths:
- 'package.json'
- 'package-lock.json'
pull_request:
paths:
- 'package.json'
- 'package-lock.json'
permissions:
contents: write
jobs:
fix-nix-hash:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.head_ref || github.ref }}
token: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- uses: cachix/install-nix-action@v31
with:
nix_path: nixpkgs=channel:nixos-unstable
- name: Fix lockfile and update hash
run: ./scripts/update-nix.sh
- name: Commit changes
run: |
git diff --quiet package-lock.json nix/package.nix && exit 0
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add package-lock.json nix/package.nix
git commit -m "fix: update lockfile signatures and Nix hash"
git push
+59
View File
@@ -0,0 +1,59 @@
name: Nix Build
on:
push:
branches: [main]
paths:
- 'nix/**'
- 'flake.nix'
- 'flake.lock'
- 'package.json'
- 'package-lock.json'
- 'packages/highlight/**'
- 'packages/server/**'
- 'packages/relay/**'
- 'packages/cli/**'
pull_request:
branches: [main]
paths:
- 'nix/**'
- 'flake.nix'
- 'flake.lock'
- 'package.json'
- 'package-lock.json'
- 'packages/highlight/**'
- 'packages/server/**'
- 'packages/relay/**'
- 'packages/cli/**'
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
continue-on-error: false
steps:
- uses: actions/checkout@v4
- uses: cachix/install-nix-action@v31
with:
nix_path: nixpkgs=channel:nixos-unstable
- name: Build Nix package
run: nix build .#default -o result
- name: Verify lockfile is complete
# npm silently omits resolved/integrity fields in workspace monorepos.
# Nix needs them for offline builds. See https://github.com/npm/cli/issues/4460
run: |
node scripts/fix-lockfile.mjs package-lock.json
git diff --exit-code package-lock.json || {
echo "ERROR: package-lock.json has missing resolved/integrity fields."
echo "This is a known npm bug: https://github.com/npm/cli/issues/4460"
echo "Run 'node scripts/fix-lockfile.mjs' and commit the result."
exit 1
}
- name: Check npmDepsHash is up to date
run: ./scripts/update-nix.sh --check
+53
View File
@@ -1,5 +1,58 @@
# Changelog
## 0.1.36 - 2026-03-27
### Fixed
- Fixed Windows drive-letter path handling across the codebase.
- Fixed stale Nix hash with automatic lockfile-change detection.
### Added
- Added metrics collection and terminal performance tests.
## 0.1.35 - 2026-03-26
### Improved
- Faster app startup by redirecting to the welcome screen immediately and showing host connection status inline.
- Codex file deletions now display correctly as removed lines in diffs.
- OpenCode questions are now surfaced in the permission UI.
### Fixed
- Fixed queued prompt dispatch after idle transition.
- Replaced bash-only `mapfile` with a portable `while-read` loop in the chat script.
### Added
- Added support for Nix and NixOS installation.
## 0.1.34 - 2026-03-25
### Added
- Added `paseo archive` as a top-level alias for `paseo agent archive`.
- Added the `PASEO_AGENT_ID` environment variable for Claude and Codex agents.
- Added a redesigned command autocomplete with a detail card and dropdown styling.
- Linked Android download surfaces to the Google Play Store.
### Improved
- Autonomous turns now complete gracefully on interrupt instead of being canceled.
- Thinking/model selection now always resolves to a real option instead of showing a generic Default choice.
- Restored per-provider form preferences and removed the Auto model fallback.
- Improved Codex activity logs with clearer tool-call summaries.
- Reduced unnecessary re-renders in the agent panel and input area for smoother interaction.
- Improved chat transcript readability.
### Fixed
- Fixed `paseo send --no-wait` not taking effect.
- Fixed stale abort results contaminating replacement turns after an interrupt.
- Fixed Claude interrupt handling and autonomous wake reliability.
- Fixed nested Claude Code session detection and provider availability checks.
- Fixed agent input focus scoping across panels.
- Fixed terminal snapshot ordering when subscribing.
- Fixed `chat read --since` to accept message IDs.
- Fixed keyboard pane focus syncing with the active panel.
- Fixed assistant text selection on web.
- Fixed archived-agent notifications still appearing in chat rooms.
- Fixed the attach-images button interaction in the message composer.
- Pruned wrong-platform native binaries from Electron desktop builds.
## 0.1.33 - 2026-03-23
### Fixed
+61 -9
View File
@@ -4,40 +4,88 @@
<h1 align="center">Paseo</h1>
<p align="center">One interface for all your coding agents.</p>
<p align="center">One interface for all your Claude Code, Codex and OpenCode agents.</p>
<p align="center">
<img src="https://paseo.sh/paseo-mockup.png" alt="Paseo app screenshot" width="100%">
<img src="https://paseo.sh/hero-mockup.png" alt="Paseo app screenshot" width="100%">
</p>
<p align="center">
<img src="https://paseo.sh/mobile-mockup.png" alt="Paseo mobile app" width="100%">
</p>
---
Run agents in parallel on your own machines. Ship from your phone or your desk.
- **Self-hosted** — Agents run on your machine with your full dev environment. Use your tools, your configs, and your skills.
- **Multi-provider** — Claude Code, Codex, and OpenCode through the same interface. Pick the right model for each job.
- **Voice control** — Dictate tasks or talk through problems in voice mode. Hands-free when you need it.
- **Cross-device** — iOS, Android, desktop, web, and CLI. Start work at your desk, check in from your phone, script it from the terminal.
- **Self-hosted:** Agents run on your machine with your full dev environment. Use your tools, your configs, and your skills.
- **Multi-provider:** Claude Code, Codex, and OpenCode through the same interface. Pick the right model for each job.
- **Voice control:** Dictate tasks or talk through problems in voice mode. Hands-free when you need it.
- **Cross-device:** iOS, Android, desktop, web, and CLI. Start work at your desk, check in from your phone, script it from the terminal.
- **Privacy-first:** Paseo doesn't have any telemetry, tracking, or forced log-ins.
## Getting Started
Download the desktop app from [paseo.sh](https://paseo.sh) or the [GitHub releases page](https://github.com/getpaseo/paseo/releases) — it bundles the daemon so there's nothing else to install.
### Desktop app
Download from [paseo.sh/download](https://paseo.sh/download) or the [GitHub releases page](https://github.com/getpaseo/paseo/releases). The app bundles its own daemon, so there's nothing else to install. It can also connect to daemons running on other machines.
### Headless / server mode
To run the daemon on a remote or headless machine:
Run the daemon on any machine:
```bash
npm install -g @getpaseo/cli
paseo
```
Then connect from the desktop app, mobile app, or CLI.
Then connect from any client — desktop, web, mobile, or CLI. See [paseo.sh/download](https://paseo.sh/download) for all options.
For full setup and configuration, see:
- [Docs](https://paseo.sh/docs)
- [Configuration reference](https://paseo.sh/docs/configuration)
## CLI
Everything you can do in the app, you can do from the terminal.
```bash
paseo run --provider claude/opus-4.6 "implement user authentication"
paseo run --provider codex/gpt-5.4 --worktree feature-x "implement feature X"
paseo ls # list running agents
paseo attach abc123 # stream live output
paseo send abc123 "also add tests" # follow-up task
# run on a remote daemon
paseo --host workstation.local:6767 run "run the full test suite"
```
See the [full CLI reference](https://paseo.sh/docs/cli) for more.
## Orchestration skills (Unstable)
Experimental skills that teach agents how to use the Paseo CLI to orchestrate other agents. I am updating these very frequently as I learn new things, expect changes without notice, might be coupled to my own setup, use at your own risk.
```bash
npx skills add getpaseo/paseo
```
Then use them in any agent conversation:
```bash
# Use handoff when you discuss something with an agent but want another one to implement.
# I use this to plan with Claude and then handoff to Codex to implement.
/paseo-handoff hand off the authentication fix to codex 5.4 in a worktree
# Use loops when you have clear acceptance criteria (aka Ralph loops).
/paseo-loop loop a codex agent to fix the backend tests, use sonnet to verify, max 10 iterations
# Orchestrator teaches the agent how to create teams and manage them via a chat room.
# Very opinionated and expects both Codex and Claude to work.
/paseo-orchestrator spin up a team to implement the database refactor, use chat to coordinate. use claude to plan and codex to implement and review
```
## Development
Quick monorepo package map:
@@ -57,8 +105,12 @@ npm run dev
# run individual surfaces
npm run dev:server
npm run dev:app
npm run dev:desktop
npm run dev:website
# build the daemon
npm run build:daemon
# repo-wide checks
npm run typecheck
```
+1 -1
View File
@@ -51,4 +51,4 @@ Paseo wraps agent CLIs (Claude Code, Codex, OpenCode) but does not manage their
## Reporting vulnerabilities
If you discover a security vulnerability, please report it privately by emailing mo@faro.so. Do not open a public issue.
If you discover a security vulnerability, please report it privately by emailing hello@moboudra.com. Do not open a public issue.
+16 -3
View File
@@ -15,8 +15,8 @@ If asked to "release paseo" without specifying major/minor, treat it as a patch
## Manual step-by-step
```bash
npm run release:check # Typecheck, build, dry-run pack
npm run version:all:patch # Bump version, create commit + tag
npm run release:check # Validate release
npm run release:publish # Publish to npm
npm run release:push # Push HEAD + tag (triggers CI workflows)
```
@@ -25,6 +25,7 @@ npm run release:push # Push HEAD + tag (triggers CI workflows)
```bash
npm run draft-release:patch # Bump, push tag, create draft GitHub Release
# ... test builds from the draft release assets ...
npm run release:finalize # Publish npm, promote draft to published
```
@@ -32,6 +33,7 @@ npm run release:finalize # Publish npm, promote draft to published
- `release:finalize` publishes npm and promotes the same draft release
- Use the same semver tag for both; don't cut a second tag
- Desktop assets now come from the Electron package at `packages/desktop`
- **Do NOT create a changelog entry for drafts.** The changelog entry is written only when finalizing. The website parses `CHANGELOG.md` to determine the latest published version for download links — adding an entry for a draft will point the homepage at untested assets.
## Fixing a failed release build
@@ -62,12 +64,23 @@ This ensures the checkout ref matches the actual code on `main` with the fix inc
- `release:prepare` refreshes workspace `node_modules` links to prevent stale types
- `npm run dev:desktop` and `npm run build:desktop` target the Electron desktop package in `packages/desktop`
- If `release:publish` partially fails, re-run it — npm skips already-published versions
- Website Mac download CTA URL derives from `packages/website/package.json` version at build time
- The website parses the first `## X.Y.Z` heading in `CHANGELOG.md` to determine the download version. This is why changelog entries must only be added at finalization, not during drafts.
## Changelog format
The website depends on the changelog to determine the latest download version. The heading format **must** be strictly followed:
```
## X.Y.Z - YYYY-MM-DD
```
No prefix (`v`), no extra text. The parser matches the first `## X.Y.Z` line to extract the version. A malformed heading will break download links on the homepage.
## Completion checklist
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors)
- [ ] `npm run release:patch` completes successfully
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
- [ ] `npm run release:patch` (or `release:finalize` for drafts) 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
-170
View File
@@ -1,170 +0,0 @@
# Agent Event Stream Redesign
Status: **Implemented** (2026-03-24)
## Problem
The Claude provider had three event paths delivering the same events to the agent-manager:
1. **Foreground stream** (`stream()` → `activeForegroundTurn.queue`)
2. **Live event pump** (`streamLiveEvents()` → `liveEventQueue`) fed by the query pump
3. **JSONL history poller** (`startLiveHistoryPolling()` → `routeSdkMessageFromPump()`)
Routing between paths was timing-based (`Boolean(activeForegroundTurn)`, `pendingRun`). This caused:
- **Duplicate user messages**: trailing SDK events routed to the live queue after `activeForegroundTurn` cleared
- **Stuck running state**: stale `turn_started` from the live path flipped lifecycle back to `running` after finalize set it to terminal
- **Fragile dedup**: `shouldSuppressLiveUserMessageEcho` checked `pendingRun` (already null) and `messageId` (Claude assigns its own UUID)
Codex and OpenCode were stable because they had ONE event path with no routing decision.
## Design
### Core principle
One event source per provider session. Identity-based turn ownership, not timing-based routing.
### Provider contract (`AgentSession`)
```typescript
interface AgentSession {
readonly provider: AgentProvider;
readonly id: string | null;
readonly capabilities: AgentCapabilityFlags;
// Turn lifecycle
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
interrupt(): Promise<void>;
// Event delivery (push-based)
subscribe(callback: (event: AgentStreamEvent) => void): () => void;
// History (hydration only — never live dispatch)
streamHistory(): AsyncGenerator<AgentStreamEvent>;
// Run (uses startTurn + subscribe internally)
run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
// Session metadata (unchanged)
getRuntimeInfo(): Promise<AgentRuntimeInfo>;
getAvailableModes(): Promise<AgentMode[]>;
getCurrentMode(): Promise<string | null>;
setMode(modeId: string): Promise<void>;
getPendingPermissions(): AgentPermissionRequest[];
respondToPermission(requestId: string, response: AgentPermissionResponse): Promise<void>;
describePersistence(): AgentPersistenceHandle | null;
close(): Promise<void>;
listCommands?(): Promise<AgentSlashCommand[]>;
setModel?(modelId: string | null): Promise<void>;
setThinkingOption?(thinkingOptionId: string | null): Promise<void>;
}
```
### Method contracts
#### `startTurn(prompt, options?): Promise<{ turnId: string }>`
Initiates a foreground turn. The provider validates readiness, generates a unique `turnId`, submits the prompt to the runtime, and resolves once accepted. Resolving means the prompt was accepted — not that the turn has started processing.
Rejects if: session not connected, foreground turn already active, runtime rejects prompt.
#### `subscribe(callback): () => void`
Registers a callback that receives ALL provider events — foreground and autonomous — in provider order. Returns an unsubscribe function. Events carry `turnId` when they belong to a turn.
#### `streamHistory(): AsyncGenerator<AgentStreamEvent>`
Yields persisted timeline items from prior sessions. Hydration only. Does NOT yield live events.
#### `interrupt(): Promise<void>`
Cancels the active foreground turn. The resulting `turn_canceled` event arrives via `subscribe()`.
### Provider-side guarantees
1. **Per-session ordering**: callbacks invoked in provider event order
2. **No concurrent callback execution**: serialized delivery per session
3. **Subscribe-before-start safety**: manager subscribes at session creation, before any `startTurn()` call — no events missed
4. **Callback error isolation**: subscriber throws → provider logs and continues
5. **Deterministic cleanup**: `close()` stops all callbacks; `unsubscribe()` stops that specific callback
### Event tagging
All turn-scoped events carry `turnId: string`. Providers stamp turnId in `notifySubscribers()` from the active turn state (`activeForegroundTurnId` or `autonomousTurn.id`). The manager derives turn kind (foreground vs autonomous) by comparing against its own `activeForegroundTurnId`.
### User message dedup
Claude SDK assigns its own UUID to user messages (does not preserve ours). The provider deduplicates user_message echoes by text content against the most recent foreground prompt.
## Manager
### Single subscription per session
When a session is loaded, the manager subscribes once via `session.subscribe()`. This is the only live input path. Events flow through a single dispatcher that handles lifecycle projection, foreground turn waiters, and UI updates.
### Lifecycle projection from turn identity
- After `startTurn()` resolves: foreground turn is active
- On `turn_started` for active foreground turnId: lifecycle = `running`
- On terminal for active foreground turnId: lifecycle = `idle` or `error`, clear foreground turn
- On autonomous `turn_started`: lifecycle = `running`
- On autonomous terminal: lifecycle = `idle` or `error`
### `streamAgent()` as filtered view
```typescript
async *streamAgent(agentId, prompt, options) {
const { turnId } = await session.startTurn(prompt, options);
agent.activeForegroundTurnId = turnId;
// Foreground turn waiter yields events matching this turnId
// Ends when terminal event for turnId arrives
}
```
### State model
| Concept | Implementation |
|---------|---------------|
| Foreground turn tracking | `activeForegroundTurnId: string \| null` |
| Lifecycle projection | From turn events via turnId matching |
| Cancellation | `session.interrupt()` + await waiter settlement |
## What was deleted
- `stream()` from `AgentSession` interface and all providers
- `Pushable<T>` async queue from all providers
- `streamLiveEvents()` capability
- `activeForegroundTurn` + foreground queue in Claude provider
- `liveEventQueue` in Claude provider
- `routeSdkMessageFromPump()` timing-based routing (simplified to direct dispatch)
- `startLiveEventPump()` in manager
- `liveEventBacklog` + `flushLiveEventBacklog()` in manager
- `shouldSuppressLiveUserMessageEcho()` in manager
- `startLiveHistoryPolling()` for live dispatch
- `snapHistoryOffsetToEnd()`
- `pendingRun` as iterator reference
## Integration tests
All tests run against real Claude sessions with credentials from `.env.test`. No mocks.
File: `packages/server/src/server/agent/providers/__tests__/claude-agent.event-stream.integration.test.ts`
| Test | What it verifies |
|------|-----------------|
| Basic foreground turn | startTurn → events via subscribe → terminal with matching turnId |
| No duplicate user_messages | Exactly ONE user_message per prompt, even after terminal |
| Lifecycle doesn't get stuck | No stale turn_started after terminal for same turnId |
| Autonomous run | sleep 5 in bg → idle → autonomous wake → idle (distinct turnIds) |
| Interruption | Start long task → interrupt → turn_canceled arrives |
| Sequential turns | Two turns produce distinct turnIds, no cross-contamination |
| Fast-fail | Quick error produces clean terminal, no stale events |
| User message dedup | Exactly one user_message with matching text in event log |
### Invariants (asserted on every test)
1. For each foreground turnId, exactly ONE `user_message` event
2. Every `turn_started` has exactly one matching terminal
3. After terminal for a foreground turnId, no later event with that turnId gets projected as autonomous
4. Autonomous turns between foreground turns are visible with distinct turnIds
Generated
+27
View File
@@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1772963539,
"narHash": "sha256-9jVDGZnvCckTGdYT53d/EfznygLskyLQXYwJLKMPsZs=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "9dcb002ca1690658be4a04645215baea8b95f31d",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
+59
View File
@@ -0,0 +1,59 @@
{
description = "Paseo - self-hosted daemon for AI coding agents";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
};
outputs =
{
self,
nixpkgs,
}:
let
supportedSystems = [
"x86_64-linux"
"aarch64-linux"
"x86_64-darwin"
"aarch64-darwin"
];
forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
pkgsFor = system: import nixpkgs { inherit system; };
in
{
packages = forAllSystems (
system:
let
pkgs = pkgsFor system;
paseo = pkgs.callPackage ./nix/package.nix { };
in
{
default = paseo;
paseo = paseo;
}
);
nixosModules.default = self.nixosModules.paseo;
nixosModules.paseo =
{ pkgs, lib, ... }:
{
imports = [ ./nix/module.nix ];
services.paseo.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.default;
};
devShells = forAllSystems (
system:
let
pkgs = pkgsFor system;
in
{
default = pkgs.mkShell {
packages = [
pkgs.nodejs_22
pkgs.python3
];
};
}
);
};
}
+172
View File
@@ -0,0 +1,172 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.paseo;
in
{
options.services.paseo = {
enable = lib.mkEnableOption "Paseo, a self-hosted daemon for AI coding agents";
package = lib.mkPackageOption pkgs "paseo" { };
user = lib.mkOption {
type = lib.types.str;
default = "paseo";
description = "User account under which Paseo runs.";
};
group = lib.mkOption {
type = lib.types.str;
default = "paseo";
description = "Group under which Paseo runs.";
};
dataDir = lib.mkOption {
type = lib.types.str;
default =
if cfg.user == "paseo"
then "/var/lib/paseo"
else "/home/${cfg.user}/.paseo";
defaultText = lib.literalExpression ''
if cfg.user == "paseo"
then "/var/lib/paseo"
else "/home/''${cfg.user}/.paseo"
'';
description = "Directory for Paseo state (PASEO_HOME). Stores agent data, config, and logs.";
};
port = lib.mkOption {
type = lib.types.port;
default = 6767;
description = "Port for the Paseo daemon to listen on.";
};
listenAddress = lib.mkOption {
type = lib.types.str;
default = "127.0.0.1";
description = "Address for the Paseo daemon to bind to.";
};
openFirewall = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Whether to open the firewall for the Paseo daemon port.";
};
allowedHosts = lib.mkOption {
type = lib.types.either (lib.types.enum [ true ]) (lib.types.listOf lib.types.str);
default = [ ];
example = [ ".example.com" "myhost.local" ];
description = ''
Hosts allowed to connect to the Paseo daemon (DNS rebinding protection).
Localhost and IP addresses are always allowed by default.
Use a leading dot to match a domain and all its subdomains
(e.g. `".example.com"` matches `example.com` and `foo.example.com`).
Set to `true` to allow any host (not recommended).
'';
};
relay = {
enable = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Whether to enable the relay connection for remote access via app.paseo.sh.";
};
};
inheritUserEnvironment = lib.mkOption {
type = lib.types.bool;
default = cfg.user != "paseo";
defaultText = lib.literalExpression ''cfg.user != "paseo"'';
description = ''
Whether to include the user's profile PATH in the service environment.
When Paseo runs as a real user (not the default system user), AI agents
need access to the user's tools (git, ssh, etc.). This adds the user's
NixOS profile and system paths so agents can use them without manually
setting PATH.
Enabled by default when `user` is set to a non-default value.
'';
};
environment = lib.mkOption {
type = lib.types.attrsOf lib.types.str;
default = { };
example = lib.literalExpression ''
{
PASEO_RELAY_ENDPOINT = "relay.paseo.sh:443";
}
'';
description = "Extra environment variables for the Paseo daemon.";
};
};
config = lib.mkIf cfg.enable {
users.users.${cfg.user} = lib.mkIf (cfg.user == "paseo") {
isSystemUser = true;
group = cfg.group;
home = cfg.dataDir;
};
users.groups.${cfg.group} = lib.mkIf (cfg.group == "paseo") { };
systemd.tmpfiles.rules = [
"d ${cfg.dataDir} 0700 ${cfg.user} ${cfg.group} - -"
];
systemd.services.paseo = {
description = "Paseo - self-hosted daemon for AI coding agents";
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
environment = {
NODE_ENV = "production";
PASEO_HOME = cfg.dataDir;
PASEO_LISTEN = "${cfg.listenAddress}:${toString cfg.port}";
} // lib.optionalAttrs cfg.inheritUserEnvironment {
# mkForce overrides the default PATH from NixOS's systemd module (which
# only includes store paths for coreutils/grep/sed/systemd). Our PATH
# includes /run/current-system/sw/bin which is a superset of those.
PATH = lib.mkForce (lib.concatStringsSep ":" [
"/etc/profiles/per-user/${cfg.user}/bin"
"/run/current-system/sw/bin"
"/run/wrappers/bin"
"/nix/var/nix/profiles/default/bin"
]);
} // lib.optionalAttrs (cfg.allowedHosts == true) {
PASEO_ALLOWED_HOSTS = "true";
} // lib.optionalAttrs (lib.isList cfg.allowedHosts && cfg.allowedHosts != [ ]) {
PASEO_ALLOWED_HOSTS = lib.concatStringsSep "," cfg.allowedHosts;
} // cfg.environment;
serviceConfig = {
Type = "simple";
User = cfg.user;
Group = cfg.group;
ExecStart =
"${cfg.package}/bin/paseo-server"
+ lib.optionalString (!cfg.relay.enable) " --no-relay";
Restart = "on-failure";
RestartSec = 5;
# Graceful shutdown (server handles SIGTERM with a 10s timeout)
KillSignal = "SIGTERM";
TimeoutStopSec = 15;
};
};
environment.systemPackages = [ cfg.package ];
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ cfg.port ];
};
}
+144
View File
@@ -0,0 +1,144 @@
{
lib,
stdenv,
buildNpmPackage,
nodejs_22,
python3,
makeWrapper,
# node-pty needs libuv headers on Linux
libuv,
}:
buildNpmPackage rec {
pname = "paseo";
version = (builtins.fromJSON (builtins.readFile ../package.json)).version;
src = lib.cleanSourceWith {
src = ./..;
filter = path: type:
let
baseName = builtins.baseNameOf path;
relPath = lib.removePrefix (toString ./..) path;
in
# Exclude non-daemon workspace contents (keep package.json for workspace resolution)
!(lib.hasPrefix "/packages/app/src" relPath)
&& !(lib.hasPrefix "/packages/app/assets" relPath)
&& !(lib.hasPrefix "/packages/app/android" relPath)
&& !(lib.hasPrefix "/packages/app/ios" relPath)
&& !(lib.hasPrefix "/packages/website/src" relPath)
&& !(lib.hasPrefix "/packages/website/public" relPath)
&& !(lib.hasPrefix "/packages/desktop/src" relPath)
&& !(lib.hasPrefix "/packages/desktop/src-tauri" relPath)
# Exclude test fixtures and debug files
&& !(lib.hasSuffix ".test.ts" baseName)
&& !(lib.hasSuffix ".e2e.test.ts" baseName)
&& baseName != "node_modules"
&& baseName != ".git"
&& baseName != ".paseo"
&& baseName != ".DS_Store";
};
nodejs = nodejs_22;
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
npmDepsHash = "sha256-jBl1E7cwEf5XKXFQip/ZlEa/zNQoz0Hq5t0hqjp8Qe4=";
# Prevent onnxruntime-node's install script from running during automatic
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
# We manually rebuild only node-pty in buildPhase.
npmRebuildFlags = [ "--ignore-scripts" ];
nativeBuildInputs = [
python3 # for node-gyp (node-pty compilation)
makeWrapper
];
buildInputs = lib.optionals stdenv.hostPlatform.isLinux [
libuv
];
# Don't use the default npm build hook — we need a custom build sequence
dontNpmBuild = true;
buildPhase = ''
runHook preBuild
# Rebuild only node-pty (native addon for terminal emulation).
# Speech-related native modules (sherpa-onnx, onnxruntime-node) are
# intentionally left unbuilt — they're lazily loaded and gracefully
# degrade when unavailable.
npm rebuild node-pty
# Build all daemon packages in dependency order (defined in package.json)
npm run build:daemon
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p $out/lib/paseo
# Copy root package metadata
cp package.json $out/lib/paseo/
# Copy node_modules (preserving workspace symlinks)
cp -a node_modules $out/lib/paseo/
# Auto-detect which @getpaseo/* packages were built by build:daemon
# (they'll have a dist/ directory). Copy those and remove the rest.
for link in $out/lib/paseo/node_modules/@getpaseo/*; do
name=$(basename "$link")
if [ -d "packages/$name/dist" ]; then
mkdir -p "$out/lib/paseo/packages/$name"
cp "packages/$name/package.json" "$out/lib/paseo/packages/$name/"
cp -a "packages/$name/dist" "$out/lib/paseo/packages/$name/"
if [ -d "packages/$name/node_modules" ]; then
cp -a "packages/$name/node_modules" "$out/lib/paseo/packages/$name/"
fi
else
rm -f "$link"
fi
done
# Copy CLI bin entry
mkdir -p $out/lib/paseo/packages/cli/bin
cp packages/cli/bin/paseo $out/lib/paseo/packages/cli/bin/
# Copy extra server files referenced at runtime
for f in agent-prompt.md .env.example; do
if [ -f packages/server/$f ]; then
cp packages/server/$f $out/lib/paseo/packages/server/
fi
done
# Copy server scripts (daemon-runner, supervisor) needed by CLI
if [ -d packages/server/dist/scripts ]; then
mkdir -p $out/lib/paseo/packages/server/dist/scripts
cp -a packages/server/dist/scripts/* $out/lib/paseo/packages/server/dist/scripts/
fi
# Create wrapper for the server entry point (for systemd / direct use)
mkdir -p $out/bin
makeWrapper ${nodejs}/bin/node $out/bin/paseo-server \
--add-flags "$out/lib/paseo/packages/server/dist/server/server/index.js" \
--set NODE_ENV production
# Create wrapper for the CLI
makeWrapper ${nodejs}/bin/node $out/bin/paseo \
--add-flags "$out/lib/paseo/packages/cli/dist/index.js" \
--set NODE_PATH "$out/lib/paseo/node_modules"
runHook postInstall
'';
meta = {
description = "Self-hosted daemon for Claude Code, Codex, and OpenCode";
homepage = "https://github.com/getpaseo/paseo";
license = lib.licenses.agpl3Plus;
mainProgram = "paseo";
platforms = lib.platforms.linux ++ lib.platforms.darwin;
};
}
+80 -19
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.33",
"version": "0.1.37",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.33",
"version": "0.1.37",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -16718,6 +16718,15 @@
"node": ">=8"
}
},
"node_modules/electron-log": {
"version": "5.4.3",
"resolved": "https://registry.npmjs.org/electron-log/-/electron-log-5.4.3.tgz",
"integrity": "sha512-sOUsM3LjZdugatazSQ/XTyNcw8dfvH1SYhXWiJyfYodAAKOZdHs0txPiLDXFzOZbhXgAgshQkshH2ccq0feyLQ==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/electron-publish": {
"version": "26.8.1",
"resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.8.1.tgz",
@@ -34842,16 +34851,16 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.33",
"version": "0.1.37",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.33",
"@getpaseo/highlight": "0.1.33",
"@getpaseo/server": "0.1.33",
"@getpaseo/expo-two-way-audio": "0.1.37",
"@getpaseo/highlight": "0.1.37",
"@getpaseo/server": "0.1.37",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",
@@ -34937,6 +34946,8 @@
},
"packages/app/node_modules/expo-clipboard": {
"version": "8.0.7",
"resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-8.0.7.tgz",
"integrity": "sha512-zvlfFV+wB2QQrQnHWlo0EKHAkdi2tycLtE+EXFUWTPZYkgu1XcH+aiKfd4ul7Z0SDF+1IuwoiW9AA9eO35aj3Q==",
"license": "MIT",
"peerDependencies": {
"expo": "*",
@@ -34956,6 +34967,8 @@
},
"packages/app/node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
@@ -34963,11 +34976,11 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.33",
"version": "0.1.37",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.33",
"@getpaseo/server": "0.1.33",
"@getpaseo/relay": "0.1.37",
"@getpaseo/server": "0.1.37",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -34987,6 +35000,8 @@
},
"packages/cli/node_modules/chalk": {
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
"integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
"license": "MIT",
"engines": {
"node": "^12.17.0 || ^14.13 || >=16.0.0"
@@ -34997,6 +35012,8 @@
},
"packages/cli/node_modules/commander": {
"version": "12.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
"integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
"license": "MIT",
"engines": {
"node": ">=18"
@@ -35004,10 +35021,12 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.33",
"version": "0.1.37",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "0.1.33",
"@getpaseo/server": "0.1.33",
"@getpaseo/cli": "0.1.37",
"@getpaseo/server": "0.1.37",
"electron-log": "^5.4.3",
"electron-updater": "^6.6.2",
"ws": "^8.14.2"
},
@@ -35040,7 +35059,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.33",
"version": "0.1.37",
"license": "MIT",
"devDependencies": {
"@biomejs/biome": "1.9.4",
@@ -35241,7 +35260,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.33",
"version": "0.1.37",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5",
@@ -35267,7 +35286,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.33",
"version": "0.1.37",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -35283,13 +35302,13 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.33",
"version": "0.1.37",
"dependencies": {
"@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0",
"@getpaseo/highlight": "0.1.33",
"@getpaseo/relay": "0.1.33",
"@getpaseo/highlight": "0.1.37",
"@getpaseo/relay": "0.1.37",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.2.6",
"@sctg/sentencepiece-js": "^1.1.0",
@@ -35334,6 +35353,8 @@
},
"packages/server/node_modules/@modelcontextprotocol/sdk": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.20.1.tgz",
"integrity": "sha512-j/P+yuxXfgxb+mW7OEoRCM3G47zCTDqUPivJo/VzpjbG8I9csTXtOprCf5FfOfHK4whOJny0aHuBEON+kS7CCA==",
"license": "MIT",
"dependencies": {
"ajv": "^6.12.6",
@@ -35355,6 +35376,8 @@
},
"packages/server/node_modules/@modelcontextprotocol/sdk/node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.1",
@@ -35369,6 +35392,8 @@
},
"packages/server/node_modules/@modelcontextprotocol/sdk/node_modules/express": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz",
"integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==",
"license": "MIT",
"dependencies": {
"accepts": "^2.0.0",
@@ -35415,6 +35440,8 @@
},
"packages/server/node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
"license": "MIT",
"dependencies": {
"mime-types": "^3.0.0",
@@ -35426,6 +35453,8 @@
},
"packages/server/node_modules/ajv": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
@@ -35440,6 +35469,8 @@
},
"packages/server/node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"license": "MIT",
"engines": {
"node": ">=12"
@@ -35450,6 +35481,8 @@
},
"packages/server/node_modules/body-parser": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz",
"integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==",
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
@@ -35468,6 +35501,8 @@
},
"packages/server/node_modules/content-disposition": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz",
"integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "5.2.1"
@@ -35478,6 +35513,8 @@
},
"packages/server/node_modules/cookie-signature": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
"license": "MIT",
"engines": {
"node": ">=6.6.0"
@@ -35485,6 +35522,8 @@
},
"packages/server/node_modules/finalhandler": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz",
"integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
@@ -35500,6 +35539,8 @@
},
"packages/server/node_modules/fresh": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
@@ -35507,6 +35548,8 @@
},
"packages/server/node_modules/media-typer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
@@ -35514,6 +35557,8 @@
},
"packages/server/node_modules/merge-descriptors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
"license": "MIT",
"engines": {
"node": ">=18"
@@ -35524,6 +35569,8 @@
},
"packages/server/node_modules/mime-types": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz",
"integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==",
"license": "MIT",
"dependencies": {
"mime-db": "^1.54.0"
@@ -35534,6 +35581,8 @@
},
"packages/server/node_modules/negotiator": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
@@ -35572,6 +35621,8 @@
},
"packages/server/node_modules/send": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz",
"integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==",
"license": "MIT",
"dependencies": {
"debug": "^4.3.5",
@@ -35592,6 +35643,8 @@
},
"packages/server/node_modules/serve-static": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz",
"integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==",
"license": "MIT",
"dependencies": {
"encodeurl": "^2.0.0",
@@ -35605,6 +35658,8 @@
},
"packages/server/node_modules/strip-ansi": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.0.1"
@@ -35618,6 +35673,8 @@
},
"packages/server/node_modules/type-is": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
"license": "MIT",
"dependencies": {
"content-type": "^1.0.5",
@@ -35630,6 +35687,8 @@
},
"packages/server/node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
@@ -35637,7 +35696,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.33",
"version": "0.1.37",
"dependencies": {
"@cloudflare/vite-plugin": "^1.20.3",
"@cloudflare/workers-types": "^4.20260114.0",
@@ -35663,6 +35722,8 @@
},
"packages/website/node_modules/@types/node": {
"version": "22.19.6",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.6.tgz",
"integrity": "sha512-qm+G8HuG6hOHQigsi7VGuLjUVu6TtBo/F05zvX04Mw2uCg9Dv0Qxy3Qw7j41SidlTcl5D/5yg0SEZqOB+EqZnQ==",
"dev": true,
"license": "MIT",
"dependencies": {
+16 -8
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.33",
"version": "0.1.37",
"private": true,
"workspaces": [
"packages/expo-two-way-audio",
@@ -49,13 +49,13 @@
"release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
"release:push": "node scripts/push-current-release-tag.mjs",
"draft-release:push": "node scripts/push-current-release-tag.mjs --draft-release",
"draft-release:patch": "npm run version:all:patch && npm run release:check && npm run draft-release:push",
"draft-release:minor": "npm run version:all:minor && npm run release:check && npm run draft-release:push",
"draft-release:major": "npm run version:all:major && npm run release:check && npm run draft-release:push",
"draft-release:patch": "npm run release:check && npm run version:all:patch && npm run draft-release:push",
"draft-release:minor": "npm run release:check && npm run version:all:minor && npm run draft-release:push",
"draft-release:major": "npm run release:check && npm run version:all:major && npm run draft-release:push",
"release:finalize": "node scripts/finalize-current-release.mjs",
"release:patch": "npm run version:all:patch && npm run release:check && npm run release:publish && npm run release:push",
"release:minor": "npm run version:all:minor && npm run release:check && npm run release:publish && npm run release:push",
"release:major": "npm run version:all:major && npm run release:check && npm run release:publish && npm run release:push"
"release:patch": "npm run release:check && npm run version:all:patch && npm run release:publish && npm run release:push",
"release:minor": "npm run release:check && npm run version:all:minor && npm run release:publish && npm run release:push",
"release:major": "npm run release:check && npm run version:all:major && npm run release:publish && npm run release:push"
},
"devDependencies": {
"@biomejs/biome": "^2.4.8",
@@ -68,6 +68,7 @@
"typescript": "^5.9.3"
},
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
"homepage": "https://paseo.sh",
"keywords": [
"openai",
"realtime",
@@ -76,7 +77,14 @@
"development",
"mcp"
],
"author": "moboudra",
"author": {
"name": "Mohamed Boudra",
"email": "hello@moboudra.com"
},
"repository": {
"type": "git",
"url": "https://github.com/getpaseo/paseo.git"
},
"license": "AGPL-3.0-or-later",
"overrides": {
"lightningcss": "1.30.1",
+9 -6
View File
@@ -448,12 +448,15 @@ export default async function globalSetup() {
PASEO_LISTEN: `0.0.0.0:${port}`,
PASEO_RELAY_ENDPOINT: `127.0.0.1:${relayPort}`,
PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`,
// Use OpenAI speech providers in e2e to avoid local model bootstrapping delays.
PASEO_DICTATION_ENABLED: "1",
PASEO_VOICE_MODE_ENABLED: "1",
PASEO_DICTATION_STT_PROVIDER: dictationProvider,
PASEO_VOICE_STT_PROVIDER: "openai",
PASEO_VOICE_TTS_PROVIDER: "openai",
PASEO_DICTATION_ENABLED: openAiUsable ? "1" : "0",
PASEO_VOICE_MODE_ENABLED: openAiUsable ? "1" : "0",
...(openAiUsable
? {
PASEO_DICTATION_STT_PROVIDER: "openai",
PASEO_VOICE_STT_PROVIDER: "openai",
PASEO_VOICE_TTS_PROVIDER: "openai",
}
: {}),
...(localModelsDir ? { PASEO_LOCAL_MODELS_DIR: localModelsDir } : {}),
NODE_ENV: "development",
},
+228
View File
@@ -0,0 +1,228 @@
import type { Page } from "@playwright/test";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { randomUUID } from "node:crypto";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
export type TerminalPerfDaemonClient = {
connect(): Promise<void>;
close(): Promise<void>;
createTerminal(
cwd: string,
name?: string,
): Promise<{
terminal: { id: string; name: string; cwd: string } | null;
error: string | null;
}>;
subscribeTerminal(
terminalId: string,
): Promise<{ terminalId: string; slot: number; error: null } | { error: string }>;
sendTerminalInput(
terminalId: string,
message: { type: "input"; data: string } | { type: "resize"; rows: number; cols: number },
): void;
onTerminalStreamEvent(
handler: (event: { terminalId: string; type: string; data?: Uint8Array }) => void,
): () => void;
killTerminal(terminalId: string): Promise<{ error: string | null }>;
};
function getDaemonWsUrl(): string {
const daemonPort = process.env.E2E_DAEMON_PORT;
if (!daemonPort) {
throw new Error("E2E_DAEMON_PORT is not set.");
}
return `ws://127.0.0.1:${daemonPort}/ws`;
}
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
async function loadDaemonClientConstructor(): Promise<
new (config: { url: string; clientId: string; clientType: "cli" }) => TerminalPerfDaemonClient
> {
const repoRoot = path.resolve(process.cwd(), "../..");
const moduleUrl = pathToFileURL(
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
).href;
const mod = (await import(moduleUrl)) as {
DaemonClient: new (config: {
url: string;
clientId: string;
clientType: "cli";
}) => TerminalPerfDaemonClient;
};
return mod.DaemonClient;
}
export async function connectTerminalClient(): Promise<TerminalPerfDaemonClient> {
const DaemonClient = await loadDaemonClientConstructor();
const client = new DaemonClient({
url: getDaemonWsUrl(),
clientId: `terminal-perf-${randomUUID()}`,
clientType: "cli",
});
await client.connect();
return client;
}
export function buildTerminalWorkspaceUrl(cwd: string, terminalId: string): string {
const serverId = getServerId();
const route = buildHostWorkspaceRoute(serverId, cwd);
return `${route}?open=${encodeURIComponent(`terminal:${terminalId}`)}`;
}
export async function getTerminalBufferText(page: Page): Promise<string> {
return page.evaluate(() => {
const term = (window as any).__paseoTerminal;
if (!term) {
return "";
}
const buf = term.buffer.active;
const lines: string[] = [];
for (let i = 0; i < buf.length; i++) {
const line = buf.getLine(i);
if (line) {
lines.push(line.translateToString(true));
}
}
return lines.join("\n");
});
}
export async function waitForTerminalContent(
page: Page,
predicate: (text: string) => boolean,
timeout: number,
): Promise<void> {
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
const text = await getTerminalBufferText(page);
if (predicate(text)) {
return;
}
await page.waitForTimeout(50);
}
throw new Error(`Terminal content did not match predicate within ${timeout}ms`);
}
export async function navigateToTerminal(
page: Page,
input: { cwd: string; terminalId: string },
): Promise<void> {
// Boot the app at the workspace route directly.
// The fixtures.ts beforeEach addInitScript seeds localStorage on every navigation,
// so the daemon registry is already configured when the app starts.
const workspaceRoute = buildHostWorkspaceRoute(getServerId(), input.cwd);
await page.goto(workspaceRoute);
// Wait for daemon connection (sidebar shows host label)
await page.getByText("localhost", { exact: true }).first().waitFor({ state: "visible", timeout: 15_000 });
// The workspace should now query listTerminals and discover our terminal.
// Click the terminal tab if it auto-appeared, or wait for it.
const terminalSurface = page.locator('[data-testid="terminal-surface"]');
const surfaceVisible = await terminalSurface.isVisible().catch(() => false);
if (!surfaceVisible) {
// Terminal tab might not be focused — look for it in the tab row and click it
const terminalTab = page.locator(`[data-testid="workspace-tab-terminal:${input.terminalId}"]`);
const tabExists = await terminalTab.isVisible({ timeout: 5_000 }).catch(() => false);
if (tabExists) {
await terminalTab.click();
} else {
// Terminal tab not yet created — click "New terminal tab" to create one through the UI
const newTerminalBtn = page.getByRole("button", { name: "New terminal tab" });
await newTerminalBtn.waitFor({ state: "visible", timeout: 10_000 });
await newTerminalBtn.click();
}
}
// Wait for terminal surface to be visible
await terminalSurface.waitFor({ state: "visible", timeout: 15_000 });
// Wait for loading overlay to disappear (terminal attached)
await page
.locator('[data-testid="terminal-attach-loading"]')
.waitFor({ state: "hidden", timeout: 10_000 })
.catch(() => {
// overlay may never appear if attachment is instant
});
await terminalSurface.click();
}
export async function setupDeterministicPrompt(page: Page, sentinel?: string): Promise<void> {
const tag = sentinel ?? `READY_${Date.now()}`;
const terminal = page.locator('[data-testid="terminal-surface"]');
await terminal.pressSequentially(`echo ${tag}\n`, { delay: 0 });
await waitForTerminalContent(page, (text) => text.includes(tag), 10_000);
await terminal.pressSequentially("export PS1='$ '\n", { delay: 0 });
await page.waitForTimeout(300);
}
export type LatencySample = {
char: string;
latencyMs: number;
};
/**
* Measures keystroke echo round-trip latency.
*
* Starts a high-resolution timer on the browser keydown event (capture phase)
* and stops it when xterm.js finishes parsing the echoed write. This measures
* the full path: keydown → WebSocket → daemon PTY echo → WebSocket → xterm render.
*/
export async function measureKeystrokeLatency(page: Page, char: string): Promise<number> {
await page.evaluate(() => {
const term = (window as any).__paseoTerminal;
if (!term) {
throw new Error("__paseoTerminal not available");
}
const state = ((window as any).__perfKeystroke = {
promise: null as Promise<number> | null,
});
state.promise = new Promise<number>((resolve, reject) => {
const timeout = setTimeout(() => {
document.removeEventListener("keydown", onKeyDown, true);
reject(new Error("keystroke echo timeout (5s)"));
}, 5000);
function onKeyDown() {
document.removeEventListener("keydown", onKeyDown, true);
const start = performance.now();
const disposable = term.onWriteParsed(() => {
clearTimeout(timeout);
disposable.dispose();
resolve(performance.now() - start);
});
}
document.addEventListener("keydown", onKeyDown, true);
});
});
await page.keyboard.press(char);
return page.evaluate(() => (window as any).__perfKeystroke.promise);
}
export function computePercentile(samples: number[], p: number): number {
const sorted = [...samples].sort((a, b) => a - b);
const index = Math.ceil((p / 100) * sorted.length) - 1;
return sorted[Math.max(0, index)];
}
export function round2(value: number): number {
return Math.round(value * 100) / 100;
}
@@ -0,0 +1,160 @@
import { test, expect } from "./fixtures";
import { createTempGitRepo } from "./helpers/workspace";
import {
connectTerminalClient,
navigateToTerminal,
setupDeterministicPrompt,
waitForTerminalContent,
measureKeystrokeLatency,
computePercentile,
round2,
type TerminalPerfDaemonClient,
type LatencySample,
} from "./helpers/terminal-perf";
const LINE_COUNT = 50_000;
const THROUGHPUT_BUDGET_MS = 30_000;
const KEYSTROKE_SAMPLE_COUNT = 20;
const KEYSTROKE_P95_BUDGET_MS = 150;
test.describe("Terminal wire performance", () => {
let client: TerminalPerfDaemonClient;
let tempRepo: { path: string; cleanup: () => Promise<void> };
test.beforeAll(async () => {
tempRepo = await createTempGitRepo("perf-");
client = await connectTerminalClient();
});
test.afterAll(async () => {
if (client) {
await client.close();
}
if (tempRepo) {
await tempRepo.cleanup();
}
});
test("throughput: bulk terminal output renders within budget", async ({ page }, testInfo) => {
test.setTimeout(90_000);
const result = await client.createTerminal(tempRepo.path, "throughput");
if (!result.terminal) {
throw new Error(`Failed to create terminal: ${result.error}`);
}
const terminalId = result.terminal.id;
try {
await navigateToTerminal(page, { cwd: tempRepo.path, terminalId });
await setupDeterministicPrompt(page);
const sentinel = `PERF_DONE_${Date.now()}`;
const terminal = page.locator('[data-testid="terminal-surface"]');
const startMs = Date.now();
await terminal.pressSequentially(`seq 1 ${LINE_COUNT}; echo ${sentinel}\n`, { delay: 0 });
await waitForTerminalContent(page, (text) => text.includes(sentinel), THROUGHPUT_BUDGET_MS + 15_000);
const elapsedMs = Date.now() - startMs;
// seq 1 N outputs each number on its own line
const estimatedBytes = Array.from(
{ length: LINE_COUNT },
(_, i) => String(i + 1).length + 1,
).reduce((a, b) => a + b, 0);
const throughputMBps = estimatedBytes / (1024 * 1024) / (elapsedMs / 1000);
const report = {
lineCount: LINE_COUNT,
estimatedBytes,
elapsedMs,
throughputMBps: round2(throughputMBps),
};
await testInfo.attach("throughput-report", {
body: JSON.stringify(report, null, 2),
contentType: "application/json",
});
console.log(
`[perf] Throughput: ${report.throughputMBps} MB/s — ${LINE_COUNT} lines in ${elapsedMs}ms`,
);
expect(elapsedMs, `${LINE_COUNT} lines should render within ${THROUGHPUT_BUDGET_MS}ms`).toBeLessThan(
THROUGHPUT_BUDGET_MS,
);
} finally {
await client.killTerminal(terminalId).catch(() => {});
}
});
test("keystroke latency: echo round-trip under budget", async ({ page }, testInfo) => {
test.setTimeout(60_000);
const result = await client.createTerminal(tempRepo.path, "latency");
if (!result.terminal) {
throw new Error(`Failed to create terminal: ${result.error}`);
}
const terminalId = result.terminal.id;
try {
await navigateToTerminal(page, { cwd: tempRepo.path, terminalId });
await setupDeterministicPrompt(page);
// Ensure clean prompt state
const terminal = page.locator('[data-testid="terminal-surface"]');
await terminal.press("Control+c");
await page.waitForTimeout(200);
const samples: LatencySample[] = [];
const chars = "abcdefghijklmnopqrst";
for (let i = 0; i < KEYSTROKE_SAMPLE_COUNT; i++) {
const char = chars[i % chars.length];
const latencyMs = await measureKeystrokeLatency(page, char);
samples.push({ char, latencyMs });
await page.waitForTimeout(50);
}
// Clean up typed characters
await terminal.press("Control+c");
const latencies = samples.map((s) => s.latencyMs);
const p50 = computePercentile(latencies, 50);
const p95 = computePercentile(latencies, 95);
const max = Math.max(...latencies);
const min = Math.min(...latencies);
const avg = latencies.reduce((a, b) => a + b, 0) / latencies.length;
const report = {
sampleCount: KEYSTROKE_SAMPLE_COUNT,
p50Ms: round2(p50),
p95Ms: round2(p95),
maxMs: round2(max),
minMs: round2(min),
avgMs: round2(avg),
samples: samples.map((s) => ({
char: s.char,
latencyMs: round2(s.latencyMs),
})),
};
await testInfo.attach("latency-report", {
body: JSON.stringify(report, null, 2),
contentType: "application/json",
});
console.log(
`[perf] Keystroke latency — p50: ${report.p50Ms}ms, p95: ${report.p95Ms}ms, max: ${report.maxMs}ms`,
);
expect(
p95,
`Keystroke p95 latency should be under ${KEYSTROKE_P95_BUDGET_MS}ms`,
).toBeLessThan(KEYSTROKE_P95_BUDGET_MS);
} finally {
await client.killTerminal(terminalId).catch(() => {});
}
});
});
+4 -4
View File
@@ -1,7 +1,7 @@
{
"name": "@getpaseo/app",
"main": "index.ts",
"version": "0.1.33",
"version": "0.1.37",
"private": true,
"scripts": {
"start": "expo start",
@@ -31,9 +31,9 @@
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.33",
"@getpaseo/highlight": "0.1.33",
"@getpaseo/server": "0.1.33",
"@getpaseo/expo-two-way-audio": "0.1.37",
"@getpaseo/highlight": "0.1.37",
"@getpaseo/server": "0.1.37",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",
@@ -1,36 +0,0 @@
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;
}
+20 -7
View File
@@ -25,6 +25,9 @@ import {
useHostMutations,
useHostRuntimeClient,
} from "@/runtime/host-runtime";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
import { loadSettingsFromStorage } from "@/hooks/use-settings";
import { SessionProvider } from "@/contexts/session-context";
import type { HostProfile } from "@/types/host-connection";
import {
@@ -210,14 +213,22 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
let cancelled = false;
const store = getHostRuntimeStore();
void store
.loadFromStorage()
const init = async () => {
const settings = await loadSettingsFromStorage();
const isDesktopManaged = shouldUseDesktopDaemon() && settings.manageBuiltInDaemon;
await store.loadFromStorage();
if (isDesktopManaged) {
await store.bootstrap({ manageBuiltInDaemon: true });
} else {
void store.bootstrap({ manageBuiltInDaemon: settings.manageBuiltInDaemon });
}
};
void init()
.then(() => {
if (cancelled) {
return;
if (!cancelled) {
setReady(true);
}
setReady(true);
void store.bootstrap();
})
.catch((error) => {
console.error("[HostRuntime] Failed to initialize store", error);
@@ -415,7 +426,9 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
}, [isLoading, settings.theme]);
if (isLoading) {
return <LoadingView />;
const isDesktopManaged =
!settingsLoading && shouldUseDesktopDaemon() && settings.manageBuiltInDaemon;
return isDesktopManaged ? <StartupSplashScreen /> : <LoadingView />;
}
return (
+3 -98
View File
@@ -1,113 +1,18 @@
import { useEffect, useSyncExternalStore, useState } from "react";
import { useEffect } from "react";
import { usePathname, useRouter } from "expo-router";
import { useHosts } from "@/runtime/host-runtime";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { buildHostRootRoute } from "@/utils/host-routes";
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
import { WelcomeScreen } from "@/components/welcome-screen";
import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime";
import {
shouldRedirectToWelcome,
shouldWaitOnStartupRace,
WELCOME_ROUTE,
} from "@/app-support/index-startup";
const STARTUP_TIMEOUT_MS = 30_000;
function useAnyHostOnline(serverIds: string[]): string | null {
const runtime = getHostRuntimeStore();
return useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange),
() => {
let firstOnlineServerId: string | null = null;
let firstOnlineAt: string | null = null;
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;
},
);
}
const WELCOME_ROUTE = "/welcome";
export default function Index() {
const router = useRouter();
const pathname = usePathname();
const daemons = useHosts();
const [hasTimedOut, setHasTimedOut] = useState(false);
const isDesktopStartupRace = shouldUseDesktopDaemon();
const onlineServerId = useAnyHostOnline(daemons.map((daemon) => daemon.serverId));
useEffect(() => {
const timer = setTimeout(() => {
setHasTimedOut(true);
}, STARTUP_TIMEOUT_MS);
return () => {
clearTimeout(timer);
};
}, []);
useEffect(() => {
if (!onlineServerId) {
return;
}
if (pathname !== "/" && pathname !== "") {
return;
}
router.replace(buildHostRootRoute(onlineServerId) as any);
}, [onlineServerId, pathname, router]);
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 (!onlineServerId) {
return <WelcomeScreen />;
}
}, [pathname, router]);
return null;
}
@@ -6,7 +6,6 @@ import {
HEADER_INNER_HEIGHT,
HEADER_INNER_HEIGHT_MOBILE,
HEADER_TOP_PADDING_MOBILE,
getIsDesktopMac,
} from "@/constants/layout";
import { useDesktopDragHandlers, useTrafficLightPadding } from "@/utils/desktop-window";
import { usePanelStore } from "@/stores/panel-store";
@@ -31,8 +30,10 @@ export function ScreenHeader({ left, right, leftStyle, rightStyle }: ScreenHeade
// Only add extra padding on mobile for better touch targets; on desktop, only use safe area insets
const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
const baseHorizontalPadding = theme.spacing[2];
const collapsedSidebarTrafficLightInset =
!isMobile && !desktopAgentListOpen && getIsDesktopMac() ? trafficLightPadding.left : 0;
const collapsedSidebarInset =
!isMobile && !desktopAgentListOpen && trafficLightPadding.side
? trafficLightPadding
: { left: 0, right: 0 };
const dragHandlers = useDesktopDragHandlers();
@@ -42,7 +43,10 @@ export function ScreenHeader({ left, right, leftStyle, rightStyle }: ScreenHeade
<View
style={[
styles.row,
{ paddingLeft: baseHorizontalPadding + collapsedSidebarTrafficLightInset },
{
paddingLeft: baseHorizontalPadding + collapsedSidebarInset.left,
paddingRight: baseHorizontalPadding + collapsedSidebarInset.right,
},
]}
{...dragHandlers}
>
+1 -1
View File
@@ -681,7 +681,7 @@ function DesktopSidebar({
return (
<Animated.View style={[styles.desktopSidebar, resizeAnimatedStyle]}>
{trafficLightPadding.top > 0 ? (
{trafficLightPadding.side === 'left' ? (
<View style={{ height: trafficLightPadding.top }} {...dragHandlers} />
) : null}
<View style={styles.sidebarHeader} {...dragHandlers}>
+22 -16
View File
@@ -346,9 +346,13 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
[onChangeText, onSubmit, images, isAgentRunning],
);
const handleDictationError = useCallback((error: Error) => {
console.error("[MessageInput] Dictation error:", error);
}, []);
const handleDictationError = useCallback(
(error: Error) => {
console.error("[MessageInput] Dictation error:", error);
toast.error(error.message);
},
[toast],
);
const dictationUnavailableMessage = resolveVoiceUnavailableMessage({
serverInfo,
@@ -950,18 +954,20 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
<View style={styles.leftButtonGroup}>
{onPickImages && (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={onPickImages}
disabled={!isConnected || disabled}
accessibilityLabel="Attach images"
accessibilityRole="button"
style={({ hovered }) => [
styles.attachButton,
hovered && styles.iconButtonHovered,
(!isConnected || disabled) && styles.buttonDisabled,
]}
>
<Paperclip size={buttonIconSize} color={theme.colors.foreground} />
<TooltipTrigger asChild>
<Pressable
onPress={onPickImages}
disabled={!isConnected || disabled}
accessibilityLabel="Attach images"
accessibilityRole="button"
style={({ hovered }) => [
styles.attachButton,
hovered && styles.iconButtonHovered,
(!isConnected || disabled) && styles.buttonDisabled,
]}
>
<Paperclip size={buttonIconSize} color={theme.colors.foreground} />
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>Attach images</Text>
@@ -1203,7 +1209,7 @@ const styles = StyleSheet.create(((theme: any) => ({
leftButtonGroup: {
flexDirection: "row",
alignItems: "flex-end",
gap: Platform.OS === "web" ? theme.spacing[2] : theme.spacing[1],
gap: theme.spacing[1],
},
rightButtonGroup: {
flexDirection: "row",
@@ -31,11 +31,11 @@ import {
ChevronRight,
Copy,
ExternalLink,
FolderPlus,
FolderGit2,
GitPullRequest,
Monitor,
MoreVertical,
Plus,
} from "lucide-react-native";
import { NestableScrollContainer } from "react-native-draggable-flatlist";
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
@@ -392,15 +392,15 @@ function NewWorktreeButton({
}}
disabled={loading}
accessibilityRole="button"
accessibilityLabel={`Create a new worktree for ${displayName}`}
accessibilityLabel={`Create a new workspace for ${displayName}`}
testID={testID}
>
{({ hovered, pressed }) =>
loading ? (
<ActivityIndicator size={14} color={theme.colors.foregroundMuted} />
) : (
<Plus
size={14}
<FolderPlus
size={15}
color={
hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted
}
@@ -411,7 +411,7 @@ function NewWorktreeButton({
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<View style={styles.projectActionTooltipRow}>
<Text style={styles.projectActionTooltipText}>New worktree</Text>
<Text style={styles.projectActionTooltipText}>New workspace</Text>
{showShortcutHint && newWorktreeKeys ? (
<Shortcut chord={newWorktreeKeys} style={styles.projectActionTooltipShortcut} />
) : null}
@@ -886,7 +886,10 @@ function SplitPaneView({
style={[
styles.paneTabs,
isFocusModeEnabled &&
trafficLightPadding.left > 0 && { paddingLeft: trafficLightPadding.left },
trafficLightPadding.side && {
paddingLeft: trafficLightPadding.left,
paddingRight: trafficLightPadding.right,
},
]}
>
<WorkspaceDesktopTabsRow
+149 -3
View File
@@ -1,11 +1,17 @@
import { useCallback, useState } from "react";
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
import { 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 { useSafeAreaInsets } from "react-native-safe-area-context";
import type { HostProfile } from "@/types/host-connection";
import { useHostMutations } from "@/runtime/host-runtime";
import {
getHostRuntimeStore,
isHostRuntimeConnected,
useHostMutations,
useHostRuntimeSnapshot,
useHosts,
} from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import { AddHostModal } from "./add-host-modal";
import { PairLinkModal } from "./pair-link-modal";
@@ -79,6 +85,39 @@ const styles = StyleSheet.create((theme) => ({
actionTextPrimary: {
color: theme.colors.accentForeground,
},
hostList: {
width: "100%",
maxWidth: 420,
marginTop: theme.spacing[6],
borderTopWidth: 1,
borderTopColor: theme.colors.border,
paddingTop: theme.spacing[4],
gap: theme.spacing[2],
},
hostRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
paddingVertical: theme.spacing[2],
},
statusDot: {
width: 8,
height: 8,
borderRadius: 4,
},
hostLabel: {
flex: 1,
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
hostStatus: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
hostStatusError: {
color: theme.colors.destructive,
fontSize: theme.fontSize.sm,
},
versionLabel: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
@@ -87,6 +126,89 @@ const styles = StyleSheet.create((theme) => ({
},
}));
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;
},
);
}
function HostStatusRow({ serverId, label }: { serverId: string; label: string }) {
const { theme } = useUnistyles();
const snapshot = useHostRuntimeSnapshot(serverId);
const status = snapshot?.connectionStatus ?? "connecting";
const lastError = snapshot?.lastError ?? null;
let dotColor: string;
let statusText: string;
let isError = false;
switch (status) {
case "online":
dotColor = theme.colors.success;
statusText = "Online";
break;
case "connecting":
case "idle":
dotColor = theme.colors.foregroundMuted;
statusText = "Connecting…";
break;
case "offline":
dotColor = theme.colors.foregroundMuted;
statusText = "Offline";
break;
case "error":
dotColor = theme.colors.destructive;
statusText = lastError ? lastError.slice(0, 40) : "Connection error";
isError = true;
break;
}
return (
<View style={styles.hostRow}>
<View style={[styles.statusDot, { backgroundColor: dotColor }]} />
<Text style={styles.hostLabel} numberOfLines={1}>
{label}
</Text>
<Text style={isError ? styles.hostStatusError : styles.hostStatus} numberOfLines={1}>
{statusText}
</Text>
</View>
);
}
export interface WelcomeScreenProps {
onHostAdded?: (profile: HostProfile) => void;
}
@@ -105,6 +227,8 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
hostname: string | null;
} | null>(null);
const [pendingRedirectServerId, setPendingRedirectServerId] = useState<string | null>(null);
const hosts = useHosts();
const anyOnlineServerId = useAnyHostOnline(hosts.map((h) => h.serverId));
const pendingNameHostname = useSessionStore(
useCallback(
(state) => {
@@ -119,6 +243,16 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
),
);
useEffect(() => {
if (!anyOnlineServerId) {
return;
}
if (pendingNameHost) {
return;
}
router.replace(buildHostRootRoute(anyOnlineServerId) as any);
}, [anyOnlineServerId, pendingNameHost, router]);
const finishOnboarding = useCallback(
(serverId: string) => {
router.replace(buildHostRootRoute(serverId) as any);
@@ -173,6 +307,8 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
},
];
const showHostList = hosts.length > 0 && !anyOnlineServerId;
return (
<ScrollView
style={{ flex: 1, backgroundColor: theme.colors.surface0 }}
@@ -186,7 +322,9 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
<View style={styles.content}>
<PaseoLogo size={96} color={theme.colors.foreground} />
<Text style={styles.title}>Welcome to Paseo</Text>
<Text style={styles.subtitle}>Connect to your host to start</Text>
<Text style={styles.subtitle}>
{showHostList ? "Connecting to your hosts…" : "Connect to your host to start"}
</Text>
<View style={styles.actions}>
{actions.map((action) => {
@@ -209,6 +347,14 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
);
})}
</View>
{showHostList && (
<View style={styles.hostList}>
{hosts.map((host) => (
<HostStatusRow key={host.serverId} serverId={host.serverId} label={host.label} />
))}
</View>
)}
</View>
<Text style={styles.versionLabel}>{appVersionText}</Text>
+4
View File
@@ -19,6 +19,10 @@ export const MAX_CONTENT_WIDTH = 820;
export const DESKTOP_TRAFFIC_LIGHT_WIDTH = 78;
export const DESKTOP_TRAFFIC_LIGHT_HEIGHT = 45;
// Windows/Linux window controls (minimize/maximize/close) — top-right
export const DESKTOP_WINDOW_CONTROLS_WIDTH = 140;
export const DESKTOP_WINDOW_CONTROLS_HEIGHT = 48;
// Check if running in desktop app (any OS)
function isDesktopEnvironment(): boolean {
if (Platform.OS !== "web") return false;
@@ -4,6 +4,10 @@ import { useSharedValue, withTiming, Easing, type SharedValue } from "react-nati
import { type GestureType } from "react-native-gesture-handler";
import { UnistylesRuntime } from "react-native-unistyles";
import { usePanelStore } from "@/stores/panel-store";
import {
getRightSidebarAnimationTargets,
shouldSyncSidebarAnimation,
} from "@/utils/sidebar-animation-state";
const ANIMATION_DURATION = 220;
const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1);
@@ -31,55 +35,59 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
// Right sidebar: closed = +windowWidth (off-screen right), open = 0
const translateX = useSharedValue(isOpen ? 0 : windowWidth);
const backdropOpacity = useSharedValue(isOpen ? 1 : 0);
const initialTargets = getRightSidebarAnimationTargets({ isOpen, windowWidth });
const translateX = useSharedValue(initialTargets.translateX);
const backdropOpacity = useSharedValue(initialTargets.backdropOpacity);
const isGesturing = useSharedValue(false);
const closeGestureRef = useRef<GestureType | undefined>(undefined);
// Track previous isOpen to detect changes
const prevIsOpen = useRef(isOpen);
const prevWindowWidth = useRef(windowWidth);
// Sync animation with store state changes (e.g., backdrop tap, programmatic open/close)
useEffect(() => {
// Skip if this is initial render or if we're mid-gesture
if (prevIsOpen.current === isOpen) {
return;
}
const didStateChange = shouldSyncSidebarAnimation({
previousIsOpen: prevIsOpen.current,
nextIsOpen: isOpen,
previousWindowWidth: prevWindowWidth.current,
nextWindowWidth: windowWidth,
});
const previousIsOpen = prevIsOpen.current;
prevIsOpen.current = isOpen;
prevWindowWidth.current = windowWidth;
if (!didStateChange) {
return;
}
// Don't animate if we're in the middle of a gesture - the gesture handler will handle it
if (isGesturing.value) {
return;
}
if (isOpen) {
translateX.value = withTiming(0, {
const targets = getRightSidebarAnimationTargets({ isOpen, windowWidth });
if (previousIsOpen !== isOpen) {
translateX.value = withTiming(targets.translateX, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
backdropOpacity.value = withTiming(1, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
} else {
translateX.value = withTiming(windowWidth, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
backdropOpacity.value = withTiming(0, {
backdropOpacity.value = withTiming(targets.backdropOpacity, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
return;
}
translateX.value = targets.translateX;
backdropOpacity.value = targets.backdropOpacity;
}, [
isOpen,
translateX,
backdropOpacity,
windowWidth,
isGesturing,
mobileView,
desktopFileExplorerOpen,
]);
const animateToOpen = () => {
@@ -49,6 +49,7 @@ import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agen
import { resolveProjectPlacement } from "@/utils/project-placement";
import { buildDraftStoreKey } from "@/stores/draft-keys";
import type { AttachmentMetadata } from "@/attachments/types";
import { reconcilePreviousAgentStatuses } from "@/contexts/session-status-tracking";
// Re-export types from session-store and draft-store for backward compatibility
export type { DraftInput } from "@/stores/draft-store";
@@ -295,15 +296,10 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
}, []);
useEffect(() => {
if (!sessionAgents) {
previousAgentStatusRef.current.clear();
return;
}
const nextStatuses = new Map<string, AgentLifecycleStatus>();
for (const nextAgent of sessionAgents.values()) {
nextStatuses.set(nextAgent.id, nextAgent.status);
}
previousAgentStatusRef.current = nextStatuses;
previousAgentStatusRef.current = reconcilePreviousAgentStatuses(
previousAgentStatusRef.current,
sessionAgents,
);
}, [sessionAgents]);
const hydrateWorkspaces = useCallback(
@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import type { Agent } from "@/stores/session-store";
import { reconcilePreviousAgentStatuses } from "./session-status-tracking";
function createAgent(status: Agent["status"]): Agent {
return {
serverId: "server-1",
id: "agent-1",
provider: "codex",
status,
createdAt: new Date(0),
updatedAt: new Date(0),
lastUserMessageAt: null,
lastActivityAt: new Date(0),
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
title: "Agent",
cwd: "/tmp",
model: null,
labels: {},
projectPlacement: null,
};
}
describe("reconcilePreviousAgentStatuses", () => {
it("preserves previously seen status for existing agents", () => {
const previous = new Map([["agent-1", "running" as const]]);
const sessionAgents = new Map([["agent-1", createAgent("idle")]]);
const result = reconcilePreviousAgentStatuses(previous, sessionAgents);
expect(result).toEqual(new Map([["agent-1", "running"]]));
});
it("seeds newly seen agents from the current snapshot", () => {
const sessionAgents = new Map([["agent-1", createAgent("idle")]]);
const result = reconcilePreviousAgentStatuses(new Map(), sessionAgents);
expect(result).toEqual(new Map([["agent-1", "idle"]]));
});
it("removes agents that are no longer present", () => {
const previous = new Map([
["agent-1", "running" as const],
["agent-2", "idle" as const],
]);
const sessionAgents = new Map([["agent-1", createAgent("idle")]]);
const result = reconcilePreviousAgentStatuses(previous, sessionAgents);
expect(result).toEqual(new Map([["agent-1", "running"]]));
});
it("clears all tracked statuses when the session is unavailable", () => {
const previous = new Map([["agent-1", "running" as const]]);
const result = reconcilePreviousAgentStatuses(previous, undefined);
expect(result).toEqual(new Map());
});
});
@@ -0,0 +1,29 @@
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle";
import type { Agent } from "@/stores/session-store";
export function reconcilePreviousAgentStatuses(
previousStatuses: Map<string, AgentLifecycleStatus>,
sessionAgents: Map<string, Agent> | undefined,
): Map<string, AgentLifecycleStatus> {
if (!sessionAgents) {
return new Map();
}
const nextStatuses = new Map(previousStatuses);
const seenAgentIds = new Set<string>();
for (const agent of sessionAgents.values()) {
seenAgentIds.add(agent.id);
if (!nextStatuses.has(agent.id)) {
nextStatuses.set(agent.id, agent.status);
}
}
for (const agentId of nextStatuses.keys()) {
if (!seenAgentIds.has(agentId)) {
nextStatuses.delete(agentId);
}
}
return nextStatuses;
}
@@ -12,6 +12,10 @@ import { useSharedValue, withTiming, Easing, type SharedValue } from "react-nati
import { type GestureType } from "react-native-gesture-handler";
import { UnistylesRuntime } from "react-native-unistyles";
import { usePanelStore } from "@/stores/panel-store";
import {
getLeftSidebarAnimationTargets,
shouldSyncSidebarAnimation,
} from "@/utils/sidebar-animation-state";
const ANIMATION_DURATION = 220;
const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1);
@@ -38,46 +42,53 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
// Initialize based on current state
const translateX = useSharedValue(isOpen ? 0 : -windowWidth);
const backdropOpacity = useSharedValue(isOpen ? 1 : 0);
const initialTargets = getLeftSidebarAnimationTargets({ isOpen, windowWidth });
const translateX = useSharedValue(initialTargets.translateX);
const backdropOpacity = useSharedValue(initialTargets.backdropOpacity);
const isGesturing = useSharedValue(false);
const closeGestureRef = useRef<GestureType | undefined>(undefined);
// Track previous isOpen to detect changes
const prevIsOpen = useRef(isOpen);
const prevWindowWidth = useRef(windowWidth);
// Sync animation with store state changes (e.g., backdrop tap, programmatic open/close)
useEffect(() => {
// Skip if this is initial render or if we're mid-gesture
if (prevIsOpen.current === isOpen) {
const didStateChange = shouldSyncSidebarAnimation({
previousIsOpen: prevIsOpen.current,
nextIsOpen: isOpen,
previousWindowWidth: prevWindowWidth.current,
nextWindowWidth: windowWidth,
});
const previousIsOpen = prevIsOpen.current;
prevIsOpen.current = isOpen;
prevWindowWidth.current = windowWidth;
if (!didStateChange) {
return;
}
prevIsOpen.current = isOpen;
// Don't animate if we're in the middle of a gesture - the gesture handler will handle it
if (isGesturing.value) {
return;
}
if (isOpen) {
translateX.value = withTiming(0, {
const targets = getLeftSidebarAnimationTargets({ isOpen, windowWidth });
if (previousIsOpen !== isOpen) {
translateX.value = withTiming(targets.translateX, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
backdropOpacity.value = withTiming(1, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
} else {
translateX.value = withTiming(-windowWidth, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
backdropOpacity.value = withTiming(0, {
backdropOpacity.value = withTiming(targets.backdropOpacity, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
return;
}
translateX.value = targets.translateX;
backdropOpacity.value = targets.backdropOpacity;
}, [isOpen, translateX, backdropOpacity, windowWidth, isGesturing]);
const animateToOpen = useCallback(() => {
+30 -17
View File
@@ -1137,7 +1137,7 @@ export class HostRuntimeStore {
}
}
async bootstrap(): Promise<void> {
async bootstrap(options?: { manageBuiltInDaemon?: boolean }): Promise<void> {
if (this.bootstrapAttempted) {
return;
}
@@ -1153,30 +1153,43 @@ export class HostRuntimeStore {
}
if (shouldUseDesktopDaemon()) {
await this.bootstrapDesktop();
if (options?.manageBuiltInDaemon ?? true) {
await this.bootstrapDesktop();
}
} else {
await this.bootstrapLocalhost();
}
}
private async bootstrapDesktop(): Promise<void> {
try {
const daemon = await startDesktopDaemon();
const connection = connectionFromListen(daemon.listen);
if (!connection) {
let lastError: unknown = null;
for (let attempt = 1; attempt <= 3; attempt += 1) {
try {
const daemon = await startDesktopDaemon();
const connection = connectionFromListen(daemon.listen);
if (!connection || !daemon.serverId) {
return;
}
await this.upsertHostConnection({
serverId: daemon.serverId,
label: daemon.hostname ?? undefined,
connection,
});
return;
} catch (error) {
lastError = error;
console.warn(`[HostRuntime] Failed to bootstrap desktop daemon (attempt ${attempt}/3)`, error);
if (attempt < 3) {
await new Promise((resolve) => {
setTimeout(resolve, attempt * 500);
});
}
}
const { client, serverId, hostname } = await connectToDaemon(connection, {
timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS,
});
await this.upsertHostConnection({
serverId,
label: hostname ?? daemon.hostname ?? undefined,
connection,
existingClient: client,
});
} catch (error) {
console.warn("[HostRuntime] Failed to bootstrap desktop daemon connection", error);
}
if (lastError) {
console.warn("[HostRuntime] Desktop daemon bootstrap exhausted retries", lastError);
}
}
@@ -7,7 +7,6 @@ import { PaseoLogo } from "@/components/icons/paseo-logo";
import { SidebarMenuToggle } from "@/components/headers/menu-header";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { usePanelStore } from "@/stores/panel-store";
import { getIsDesktopMac } from "@/constants/layout";
import { useDesktopDragHandlers, useTrafficLightPadding } from "@/utils/desktop-window";
export function OpenProjectScreen({ serverId }: { serverId: string }) {
@@ -19,8 +18,10 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
const openProjectPicker = useOpenProjectPicker(serverId);
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const needsTrafficLightInset = !isMobile && !desktopAgentListOpen && getIsDesktopMac();
const trafficLightInset = needsTrafficLightInset ? trafficLightPadding.left : 0;
const collapsedSidebarInset =
!isMobile && !desktopAgentListOpen && trafficLightPadding.side
? trafficLightPadding
: { left: 0, right: 0 };
const dragHandlers = useDesktopDragHandlers();
useEffect(() => {
@@ -31,7 +32,7 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
return (
<View style={styles.container} {...dragHandlers}>
<View style={[styles.menuToggle, { paddingTop: insets.top, paddingLeft: trafficLightInset }]}>
<View style={[styles.menuToggle, { paddingTop: insets.top, paddingLeft: collapsedSidebarInset.left, paddingRight: collapsedSidebarInset.right }]}>
<SidebarMenuToggle />
</View>
<View style={styles.content}>
@@ -896,12 +896,6 @@ export default function SettingsScreen() {
const handleThemeChange = useCallback(
(newTheme: AppSettings["theme"]) => {
void updateSettings({ theme: newTheme });
if (newTheme === "auto") {
UnistylesRuntime.setAdaptiveThemes(true);
} else {
UnistylesRuntime.setAdaptiveThemes(false);
UnistylesRuntime.setTheme(newTheme);
}
},
[updateSettings],
);
+20 -6
View File
@@ -2,8 +2,11 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { Platform, type PointerEvent as RNPointerEvent, type ViewProps } from "react-native";
import {
getIsDesktopMac,
getIsDesktop,
DESKTOP_TRAFFIC_LIGHT_WIDTH,
DESKTOP_TRAFFIC_LIGHT_HEIGHT,
DESKTOP_WINDOW_CONTROLS_WIDTH,
DESKTOP_WINDOW_CONTROLS_HEIGHT,
} from "@/constants/layout";
import { getDesktopWindow } from "@/desktop/electron/window";
import { isDesktop } from "@/desktop/host";
@@ -120,11 +123,11 @@ export function useDesktopDragHandlers(): DesktopDragViewProps {
}, [isActive]);
}
export function useTrafficLightPadding(): { left: number; top: number } {
export function useTrafficLightPadding(): { left: number; right: number; top: number; side: 'left' | 'right' | null } {
const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => {
if (Platform.OS !== "web" || !getIsDesktopMac()) return;
if (Platform.OS !== "web" || !getIsDesktop()) return;
let disposed = false;
let cleanup: (() => void) | undefined;
@@ -175,12 +178,23 @@ export function useTrafficLightPadding(): { left: number; top: number } {
};
}, []);
if (!getIsDesktopMac() || isFullscreen) {
return { left: 0, top: 0 };
if (!getIsDesktop() || isFullscreen) {
return { left: 0, right: 0, top: 0, side: null };
}
if (getIsDesktopMac()) {
return {
left: DESKTOP_TRAFFIC_LIGHT_WIDTH,
right: 0,
top: DESKTOP_TRAFFIC_LIGHT_HEIGHT,
side: 'left',
};
}
return {
left: DESKTOP_TRAFFIC_LIGHT_WIDTH,
top: DESKTOP_TRAFFIC_LIGHT_HEIGHT,
left: 0,
right: DESKTOP_WINDOW_CONTROLS_WIDTH,
top: DESKTOP_WINDOW_CONTROLS_HEIGHT,
side: 'right',
};
}
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import {
getLeftSidebarAnimationTargets,
getRightSidebarAnimationTargets,
shouldSyncSidebarAnimation,
} from "./sidebar-animation-state";
describe("sidebar-animation-state", () => {
it("requests a sync when the open state changes", () => {
expect(
shouldSyncSidebarAnimation({
previousIsOpen: false,
nextIsOpen: true,
previousWindowWidth: 390,
nextWindowWidth: 390,
}),
).toBe(true);
});
it("requests a sync when the viewport width changes", () => {
expect(
shouldSyncSidebarAnimation({
previousIsOpen: false,
nextIsOpen: false,
previousWindowWidth: 390,
nextWindowWidth: 430,
}),
).toBe(true);
});
it("keeps the left sidebar fully off-screen when closed", () => {
expect(getLeftSidebarAnimationTargets({ isOpen: false, windowWidth: 430 })).toEqual({
translateX: -430,
backdropOpacity: 0,
});
});
it("keeps the right sidebar fully off-screen when closed", () => {
expect(getRightSidebarAnimationTargets({ isOpen: false, windowWidth: 430 })).toEqual({
translateX: 430,
backdropOpacity: 0,
});
});
});
@@ -0,0 +1,41 @@
interface SidebarAnimationSyncInput {
previousIsOpen: boolean;
nextIsOpen: boolean;
previousWindowWidth: number;
nextWindowWidth: number;
}
interface SidebarAnimationTargetInput {
isOpen: boolean;
windowWidth: number;
}
interface SidebarAnimationTargets {
translateX: number;
backdropOpacity: number;
}
export function shouldSyncSidebarAnimation(input: SidebarAnimationSyncInput): boolean {
return (
input.previousIsOpen !== input.nextIsOpen ||
input.previousWindowWidth !== input.nextWindowWidth
);
}
export function getLeftSidebarAnimationTargets(
input: SidebarAnimationTargetInput,
): SidebarAnimationTargets {
return {
translateX: input.isOpen ? 0 : -input.windowWidth,
backdropOpacity: input.isOpen ? 1 : 0,
};
}
export function getRightSidebarAnimationTargets(
input: SidebarAnimationTargetInput,
): SidebarAnimationTargets {
return {
translateX: input.isOpen ? 0 : input.windowWidth,
backdropOpacity: input.isOpen ? 1 : 0,
};
}
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.33",
"version": "0.1.37",
"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.33",
"@getpaseo/server": "0.1.33",
"@getpaseo/relay": "0.1.37",
"@getpaseo/server": "0.1.37",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
+2 -4
View File
@@ -1,5 +1,6 @@
import type { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import { isSameOrDescendantPath } from "../../utils/paths.js";
export function addDeleteOptions(cmd: Command): Command {
return cmd
@@ -69,12 +70,9 @@ export async function runDeleteCommand(
if (options.all) {
agents = agents.filter((a) => !a.archivedAt);
} else if (options.cwd) {
const filterCwd = options.cwd;
agents = agents.filter((a) => {
if (a.archivedAt) return false;
const agentCwd = a.cwd.replace(/\/$/, "");
const targetCwd = filterCwd.replace(/\/$/, "");
return agentCwd === targetCwd || agentCwd.startsWith(targetCwd + "/");
return isSameOrDescendantPath(options.cwd!, a.cwd);
});
} else if (id) {
const fetchResult = await client.fetchAgent(id);
+2 -5
View File
@@ -3,6 +3,7 @@ import type { AgentSnapshotPayload } from "@getpaseo/server";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js";
import { collectMultiple } from "../../utils/command-options.js";
import { isSameOrDescendantPath } from "../../utils/paths.js";
export function addLsOptions(cmd: Command): Command {
return cmd
@@ -193,11 +194,7 @@ export async function runLsCommand(
// Optional cwd filter.
if (options.cwd) {
const targetCwd = options.cwd.replace(/\/$/, "");
agents = agents.filter((a) => {
const agentCwd = a.cwd.replace(/\/$/, "");
return agentCwd === targetCwd || agentCwd.startsWith(targetCwd + "/");
});
agents = agents.filter((a) => isSameOrDescendantPath(options.cwd!, a.cwd));
}
// Apply label filtering only when explicitly requested.
+2 -4
View File
@@ -1,5 +1,6 @@
import { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import { isSameOrDescendantPath } from "../../utils/paths.js";
import type {
CommandOptions,
SingleResult,
@@ -75,12 +76,9 @@ export async function runStopCommand(
agents = agents.filter((a) => !a.archivedAt);
} else if (options.cwd) {
// Stop agents in directory
const filterCwd = options.cwd;
agents = agents.filter((a) => {
if (a.archivedAt) return false;
const agentCwd = a.cwd.replace(/\/$/, "");
const targetCwd = filterCwd.replace(/\/$/, "");
return agentCwd === targetCwd || agentCwd.startsWith(targetCwd + "/");
return isSameOrDescendantPath(options.cwd!, a.cwd);
});
} else if (id) {
// Stop specific agent
@@ -294,7 +294,8 @@ export function resolveTcpHostFromListen(listen: string): string | null {
normalized.startsWith("/") ||
normalized.startsWith("unix://") ||
normalized.startsWith("pipe://") ||
normalized.startsWith("\\\\.\\pipe\\")
normalized.startsWith("\\\\.\\pipe\\") ||
/^[A-Za-z]:[/\\]/.test(normalized)
) {
return null;
}
@@ -1,4 +1,5 @@
import { spawnSync } from "node:child_process";
import { platform } from "node:os";
export interface NodePathFromPidResult {
nodePath: string | null;
@@ -12,17 +13,14 @@ function normalizeError(error: unknown): string {
return String(error);
}
export function resolveNodePathFromPid(pid: number): NodePathFromPidResult {
function resolveNodePathFromPidUnix(pid: number): NodePathFromPidResult {
const result = spawnSync("ps", ["-o", "comm=", "-p", String(pid)], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
if (result.error) {
return {
nodePath: null,
error: `ps failed: ${normalizeError(result.error)}`,
};
return { nodePath: null, error: `ps failed: ${normalizeError(result.error)}` };
}
if ((result.status ?? 1) !== 0) {
@@ -34,12 +32,36 @@ export function resolveNodePathFromPid(pid: number): NodePathFromPidResult {
}
const resolved = result.stdout.trim();
if (!resolved) {
return resolved ? { nodePath: resolved } : { nodePath: null, error: "ps returned an empty command path" };
}
function resolveNodePathFromPidWindows(pid: number): NodePathFromPidResult {
const result = spawnSync(
"wmic",
["process", "where", `ProcessId=${pid}`, "get", "ExecutablePath", "/VALUE"],
{ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },
);
if (result.error) {
return { nodePath: null, error: `wmic failed: ${normalizeError(result.error)}` };
}
if ((result.status ?? 1) !== 0) {
const details = result.stderr?.trim();
return {
nodePath: null,
error: "ps returned an empty command path",
error: details ? `wmic failed: ${details}` : `wmic exited with code ${result.status ?? 1}`,
};
}
return { nodePath: resolved };
// wmic output format: "ExecutablePath=C:\path\to\node.exe\r\n"
const match = result.stdout.match(/ExecutablePath=(.+)/);
const resolved = match?.[1]?.trim();
return resolved ? { nodePath: resolved } : { nodePath: null, error: "wmic returned no executable path" };
}
export function resolveNodePathFromPid(pid: number): NodePathFromPidResult {
return platform() === "win32"
? resolveNodePathFromPidWindows(pid)
: resolveNodePathFromPidUnix(pid);
}
@@ -1,3 +1,4 @@
import path from "path";
import type { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type {
@@ -77,7 +78,7 @@ export async function runArchiveCommand(
// Find the worktree by name or branch
const worktree = listResponse.worktrees.find((wt) => {
const name = wt.worktreePath.split("/").pop();
const name = path.basename(wt.worktreePath);
return name === nameArg || wt.branchName === nameArg;
});
@@ -105,7 +106,7 @@ export async function runArchiveCommand(
throw error;
}
const worktreeName = worktree.worktreePath.split("/").pop() ?? nameArg;
const worktreeName = path.basename(worktree.worktreePath) || nameArg;
return {
type: "single",
+6 -1
View File
@@ -45,10 +45,15 @@ export function normalizeDaemonHost(raw: string): string | null {
return trimmed.startsWith("\\\\.\\pipe\\") ? `pipe://${trimmed}` : trimmed;
}
if (path.isAbsolute(trimmed)) {
if (trimmed.startsWith("/") || trimmed.startsWith("~")) {
return `unix://${trimmed}`;
}
// Windows absolute paths (e.g. C:\Users\foo) are filesystem paths, not TCP or IPC targets.
if (/^[A-Za-z]:[/\\]/.test(trimmed)) {
return null;
}
if (/^\d+$/.test(trimmed)) {
return `127.0.0.1:${trimmed}`;
}
+27
View File
@@ -0,0 +1,27 @@
/**
* Path utilities for cwd filtering in agent commands.
*/
/**
* Check if `candidatePath` is the same directory as `basePath` or a descendant of it.
*
* Handles both Unix (/) and Windows (\) path separators, including mixed separators.
* This is important because agent cwd paths come from the agent's OS (could be Windows)
* while the CLI filter path comes from the user (could also be Windows or Unix).
*/
export function isSameOrDescendantPath(basePath: string, candidatePath: string): boolean {
// Normalize both paths: replace all backslashes with forward slashes, strip trailing separator
let normalizedBase = basePath.replace(/\\/g, "/").replace(/\/$/, "");
let normalizedCandidate = candidatePath.replace(/\\/g, "/").replace(/\/$/, "");
// Windows paths are case-insensitive — detect by drive letter prefix (e.g. "C:/")
if (/^[a-zA-Z]:\//.test(normalizedBase) || /^[a-zA-Z]:\//.test(normalizedCandidate)) {
normalizedBase = normalizedBase.toLowerCase();
normalizedCandidate = normalizedCandidate.toLowerCase();
}
return (
normalizedCandidate === normalizedBase ||
normalizedCandidate.startsWith(normalizedBase + "/")
);
}
@@ -42,4 +42,12 @@ console.log("=== Local Daemon Utility Helpers ===\n");
console.log("✓ rejects empty and non-host listen values\n");
}
{
console.log("Test 5: rejects Windows absolute paths (not TCP endpoints)");
assert.strictEqual(resolveTcpHostFromListen("C:\\Users\\foo\\.paseo\\paseo.sock"), null);
assert.strictEqual(resolveTcpHostFromListen("D:\\project\\socket"), null);
assert.strictEqual(resolveTcpHostFromListen("C:\\paseo.sock"), null);
console.log("✓ rejects Windows absolute paths\n");
}
console.log("=== All local daemon utility tests passed ===");
@@ -41,6 +41,13 @@ console.log("=== CLI IPC Target Helpers ===\n");
console.log("✓ local unix socket paths normalize into IPC daemon targets\n");
}
{
console.log("Test 3b: Windows absolute paths are NOT treated as unix sockets");
assert.strictEqual(normalizeDaemonHost("C:\\Users\\foo\\.paseo\\paseo.sock"), null);
assert.strictEqual(normalizeDaemonHost("D:\\project\\socket"), null);
console.log("✓ Windows absolute paths are not treated as unix sockets\n");
}
{
console.log("Test 4: default host resolution tries local IPC first, then localhost fallback");
const paseoHome = mkdtempSync(path.join(os.tmpdir(), "paseo-client-targets-"));
@@ -0,0 +1,213 @@
#!/usr/bin/env npx tsx
/**
* CWD Filter Path Tests
*
* Tests that cwd filtering in agent commands works correctly
* with both Unix and Windows-style paths.
*
* Bug: The original code used hardcoded "/" separators for:
* 1. Stripping trailing separators (only stripped "/", not "\")
* 2. Descendant matching (appended "/" instead of platform separator)
*
* This causes Windows paths like "C:\Users\dev\project" to fail matching
* against "C:\Users\dev\project\sub" because the code appends "/" instead
* of "\" for the startsWith check.
*/
import assert from "node:assert";
import { isSameOrDescendantPath } from "../src/utils/paths.ts";
console.log("=== CWD Filter Path Tests ===\n");
// Test 1: Unix exact match
{
console.log("Test 1: Unix exact match");
assert.strictEqual(
isSameOrDescendantPath("/home/user/project", "/home/user/project"),
true,
"exact Unix paths should match",
);
console.log("✓ Unix exact match\n");
}
// Test 2: Unix descendant match
{
console.log("Test 2: Unix descendant match");
assert.strictEqual(
isSameOrDescendantPath("/home/user/project", "/home/user/project/src"),
true,
"Unix descendant should match",
);
console.log("✓ Unix descendant match\n");
}
// Test 3: Unix non-match (sibling)
{
console.log("Test 3: Unix non-match (sibling)");
assert.strictEqual(
isSameOrDescendantPath("/home/user/project", "/home/user/other"),
false,
"sibling directories should not match",
);
console.log("✓ Unix non-match (sibling)\n");
}
// Test 4: Unix prefix overlap (project vs project2)
{
console.log("Test 4: Unix prefix overlap (project vs project2)");
assert.strictEqual(
isSameOrDescendantPath("/home/user/project", "/home/user/project2"),
false,
"prefix overlap without separator should not match",
);
console.log("✓ Unix prefix overlap\n");
}
// Test 5: Unix trailing slash on base
{
console.log("Test 5: Unix trailing slash on base");
assert.strictEqual(
isSameOrDescendantPath("/home/user/project/", "/home/user/project/src"),
true,
"trailing slash on base should still match descendants",
);
console.log("✓ Unix trailing slash on base\n");
}
// Test 6: Unix trailing slash on candidate
{
console.log("Test 6: Unix trailing slash on candidate");
assert.strictEqual(
isSameOrDescendantPath("/home/user/project", "/home/user/project/"),
true,
"trailing slash on candidate should match as same dir",
);
console.log("✓ Unix trailing slash on candidate\n");
}
// Test 7: Windows exact match
{
console.log("Test 7: Windows exact match");
assert.strictEqual(
isSameOrDescendantPath("C:\\Users\\dev\\project", "C:\\Users\\dev\\project"),
true,
"exact Windows paths should match",
);
console.log("✓ Windows exact match\n");
}
// Test 8: Windows descendant match
{
console.log("Test 8: Windows descendant match");
assert.strictEqual(
isSameOrDescendantPath("C:\\Users\\dev\\project", "C:\\Users\\dev\\project\\src"),
true,
"Windows descendant should match",
);
console.log("✓ Windows descendant match\n");
}
// Test 9: Windows non-match (sibling)
{
console.log("Test 9: Windows non-match (sibling)");
assert.strictEqual(
isSameOrDescendantPath("C:\\Users\\dev\\project", "C:\\Users\\dev\\other"),
false,
"Windows sibling directories should not match",
);
console.log("✓ Windows non-match (sibling)\n");
}
// Test 10: Windows prefix overlap (project vs project2)
{
console.log("Test 10: Windows prefix overlap (project vs project2)");
assert.strictEqual(
isSameOrDescendantPath("C:\\Users\\dev\\project", "C:\\Users\\dev\\project2"),
false,
"Windows prefix overlap without separator should not match",
);
console.log("✓ Windows prefix overlap\n");
}
// Test 11: Windows trailing backslash on base
{
console.log("Test 11: Windows trailing backslash on base");
assert.strictEqual(
isSameOrDescendantPath("C:\\Users\\dev\\project\\", "C:\\Users\\dev\\project\\src"),
true,
"trailing backslash on base should still match descendants",
);
console.log("✓ Windows trailing backslash on base\n");
}
// Test 12: Windows trailing backslash on candidate
{
console.log("Test 12: Windows trailing backslash on candidate");
assert.strictEqual(
isSameOrDescendantPath("C:\\Users\\dev\\project", "C:\\Users\\dev\\project\\"),
true,
"trailing backslash on candidate should match as same dir",
);
console.log("✓ Windows trailing backslash on candidate\n");
}
// Test 13: Mixed separators (agent might use \ while CLI sends /)
{
console.log("Test 13: Mixed separators");
assert.strictEqual(
isSameOrDescendantPath("C:/Users/dev/project", "C:\\Users\\dev\\project\\src"),
true,
"mixed separators should still match",
);
console.log("✓ Mixed separators\n");
}
// Test 14: Deep Windows descendant
{
console.log("Test 14: Deep Windows descendant");
assert.strictEqual(
isSameOrDescendantPath(
"C:\\Users\\dev\\project",
"C:\\Users\\dev\\project\\src\\components\\Button.tsx",
),
true,
"deep Windows descendant should match",
);
console.log("✓ Deep Windows descendant\n");
}
// Test 15: Case-insensitive Windows descendant match
{
console.log("Test 15: Case-insensitive Windows descendant match");
assert.strictEqual(
isSameOrDescendantPath("C:\\Users\\Dev\\Project", "c:\\users\\dev\\project\\src"),
true,
"Windows paths with different casing should match (case-insensitive)",
);
console.log("✓ Case-insensitive Windows descendant match\n");
}
// Test 16: Case-insensitive Windows exact match
{
console.log("Test 16: Case-insensitive Windows exact match");
assert.strictEqual(
isSameOrDescendantPath("c:\\repo", "C:\\Repo"),
true,
"Windows paths with different casing should match exactly (case-insensitive)",
);
console.log("✓ Case-insensitive Windows exact match\n");
}
// Test 17: Parent should not match
{
console.log("Test 15: Parent should not match");
assert.strictEqual(
isSameOrDescendantPath("/home/user/project/src", "/home/user/project"),
false,
"parent directory should not match (only same-or-descendant)",
);
console.log("✓ Parent should not match\n");
}
console.log("=== All CWD filter path tests passed ===");
+3 -2
View File
@@ -9,5 +9,6 @@ if not exist "%APP_EXECUTABLE%" (
exit /b 1
)
set "PASEO_DESKTOP_CLI=1"
"%APP_EXECUTABLE%" %*
set "ELECTRON_RUN_AS_NODE=1"
"%APP_EXECUTABLE%" "%RESOURCES_DIR%\app.asar\dist\daemon\node-entrypoint-runner.js" bare "%RESOURCES_DIR%\app.asar\node_modules\@getpaseo\cli\dist\index.js" %*
exit /b %errorlevel%
+5
View File
@@ -14,6 +14,7 @@ publish:
owner: getpaseo
repo: paseo
mac:
artifactName: "Paseo-${version}-${arch}.${ext}"
category: public.app-category.developer-tools
icon: assets/icon.icns
hardenedRuntime: true
@@ -30,11 +31,15 @@ linux:
category: Development
icon: assets
artifactName: "Paseo-${version}-${arch}.${ext}"
maintainer: "Mohamed Boudra <hello@moboudra.com>"
vendor: "Paseo"
extraResources:
- from: bin/paseo
to: bin/paseo
target:
- AppImage
- deb
- rpm
- tar.gz
win:
icon: assets/icon.ico
+15 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.1.33",
"version": "0.1.37",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"main": "dist/main.js",
@@ -12,8 +12,9 @@
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@getpaseo/cli": "0.1.33",
"@getpaseo/server": "0.1.33",
"@getpaseo/cli": "0.1.37",
"@getpaseo/server": "0.1.37",
"electron-log": "^5.4.3",
"electron-updater": "^6.6.2",
"ws": "^8.14.2"
},
@@ -25,5 +26,15 @@
"typescript": "5.9.3",
"unzip-crx-3": "^0.2.0",
"wait-on": "8.0.5"
}
},
"homepage": "https://paseo.sh",
"repository": {
"type": "git",
"url": "https://github.com/getpaseo/paseo.git"
},
"author": {
"name": "Mohamed Boudra",
"email": "hello@moboudra.com"
},
"license": "AGPL-3.0-or-later"
}
@@ -148,8 +148,6 @@ function buildDesktopDaemonCorsOriginsEnv(): string | undefined {
.filter((value) => value.length > 0),
);
origins.add("paseo://app");
const devServerUrl = process.env.EXPO_DEV_URL ?? DEFAULT_ELECTRON_DEV_SERVER_URL;
try {
const parsed = new URL(devServerUrl);
@@ -189,7 +187,8 @@ function resolveTcpHostFromListen(listen: string): string | null {
normalized.startsWith("/") ||
normalized.startsWith("unix://") ||
normalized.startsWith("pipe://") ||
normalized.startsWith("\\\\.\\pipe\\")
normalized.startsWith("\\\\.\\pipe\\") ||
/^[A-Za-z]:[/\\]/.test(normalized)
) {
return null;
}
@@ -544,12 +543,6 @@ export function createDaemonCommandHandlers(): Record<string, DesktopCommandHand
return downloadAndInstallUpdate(currentVersion);
},
get_local_daemon_version: () => getLocalDaemonVersion(),
webview_log: (args) => {
const level = typeof args?.level === "number" ? args.level : 1;
const message = typeof args?.message === "string" ? args.message : "";
const method = level === 0 ? "debug" : level === 2 ? "warn" : level >= 3 ? "error" : "info";
console[method]("[webview]", message);
},
};
}
+14 -2
View File
@@ -1,3 +1,6 @@
import log from "electron-log/main";
log.initialize({ spyRendererConsole: true });
import path from "node:path";
import { pathToFileURL } from "node:url";
import { existsSync } from "node:fs";
@@ -89,8 +92,17 @@ async function createMainWindow(): Promise<void> {
height: 800,
show: false,
...(iconPath ? { icon: iconPath } : {}),
titleBarStyle: isMac ? "hidden" : "default",
trafficLightPosition: isMac ? { x: 16, y: 14 } : undefined,
titleBarStyle: "hidden",
...(isMac
? { trafficLightPosition: { x: 16, y: 14 } }
: {
titleBarOverlay: {
color: "#18181c",
symbolColor: "#e4e4e7",
height: 48,
},
autoHideMenuBar: true,
}),
webPreferences: {
preload: getPreloadPath(),
contextIsolation: true,
+1
View File
@@ -1,3 +1,4 @@
import "electron-log/preload";
import { contextBridge, ipcRenderer } from "electron";
type EventHandler = (payload: unknown) => void;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.33",
"version": "0.1.37",
"description": "Native module for two way audio streaming",
"main": "build/index.js",
"types": "build/index.d.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.1.33",
"version": "0.1.37",
"type": "module",
"publishConfig": {
"access": "public"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.1.33",
"version": "0.1.37",
"description": "Paseo relay for bridging daemon and client connections",
"type": "module",
"publishConfig": {
-339
View File
@@ -1,339 +0,0 @@
# Voice Assistant System Prompt
## 1. Core Voice Rules (NON-NEGOTIABLE)
### Voice Context
You are a **voice-controlled** assistant. The user speaks to you via phone and hears your responses via TTS.
### Voice Message Envelope
Some user utterances will be wrapped in an XML tag like:
`<voice-transcription focused-agent-id="...">...</voice-transcription>`
- Treat the inner text as what the user said (STT output).
- `focused-agent-id` is **context only**: it means which agent screen the user is currently looking at. It does *not* mean all actions must target that agent, but it is a strong hint for ambiguous commands like “stop it” or “cancel the agent”.
**Critical constraints:**
- User typically codes from their **phone** using voice
- **No visual feedback** - they can't see command output unless at laptop
- Input comes through **speech-to-text (STT)** which makes errors
- Output is spoken via **text-to-speech (TTS)**
- User may be mobile, away from desk, multitasking
### Communication Rules
**1-3 sentences maximum per response. Always.**
- **Plain speech only** - NO markdown (no bullets, bold, lists, headers)
- **Progressive disclosure** - answer what's asked, let user ask for more
- **Start high-level** - give the gist, not every detail
- **Natural pauses** - leave room for user to respond or redirect
**Good example:**
```
User: "List my agents"
You: "You have two agents. One working on authentication in the web app, another running tests in Faro."
User: "How's the auth agent doing?"
You: "It finished adding the login flow and is waiting for your approval on the database migration."
```
**Bad example:**
```
User: "List my agents"
You: "You have 2 agents: 1. **auth-agent** - Working on authentication 2. **test-agent** - Running tests..."
```
### Handling STT Errors
Speech-to-text makes mistakes. Fix them silently using context.
**Common errors:**
- Homophones: "list" → "missed", "code" → "load"
- Project names: "faro" → "pharaoh", "mcp" → "empty"
- Technical terms: "typescript" → "type script", "npm install" → "NPM in style"
**How to handle:**
1. Use context to fix obvious mistakes silently
2. Ask for clarification only when truly ambiguous
3. Never lecture about the error - just handle it
4. When clarifying, be brief: "Which project? Web, agent, or MCP?"
**Examples:**
- User: "Run empty install" → Interpret as "Run npm install"
- User: "Check the agent" → If only one agent, check that one; if multiple, ask which
### Immediate Silence Protocol
If user says any of these, **STOP ALL OUTPUT IMMEDIATELY**:
- "I'm not talking to you"
- "Shut up" / "Be quiet" / "Stop talking"
- "Not you"
**Response: Complete silence. No acknowledgment. Wait for user to address you again.**
## 2. Delegation Pattern
### Core Rule: Always Work Through Coding Agents
Direct command-line tools (`execute_command`, `send_text_to_command`, `kill_command`, etc.) are disabled. Every change to files, git, builds, or tests must go through a coding agent. Your job is to decide when to reuse an existing agent versus spinning up a new one, then route follow-ups appropriately.
### Safe Operations (Execute Immediately)
These orchestration tools only read or summarize state:
- `list_agents()` – discover who exists before delegating
- `get_agent_activity()` – pull the curated activity/readout for a specific agent
- `wait_for_agent()` – block until an agent requests permission or completes the current run
Call them without asking when context requires it. Never fabricate their output.
### Delegated Operations (Announce + Execute)
- `create_agent()` – Ask for confirmation unless the user already issued a clear imperative (“spin up a new planner”), then acknowledge and create immediately.
- `send_agent_prompt()` – Route the user’s request to the focused agent. If the user explicitly names a different agent, switch focus first, then send the prompt.
- `set_agent_mode()`, `cancel_agent()`, `kill_agent()` – Only when the user directs you to or the agent is stuck. Confirm destructive actions.
After delegating, monitor via `wait_for_agent()` or `get_agent_activity()` and translate the relevant summary back to the user.
### When to Ask vs Act
Ask only when the routing decision is truly ambiguous. Otherwise:
- Default to the most recently addressed agent.
- If the user mentions a new agent (“spin up planner”, “Codex, pick this up”), treat it as both a creation/selection and a focus switch.
- Use activity context to disambiguate references (“keep going on the migration” → whichever agent was migrating).
### Tool Results Reporting
After any agent-facing tool call, verbally report the key result in one sentence: who acted, what happened, and whether more work is pending. Example: “Agent Planner says the test plan is drafted and still running validations.” Progressive disclosure still applies—offer deeper details only when asked.
## 4. Agent Integrations
### Your Role: Orchestrator
You orchestrate work. Agents execute.
**First action when agent work is mentioned: Call `list_agents()`**
Load the agent list before any agent interaction. Always.
#### Focus Management
- Keep a lightweight "focus" pointer to the last agent the user explicitly addressed or implicitly referenced. Route follow-up utterances there unless the user names another agent.
- Update focus whenever the user spins up a new agent (“create a planner for this”) or targets one by name. Treat that change as sticky until silence/irrelevant turns cause confidence to drop.
- When confidence is low (long gap, conflicting references), briefly confirm: “Do you want Planner or Architect on this?”
- Always narrate hand-offs: “Okay, handing that to Planner.”
- Every time you speak on behalf of an agent, prefix with `Agent <name> says …` so the user always knows who just responded and can redirect explicitly.
**Confirm before destructive agent operations:**
- Creating agents: "Create agent in [directory] for [task]?" (unless user already issued a direct imperative)
- Killing agents: "Kill agent [id] working on [task]?"
**Delegate vs execute:**
- Everything touching code, git, or shell runs through agents
- Keep lightweight questions or summaries in-orchestrator when no action is needed
- Active agent context → Send prompt to that agent (respect focus)
### Available Agents (Source of Truth)
We only have two coding agents. Do not call tools to discover them—treat this section as canonical. When you create or configure an agent, runtime validation will reject invalid combinations.
**Claude Code (`claude`)**
- Default mode: `plan`
- Alternate mode: `bypassPermissions`
- Best for deliberative work. Start in `plan` when the user wants transparency, switch to `bypassPermissions` only with explicit approval for fast execution.
**Codex (`codex`)**
- Default mode: `auto`
- Other modes: `read-only`, `full-access`
- Use `read-only` for safe inspection, `auto` for normal edit/run loops, and escalate to `full-access` only when the user authorizes unrestricted access.
### Creating Agents
**Confirm creation only when intent is unclear.** If the user gives a direct imperative (“spin up a new planner agent in paseo”), acknowledge and create immediately; otherwise, ask.
```javascript
// Claude Code with planning
create_agent({
cwd: "~/dev/paseo",
agentType: "claude",
initialPrompt: "add dark mode toggle to settings page",
initialMode: "plan"
})
// Codex for quick edits
create_agent({
cwd: "~/dev/paseo",
agentType: "codex",
initialPrompt: "clean up the logging",
initialMode: "auto"
})
```
If the user omits `initialMode`, the defaults above apply. Invalid agentType/mode pairs will throw—just surface the error.
### Working with Agents
**Send prompts to agents:**
```javascript
// Send task (non-blocking by default)
send_agent_prompt({
agentId: "abc123",
prompt: "explain how authentication works"
})
// Returns immediately, agent processes in background
// Send task and wait for completion
send_agent_prompt({
agentId: "abc123",
prompt: "fix the bug in auth.ts",
maxWait: 60000 // Wait up to 60 seconds
})
// Change mode and send prompt (Claude -> bypassPermissions, Codex -> full-access)
send_agent_prompt({
agentId: "abc123",
prompt: "implement user registration",
sessionMode: "bypassPermissions"
})
```
**Check agent status:**
```javascript
// Get current status
get_agent_status({ agentId: "abc123" })
// Returns: { status: "processing", info: {...} }
// Get agent activity (curated, human-readable)
get_agent_activity({
agentId: "abc123",
format: "curated" // Clean summary of what agent did
})
// List all agents
list_agents()
// Returns: { agents: [{id, status, createdAt, ...}, ...] }
```
**Control agents:**
```javascript
// Change session mode (safe, no confirmation needed)
set_agent_mode({
agentId: "abc123",
modeId: "plan"
})
// Cancel current task (safe, no confirmation needed)
cancel_agent({ agentId: "abc123" })
// Kill agent (REQUIRES confirmation first)
kill_agent({ agentId: "abc123" })
```
### Agent Workflow Pattern
```javascript
// 1. Load agents first
list_agents()
// 2. If creating new agent, confirm first
// You: "Create agent in ~/dev/project for authentication?"
// User: "yes"
// 3. Create with type + mode
create_agent({
cwd: "~/dev/project",
agentType: "claude",
initialPrompt: "add authentication",
initialMode: "plan"
})
// 4. Monitor or send follow-up tasks
get_agent_activity({ agentId })
send_agent_prompt({ agentId, prompt: "add tests" })
```
## 5. Git & GitHub
### Git Worktree Utilities
Custom utilities for safe worktree management:
**create-worktree:**
- Creates new git worktree with new branch
- Example: `create-worktree "feature"` creates `~/dev/repo-feature`
- Outputs WORKTREE_PATH for you to parse
**delete-worktree:**
- Preserves the branch, only deletes directory
- Safe to use - won't lose work
- Run from within worktree directory
### GitHub CLI (gh)
Already authenticated. Use for:
- Creating PRs: `gh pr create`
- Viewing PRs: `gh pr view`
- Managing issues: `gh issue list`
- Checking CI: `gh pr checks`
## 6. Projects & Context
### Project Locations
All projects in `~/dev`:
**paseo**
- Location: `~/dev/paseo`
- Packages: `voice-assistant`
**Faro** (Autonomous Competitive Intelligence)
- Bare repo: `~/dev/faro`
- Main checkout: `~/dev/faro/main`
**Blank.page** (Minimal browser text editor)
- Location: `~/dev/blank.page/editor`
### Decision Rules
**Agent work mentioned?**
1. Call `list_agents()` first
2. Reuse existing agent if task relates to its work
3. Only confirm new-agent creation when the request is ambiguous. Clear imperatives (“spin up a new planner agent”) should be acknowledged and executed immediately.
**Creating/killing agents?**
- Ask: "Create agent in [dir] for [task]?" when intent isn’t explicit
- Ask: "Kill agent [id]?"
- Wait for "yes"
**Task routing:**
- All coding tasks → Delegate to an agent
- Active agent + related work → Delegate to that agent
- If the user explicitly mentions another agent, switch focus before delegating
**Context tracking:**
- Track active agents and their directories
- Use conversation context to resolve ambiguity
- Fix STT errors silently
- Maintain a recency-based focus pointer and narrate any focus change out loud
### Core Reminders
- Call actual tools, never just describe
- 1-3 sentences max per response
- Always report agent/tool results verbally (preface with "Agent X says …" when relaying)
- Default to action when context is clear
+4 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.1.33",
"version": "0.1.37",
"description": "Paseo backend server",
"type": "module",
"publishConfig": {
@@ -13,8 +13,7 @@
"dist/scripts",
"src/server/speech/providers/local/sherpa/assets",
"README.md",
".env.example",
"agent-prompt.md"
".env.example"
],
"exports": {
".": {
@@ -64,8 +63,8 @@
"@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0",
"@getpaseo/highlight": "0.1.33",
"@getpaseo/relay": "0.1.33",
"@getpaseo/highlight": "0.1.37",
"@getpaseo/relay": "0.1.37",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.2.6",
"@sctg/sentencepiece-js": "^1.1.0",
@@ -231,6 +231,16 @@ export type ManagedAgent =
| ManagedAgentError
| ManagedAgentClosed;
export interface AgentMetricsSnapshot {
total: number;
byLifecycle: Record<string, number>;
withActiveForegroundTurn: number;
timelineStats: {
totalItems: number;
maxItemsPerAgent: number;
};
}
type ActiveManagedAgent =
| ManagedAgentInitializing
| ManagedAgentIdle
@@ -345,6 +355,37 @@ export class AgentManager {
this.onAgentAttention = callback;
}
public getMetricsSnapshot(): AgentMetricsSnapshot {
const byLifecycle: Record<string, number> = {};
let withActiveForegroundTurn = 0;
let totalItems = 0;
let maxItemsPerAgent = 0;
for (const agent of this.agents.values()) {
byLifecycle[agent.lifecycle] = (byLifecycle[agent.lifecycle] ?? 0) + 1;
if (agent.activeForegroundTurnId !== null) {
withActiveForegroundTurn++;
}
const len = agent.timeline.length;
totalItems += len;
if (len > maxItemsPerAgent) {
maxItemsPerAgent = len;
}
}
return {
total: this.agents.size,
byLifecycle,
withActiveForegroundTurn,
timelineStats: {
totalItems,
maxItemsPerAgent,
},
};
}
private touchUpdatedAt(agent: ManagedAgent): Date {
const nowMs = Date.now();
const previousMs = agent.updatedAt.getTime();
@@ -1,7 +1,7 @@
import { describe, expect, test, beforeEach, afterEach } from "vitest";
import os from "node:os";
import path from "node:path";
import { mkdtempSync, rmSync } from "node:fs";
import { mkdtempSync, readdirSync, rmSync } from "node:fs";
import { promises as fs } from "node:fs";
import { createTestLogger } from "../../test-utils/test-logger.js";
@@ -352,6 +352,24 @@ describe("AgentStorage", () => {
expect(records[0]?.internal).toBe(true);
});
test("Windows drive-letter paths produce valid directory names", async () => {
await storage.applySnapshot(
createManagedAgent({
id: "win-agent",
cwd: "D:\\Users\\dev\\MyProject",
}),
);
const record = await storage.get("win-agent");
expect(record).not.toBeNull();
// The persisted directory must not contain a colon (invalid on Windows)
const dirs = readdirSync(storagePath);
expect(dirs).toHaveLength(1);
expect(dirs[0]).not.toContain(":");
expect(dirs[0]).toBe("D-Users-dev-MyProject");
});
test("remove deletes all duplicate record files across project directories", async () => {
const agentId = "agent-duplicate";
@@ -335,11 +335,16 @@ export class AgentStorage {
}
function projectDirNameFromCwd(cwd: string): string {
const trimmed = cwd.replace(/^[\\/]+/, "").replace(/[\\/]+$/, "");
if (!trimmed) {
return "root";
// path.win32.parse handles drive letters, UNC roots, and Unix roots on all platforms
const { root } = path.win32.parse(cwd);
const withoutRoot = cwd.slice(root.length).replace(/[\\/]+$/, "");
// Sanitize root: strip colons and separators, keep letters (e.g. "C:\" → "C", "\\server\share\" → "server-share")
const sanitizedRoot = root.replace(/[:\\/]+/g, "-").replace(/^-+|-+$/g, "");
const prefix = sanitizedRoot ? sanitizedRoot + "-" : "";
if (!withoutRoot) {
return sanitizedRoot || "root";
}
return trimmed.replace(/[\\/]+/g, "-");
return prefix + withoutRoot.replace(/[\\/]+/g, "-");
}
async function writeFileAtomically(targetPath: string, payload: string) {
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { execFileSync } from "node:child_process";
import type { AgentModelDefinition } from "../agent-sdk-types.js";
import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js";
function isBinaryInstalled(binary: string): boolean {
@@ -15,6 +16,16 @@ function isBinaryInstalled(binary: string): boolean {
const hasCodex = isBinaryInstalled("codex");
const hasOpenCode = isBinaryInstalled("opencode");
function modelMatchesFamily(
model: AgentModelDefinition,
family: "sonnet" | "haiku",
): boolean {
const haystacks = [model.id, model.label, model.description ?? ""].map((value) =>
value.toLowerCase(),
);
return haystacks.some((text) => text.includes(family));
}
describe("provider model catalogs (e2e)", () => {
let ctx: DaemonTestContext;
@@ -32,11 +43,8 @@ describe("provider model catalogs (e2e)", () => {
expect(result.error).toBeNull();
expect(result.models.length).toBeGreaterThan(0);
const descriptions = result.models.map((model) =>
`${model.label} ${model.description ?? ""}`.toLowerCase(),
);
expect(descriptions.some((text) => text.includes("sonnet 4.5"))).toBe(true);
expect(descriptions.some((text) => text.includes("haiku"))).toBe(true);
expect(result.models.some((model) => modelMatchesFamily(model, "sonnet"))).toBe(true);
expect(result.models.some((model) => modelMatchesFamily(model, "haiku"))).toBe(true);
}, 180_000);
test.runIf(hasCodex)(
@@ -336,11 +336,7 @@ describe("Agent event stream redesign — integration", () => {
const handle = await createSession({ cwdPrefix: "event-stream-interrupt-" });
try {
const { turnId } = await handle.session.startTurn(
"write a very long essay about the history of computing",
);
// Use a single subscription to avoid missing events between unsubscribe/resubscribe
let turnId: string | null = null;
const events = await new Promise<AgentStreamEvent[]>((resolve, reject) => {
const collected: AgentStreamEvent[] = [];
let interrupted = false;
@@ -352,10 +348,14 @@ describe("Agent event stream redesign — integration", () => {
const unsubscribe = handle.session.subscribe((event) => {
collected.push(event);
if (!turnId && event.type === "turn_started" && hasTurnId(event)) {
turnId = event.turnId;
}
// Once we see turn_started, fire the interrupt
if (
!interrupted &&
turnId &&
event.type === "turn_started" &&
hasTurnId(event) &&
event.turnId === turnId
@@ -365,14 +365,37 @@ describe("Agent event stream redesign — integration", () => {
}
// Resolve when we get a terminal event for this turn
if (isTerminalEvent(event) && hasTurnId(event) && event.turnId === turnId) {
if (turnId && isTerminalEvent(event) && hasTurnId(event) && event.turnId === turnId) {
clearTimeout(timeout);
unsubscribe();
resolve(collected);
}
});
void handle.session
.startTurn("write a very long essay about the history of computing")
.then((result) => {
if (turnId && turnId !== result.turnId) {
clearTimeout(timeout);
unsubscribe();
reject(
new Error(
`Observed turn_started for ${turnId} but startTurn returned ${result.turnId}`,
),
);
return;
}
turnId = result.turnId;
})
.catch((error) => {
clearTimeout(timeout);
unsubscribe();
reject(error);
});
});
expect(turnId).toBeDefined();
// turn_canceled or turn_failed arrives for that turnId
const terminal = events.find(
(e) =>
@@ -441,7 +441,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
}
});
test("routes input_json_delta through partial parsing before buffered JSON is complete", async () => {
test("waits for complete JSON values before updating tool input from input_json_delta", async () => {
const session = await createSession();
const internal = session as unknown as {
mapPartialEvent: (event: Record<string, unknown>) => AgentTimelineItem[];
@@ -479,7 +479,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
partial_json: '{"command":"echo ',
},
},
expectedCommand: "echo ",
expectedCommand: "echo seed",
},
{
event: {
@@ -520,7 +520,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
}
});
test("surfaces canonical partial tool input from input_json_delta before JSON is complete", async () => {
test("does not surface incomplete string values from input_json_delta", async () => {
const session = await createSession();
const internal = session as unknown as {
mapPartialEvent: (event: Record<string, unknown>) => AgentTimelineItem[];
@@ -551,7 +551,6 @@ describe("ClaudeAgentSession redesign invariants", () => {
expect(internal.toolUseCache.get(toolUseId)?.input).toEqual({
file_path: "src/message.tsx",
old_string: "before",
});
} finally {
await session.close();
@@ -67,7 +67,11 @@ import type {
McpServerConfig,
PersistedAgentDescriptor,
} from "../agent-sdk-types.js";
import { applyProviderEnv, type ProviderRuntimeSettings } from "../provider-launch-config.js";
import {
applyProviderEnv,
findExecutable,
type ProviderRuntimeSettings,
} from "../provider-launch-config.js";
import { getOrchestratorModeInstructions } from "../orchestrator-instructions.js";
const fsPromises = promises;
@@ -201,11 +205,14 @@ function applyRuntimeSettingsToClaudeOptions(
...options,
spawnClaudeCodeProcess: (spawnOptions) => {
const resolved = resolveClaudeSpawnCommand(spawnOptions, runtimeSettings);
// The SDK defaults to spawning "node" via PATH lookup, which fails when
// running from the managed runtime bundle where node isn't in PATH.
// Always use process.execPath — the actual node binary running the daemon.
const command =
resolved.command === spawnOptions.command ? process.execPath : resolved.command;
// When the SDK passes a default JS runtime ("node"/"bun"), replace it with
// process.execPath — the actual node binary running the daemon. This avoids
// PATH lookup failures in the managed runtime bundle.
// When the SDK passes a native binary path (from pathToClaudeCodeExecutable)
// or the user overrides the command via runtime settings, use that directly.
const isDefaultRuntime =
resolved.command === "node" || resolved.command === "bun";
const command = isDefaultRuntime ? process.execPath : resolved.command;
const child = spawn(command, resolved.args, {
cwd: spawnOptions.cwd,
env: {
@@ -1855,12 +1862,14 @@ class ClaudeAgentSession implements AgentSession {
.filter((entry): entry is string => typeof entry === "string" && entry.length > 0)
.join("\n\n");
const claudeBinary = findExecutable("claude");
const base: ClaudeOptions = {
cwd: this.config.cwd,
includePartialMessages: true,
permissionMode: this.currentMode,
agents: this.defaults?.agents,
canUseTool: this.handlePermissionRequest,
...(claudeBinary ? { pathToClaudeCodeExecutable: claudeBinary } : {}),
// Use Claude Code preset system prompt and load CLAUDE.md files
// Append provider-agnostic system prompt and orchestrator instructions for agents.
systemPrompt: {
@@ -13,33 +13,25 @@ describe("parsePartialJsonObject", () => {
});
});
it("keeps partial string values without field-specific logic", () => {
it("does not emit incomplete string values", () => {
expect(parsePartialJsonObject('{"command":"echo ')).toEqual({
value: {
command: "echo ",
},
value: {},
complete: false,
});
});
it("returns parsed prefix fields from incomplete objects", () => {
it("returns only complete prefix fields from incomplete objects", () => {
expect(parsePartialJsonObject('{"file_path":"src/message.tsx","old_string":"before')).toEqual({
value: {
file_path: "src/message.tsx",
old_string: "before",
},
complete: false,
});
});
it("parses nested partial values generically", () => {
it("does not emit incomplete nested values", () => {
expect(parsePartialJsonObject('{"payload":{"path":"src/index.ts","content":"hello')).toEqual({
value: {
payload: {
path: "src/index.ts",
content: "hello",
},
},
value: {},
complete: false,
});
});
@@ -271,17 +271,17 @@ function parsePartialObject(
};
}
value[parsedKey.value] = parsedMemberValue.value;
currentIndex = skipWhitespace(input, parsedMemberValue.nextIndex);
if (!parsedMemberValue.complete) {
return {
value,
nextIndex: currentIndex,
nextIndex: skipWhitespace(input, parsedMemberValue.nextIndex),
complete: false,
};
}
value[parsedKey.value] = parsedMemberValue.value;
currentIndex = skipWhitespace(input, parsedMemberValue.nextIndex);
const delimiter = input[currentIndex];
if (delimiter === ",") {
currentIndex += 1;
@@ -579,4 +579,127 @@ describe("codex tool-call mapper", () => {
expect(item).toBeNull();
});
it("maps apply_patch with Delete File directive into edit detail with removed lines", () => {
const patch = [
"*** Begin Patch",
"*** Delete File: /tmp/repo/src/dead-module.ts",
"*** End Patch",
].join("\n");
const item = expectMapped(
mapCodexRolloutToolCall({
callId: "codex-delete-rollout",
name: "apply_patch",
input: patch,
output:
'{"output":"Success. Updated the following files:\\nD /tmp/repo/src/dead-module.ts\\n"}',
cwd: "/tmp/repo",
}),
);
expect(item.status).toBe("completed");
expect(item.detail.type).toBe("edit");
if (item.detail.type === "edit") {
expect(item.detail.filePath).toBe("src/dead-module.ts");
expect(item.detail.unifiedDiff).toContain("/dev/null");
}
});
it("maps multi-file apply_patch with update + delete into edit detail referencing the deleted file", () => {
// Exact data shape from real Codex rollout: update one file, delete another
const patch = [
"*** Begin Patch",
"*** Update File: /tmp/repo/src/app/index.tsx",
"@@",
' import { useEffect } from "react";',
'-import { WELCOME_ROUTE } from "@/app-support/index-startup";',
"+",
'+const WELCOME_ROUTE = "/welcome";',
"*** Delete File: /tmp/repo/src/app-support/index-startup.ts",
"*** End Patch",
].join("\n");
const item = expectMapped(
mapCodexRolloutToolCall({
callId: "codex-delete-multi",
name: "apply_patch",
input: patch,
output: JSON.stringify({
output:
"Success. Updated the following files:\nM /tmp/repo/src/app/index.tsx\nD /tmp/repo/src/app-support/index-startup.ts\n",
metadata: { exit_code: 0, duration_seconds: 0.0 },
}),
cwd: "/tmp/repo",
}),
);
expect(item.detail.type).toBe("edit");
if (item.detail.type === "edit") {
// The unified diff should contain both file sections
const diff = item.detail.unifiedDiff ?? "";
// The update file section should have normal diff lines
expect(diff).toContain("-import");
expect(diff).toContain("+const WELCOME_ROUTE");
// The delete file section should reference /dev/null
expect(diff).toContain("/dev/null");
}
});
it("maps fileChange delete with content as removed lines, not added lines", () => {
const item = mapCodexToolCallFromThreadItem(
{
type: "fileChange",
id: "codex-file-delete-with-content",
status: "completed",
changes: [
{
path: "/tmp/repo/src/dead-module.ts",
kind: "delete",
content: 'export const FOO = "bar";\nexport function hello() {}\n',
},
],
},
{ cwd: "/tmp/repo" },
);
expect(item).toBeTruthy();
expect(item?.detail.type).toBe("edit");
if (item?.detail.type === "edit") {
expect(item.detail.filePath).toBe("src/dead-module.ts");
const diff = item.detail.unifiedDiff ?? "";
// For a deletion, the content should appear as REMOVED lines (-)
// not as ADDED lines (+). This is the core bug.
expect(diff).toContain("/dev/null");
expect(diff).toContain("-export const FOO");
expect(diff).toContain("-export function hello");
// The content must NOT appear as added lines
expect(diff).not.toContain("+export const FOO");
expect(diff).not.toContain("+export function hello");
}
});
it("maps fileChange delete without content to edit detail with /dev/null marker", () => {
const item = mapCodexToolCallFromThreadItem(
{
type: "fileChange",
id: "codex-file-delete-no-content",
status: "completed",
changes: [
{
path: "/tmp/repo/src/dead-module.ts",
kind: "delete",
},
],
},
{ cwd: "/tmp/repo" },
);
expect(item).toBeTruthy();
// A delete without content should still produce a meaningful detail
if (item?.detail.type === "edit") {
expect(item.detail.filePath).toBe("src/dead-module.ts");
const diff = item.detail.unifiedDiff ?? "";
expect(diff).toContain("/dev/null");
}
});
});
@@ -347,7 +347,7 @@ function normalizeDiffHeaderPath(rawPath: string): string {
function codexApplyPatchToUnifiedDiff(text: string): string {
const lines = text.replace(/\r\n/g, "\n").split("\n");
const output: string[] = [];
let sawDiffBody = false;
let sawDiffContent = false;
for (const line of lines) {
const directive = parseCodexApplyPatchDirective(line);
@@ -362,6 +362,7 @@ function codexApplyPatchToUnifiedDiff(text: string): string {
output.push(`diff --git a/${path} b/${path}`);
output.push(`--- ${left}`);
output.push(`+++ ${right}`);
sawDiffContent = true;
}
continue;
}
@@ -378,22 +379,22 @@ function codexApplyPatchToUnifiedDiff(text: string): string {
if (line.startsWith("@@")) {
output.push(line);
sawDiffBody = true;
sawDiffContent = true;
continue;
}
if (line.startsWith("+") || line.startsWith("-") || line.startsWith(" ")) {
output.push(line);
sawDiffBody = true;
sawDiffContent = true;
continue;
}
if (line.startsWith("\\ No newline at end of file")) {
output.push(line);
sawDiffBody = true;
sawDiffContent = true;
continue;
}
}
if (!sawDiffBody) {
if (!sawDiffContent) {
return text;
}
@@ -401,6 +402,22 @@ function codexApplyPatchToUnifiedDiff(text: string): string {
return normalized.length > 0 ? normalized : text;
}
function contentToDeletionDiff(filePath: string, content: string): string {
const lines = content.replace(/\r\n/g, "\n").split("\n");
const output: string[] = [];
output.push(`diff --git a/${filePath} b/${filePath}`);
output.push(`--- a/${filePath}`);
output.push(`+++ /dev/null`);
const nonEmpty = lines.filter((l) => l.length > 0 || lines.indexOf(l) < lines.length - 1);
if (nonEmpty.length > 0) {
output.push(`@@ -1,${nonEmpty.length} +0,0 @@`);
for (const line of nonEmpty) {
output.push(`-${line}`);
}
}
return output.join("\n");
}
function classifyDiffLikeText(
text: string,
): { isDiff: true; text: string } | { isDiff: false; text: string } {
@@ -716,6 +733,26 @@ function parseFileChangeEntries(
.filter((entry): entry is CodexFileChangeEntry => entry !== null);
}
function resolveFileChangeTextFields(
file: CodexFileChangeEntry | undefined,
): { unifiedDiff?: string; newString?: string } {
if (!file) {
return {};
}
const isDelete = file.kind === "delete";
if (isDelete && file.diff) {
const classified = classifyDiffLikeText(file.diff);
if (classified.isDiff) {
return { unifiedDiff: truncateDiffText(classified.text) };
}
return { unifiedDiff: truncateDiffText(contentToDeletionDiff(file.path, file.diff)) };
}
if (isDelete && !file.diff) {
return { unifiedDiff: contentToDeletionDiff(file.path, "") };
}
return asEditTextFields(file.diff);
}
function mapFileChangeItem(
item: z.infer<typeof CodexFileChangeItemSchema>,
options?: CodexMapperOptions,
@@ -739,7 +776,9 @@ function mapFileChangeItem(
files: files.map((file) => ({
path: file.path,
...(file.kind !== undefined ? { kind: file.kind } : {}),
...asEditFileOutputFields(file.diff),
...(file.kind === "delete"
? { patch: resolveFileChangeTextFields(file).unifiedDiff }
: asEditFileOutputFields(file.diff)),
})),
}
: {}),
@@ -749,7 +788,7 @@ function mapFileChangeItem(
const error = item.error ?? null;
const status = resolveStatus(item.status, error, output);
const firstFile = files[0];
const firstTextFields = asEditTextFields(firstFile?.diff);
const firstTextFields = resolveFileChangeTextFields(firstFile);
const hasFirstTextFields = Object.keys(firstTextFields).length > 0;
const input = toNullableObject({
...inputBase,
@@ -597,6 +597,42 @@ function stringifyStructuredAssistantMessage(value: unknown): string | null {
}
}
function readOpenCodeRecord(value: unknown): Record<string, unknown> | null {
return typeof value === "object" && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function readNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function normalizeQuestionOptions(
value: unknown,
): Array<{ label: string; description?: string }> | null {
if (!Array.isArray(value)) {
return null;
}
const options: Array<{ label: string; description?: string }> = [];
for (const item of value) {
if (typeof item === "string" && item.trim().length > 0) {
options.push({ label: item.trim() });
continue;
}
const record = readOpenCodeRecord(item);
const label = readNonEmptyString(record?.label);
if (!label) {
continue;
}
const description = readNonEmptyString(record?.description);
options.push(description ? { label, description } : { label });
}
return options;
}
export function translateOpenCodeEvent(
event: unknown,
state: OpenCodeEventTranslationState,
@@ -785,6 +821,60 @@ export function translateOpenCodeEvent(
break;
}
case "question.asked": {
const sessionId = props.sessionID as string | undefined;
if (sessionId !== state.sessionId) {
break;
}
const requestId = props.id as string | undefined;
const rawQuestions = Array.isArray(props.questions) ? props.questions : [];
if (!requestId || rawQuestions.length === 0) {
break;
}
const questions = rawQuestions.flatMap((item) => {
const questionRecord = readOpenCodeRecord(item);
const question = readNonEmptyString(questionRecord?.question);
const header = readNonEmptyString(questionRecord?.header);
if (!question || !header) {
return [];
}
const options = normalizeQuestionOptions(questionRecord?.options) ?? [];
return [
{
question,
header,
options,
...(questionRecord?.multiple === true ? { multiSelect: true } : {}),
},
];
});
if (questions.length === 0) {
break;
}
events.push({
type: "permission_requested",
provider: "opencode",
request: {
id: requestId,
provider: "opencode",
name: "question",
kind: "question",
title: "Question",
input: { questions },
metadata: {
source: "opencode_question",
...(readOpenCodeRecord(props.tool) ?? {}),
},
},
});
break;
}
case "session.idle": {
const sessionId = props.sessionID as string | undefined;
if (sessionId === state.sessionId) {
@@ -1196,6 +1286,38 @@ class OpenCodeAgentSession implements AgentSession {
throw new Error(`No pending permission request with id '${requestId}'`);
}
if (pending.kind === "question") {
if (response.behavior === "deny") {
await this.client.question.reject({
requestID: requestId,
directory: this.config.cwd,
});
} else {
const answersRecord = readOpenCodeRecord(response.updatedInput?.answers);
const questions = Array.isArray(pending.input?.questions) ? pending.input.questions : [];
const answers = questions.map((item) => {
const header = readNonEmptyString(readOpenCodeRecord(item)?.header);
const rawAnswer = header ? readNonEmptyString(answersRecord?.[header]) : null;
if (!rawAnswer) {
return [];
}
return rawAnswer
.split(",")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
});
await this.client.question.reply({
requestID: requestId,
directory: this.config.cwd,
answers,
});
}
this.pendingPermissions.delete(requestId);
return;
}
const reply = response.behavior === "allow" ? "once" : "reject";
await this.client.permission.reply({
requestID: requestId,
@@ -1,22 +0,0 @@
import { readFileSync } from "fs";
import { fileURLToPath } from "url";
import path from "path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Load system prompt from agent-prompt.md in package root
export function loadSystemPrompt(): string {
const promptPath = path.join(__dirname, "../../../agent-prompt.md");
return readFileSync(promptPath, "utf-8");
}
// Cache the loaded prompt
let cachedPrompt: string | null = null;
export function getSystemPrompt(): string {
if (!cachedPrompt) {
cachedPrompt = loadSystemPrompt();
}
return cachedPrompt;
}
@@ -122,6 +122,23 @@ describe("paseo daemon bootstrap", () => {
}
});
test("parses whitespace-padded numeric port strings", () => {
expect(parseListenString(" 6767 ")).toEqual({
type: "tcp",
host: "127.0.0.1",
port: 6767,
});
});
test("rejects Windows absolute paths that are not named pipes", () => {
// A Windows drive path like C:\daemon must NOT be silently parsed as TCP
// (split(":") would yield host="C" and port="\\daemon" which is nonsensical).
expect(() => parseListenString(String.raw`C:\daemon`)).toThrow();
expect(() => parseListenString(String.raw`D:\Users\foo\.paseo\daemon.sock`)).toThrow();
// Single-letter "host" with no valid port is not a valid listen string
expect(() => parseListenString(String.raw`C:\some\path`)).toThrow();
});
test("parses Windows named pipes as managed IPC listen targets", () => {
expect(parseListenString(String.raw`\\.\pipe\paseo-managed-test`)).toEqual({
type: "pipe",
+25 -17
View File
@@ -35,34 +35,43 @@ function resolveBoundListenTarget(
};
}
// Matches a Windows drive-letter path like C:\ or D:\
const WINDOWS_DRIVE_RE = /^[A-Za-z]:\\/;
export function parseListenString(listen: string): ListenTarget {
// 1. Windows named pipes: \\.\pipe\... or pipe://...
if (listen.startsWith("\\\\.\\pipe\\") || listen.startsWith("pipe://")) {
return {
type: "pipe",
path: listen.startsWith("pipe://") ? listen.slice("pipe://".length) : listen,
};
}
// Unix socket: starts with / or ~ or contains .sock
if (listen.startsWith("/") || listen.startsWith("~") || listen.includes(".sock")) {
return { type: "socket", path: listen };
}
// Explicit unix:// prefix
// 2. Explicit unix:// prefix
if (listen.startsWith("unix://")) {
return { type: "socket", path: listen.slice(7) };
}
// TCP: host:port or just port
// 3. Reject Windows absolute drive paths — they are not Unix sockets
if (WINDOWS_DRIVE_RE.test(listen)) {
throw new Error(`Invalid listen string (Windows path is not a valid listen target): ${listen}`);
}
// 4. POSIX absolute path (/ or ~) — Unix socket
if (listen.startsWith("/") || listen.startsWith("~")) {
return { type: "socket", path: listen };
}
// 5. Pure numeric — TCP port on 127.0.0.1
const trimmed = listen.trim();
if (/^\d+$/.test(trimmed)) {
const port = parseInt(trimmed, 10);
return { type: "tcp", host: "127.0.0.1", port };
}
// 6. host:port — TCP
if (listen.includes(":")) {
const [host, portStr] = listen.split(":");
const port = parseInt(portStr, 10);
if (!Number.isFinite(port)) {
const parsedPort = parseInt(portStr, 10);
if (!Number.isFinite(parsedPort)) {
throw new Error(`Invalid port in listen string: ${listen}`);
}
return { type: "tcp", host: host || "127.0.0.1", port };
}
// Just a port number
const port = parseInt(listen, 10);
if (Number.isFinite(port)) {
return { type: "tcp", host: "127.0.0.1", port };
return { type: "tcp", host: host || "127.0.0.1", port: parsedPort };
}
throw new Error(`Invalid listen string: ${listen}`);
}
@@ -237,9 +246,8 @@ export async function createPaseoDaemon(
// CORS - allow same-origin + configured origins
const allowedOrigins = new Set([
...config.corsAllowedOrigins,
// Packaged desktop renderers may send `file://` or `null` as the origin.
"file://",
"null",
// Packaged desktop renderers use the custom paseo:// protocol scheme.
"paseo://app",
// For TCP, add localhost variants
...(listenTarget.type === "tcp"
? [
@@ -0,0 +1,138 @@
import { describe, test, expect } from "vitest";
import { existsSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import { OpenCodeAgentClient } from "../agent/providers/opencode-agent.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { isProviderAvailable } from "./agent-configs.js";
function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-real-opencode-"));
}
function pickOpenCodeModel(
models: Array<{ id: string }>,
preferences: string[] = ["gpt-5-nano", "gpt-4.1-nano", "mini", "free"],
): string {
const preferred = models.find((model) => preferences.some((fragment) => model.id.includes(fragment)));
return preferred?.id ?? models[0]!.id;
}
async function createHarness(): Promise<{
client: DaemonClient;
daemon: Awaited<ReturnType<typeof createTestPaseoDaemon>>;
}> {
const logger = pino({ level: "silent" });
const daemon = await createTestPaseoDaemon({
agentClients: { opencode: new OpenCodeAgentClient(logger) },
logger,
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
await client.connect();
await client.fetchAgents({ subscribe: { subscriptionId: "opencode-real" } });
return { client, daemon };
}
describe("daemon E2E (real opencode) - plan mode and clarifying questions", () => {
test.runIf(isProviderAvailable("opencode"))(
"surfaces clarifying questions as pending permissions",
async () => {
const cwd = tmpCwd();
const { client, daemon } = await createHarness();
try {
const modelList = await client.listProviderModels("opencode");
expect(modelList.models.length).toBeGreaterThan(0);
const agent = await client.createAgent({
provider: "opencode",
cwd,
title: "OpenCode question regression",
model: pickOpenCodeModel(modelList.models, ["minimax-m2.5-free", "minimax", "free"]),
modeId: "plan",
});
await client.sendMessage(
agent.id,
[
"Use the question tool/feature to ask me exactly one clarifying question.",
"Ask this exact question: What kind of project should the plan cover?",
"Wait for my answer before doing anything else.",
].join(" "),
);
const snapshotWithQuestion = await client.waitForAgentUpsert(
agent.id,
(snapshot) => (snapshot.pendingPermissions?.[0]?.kind ?? null) === "question",
30_000,
);
expect(snapshotWithQuestion.pendingPermissions?.length).toBeGreaterThan(0);
const permission = snapshotWithQuestion.pendingPermissions?.[0];
expect(permission).toBeTruthy();
expect(permission?.kind).toBe("question");
expect(Array.isArray(permission?.input?.questions)).toBe(true);
const firstQuestion = permission?.input?.questions?.[0] as { header?: string } | undefined;
expect(firstQuestion?.header).toBeTruthy();
} finally {
await client.close().catch(() => undefined);
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
}
},
180_000,
);
test.runIf(isProviderAvailable("opencode"))(
"plan mode stays read-only through the daemon path",
async () => {
const cwd = tmpCwd();
const filePath = path.join(cwd, "plan-mode-output.txt");
const { client, daemon } = await createHarness();
try {
const modelList = await client.listProviderModels("opencode");
expect(modelList.models.length).toBeGreaterThan(0);
const agent = await client.createAgent({
provider: "opencode",
cwd,
title: "OpenCode plan mode regression",
model: pickOpenCodeModel(modelList.models),
modeId: "plan",
});
await client.sendMessage(
agent.id,
"Create a file named plan-mode-output.txt in the current directory containing exactly hello.",
);
const state = await client.waitForFinish(agent.id, 180_000);
expect(state.status).toBe("idle");
expect(existsSync(filePath)).toBe(false);
const timeline = await client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "projected",
});
const assistantText = timeline.entries
.filter((entry) => entry.item.type === "assistant_message")
.map((entry) => entry.item.text)
.join(" ");
expect(assistantText.toLowerCase()).toContain("plan");
} finally {
await client.close().catch(() => undefined);
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
}
},
240_000,
);
});
+18
View File
@@ -291,6 +291,8 @@ export type SessionRuntimeMetrics = {
checkoutDiffFallbackRefreshTargetCount: number;
terminalDirectorySubscriptionCount: number;
terminalSubscriptionCount: number;
inflightRequests: number;
peakInflightRequests: number;
};
type FetchAgentsRequestMessage = Extract<SessionInboundMessage, { type: "fetch_agents_request" }>;
@@ -610,6 +612,8 @@ export class Session {
private readonly activeTerminalStreams = new Map<number, ActiveTerminalStream>();
private readonly terminalIdToSlot = new Map<string, number>();
private nextTerminalSlot = 0;
private inflightRequests = 0;
private peakInflightRequests = 0;
private readonly checkoutDiffSubscriptions = new Map<string, { targetKey: string }>();
private readonly checkoutDiffTargets = new Map<string, CheckoutDiffWatchTarget>();
private readonly workspaceGitWatchTargets = new Map<string, WorkspaceGitWatchTarget>();
@@ -754,6 +758,8 @@ export class Session {
checkoutDiffFallbackRefreshTargetCount,
terminalDirectorySubscriptionCount: this.subscribedTerminalDirectories.size,
terminalSubscriptionCount: this.activeTerminalStreams.size,
inflightRequests: this.inflightRequests,
peakInflightRequests: this.peakInflightRequests,
};
}
@@ -1454,6 +1460,11 @@ export class Session {
* Main entry point for processing session messages
*/
public async handleMessage(msg: SessionInboundMessage): Promise<void> {
this.inflightRequests++;
if (this.inflightRequests > this.peakInflightRequests) {
this.peakInflightRequests = this.inflightRequests;
}
try {
this.sessionLogger.trace({ inbound: msg }, "inbound message");
try {
switch (msg.type) {
@@ -1779,6 +1790,13 @@ export class Session {
},
});
}
} finally {
this.inflightRequests--;
}
}
public resetPeakInflight(): void {
this.peakInflightRequests = this.inflightRequests;
}
public handleBinaryFrame(frame: TerminalStreamFrame): void {
@@ -70,6 +70,21 @@ export function resolveSherpaLoaderEnv(
}
}
/**
* Find the actual case-sensitive key in a plain object that matches the given
* key case-insensitively. On Windows, `{...process.env}` produces a plain
* (case-sensitive) object where PATH is typically stored as `Path`. Using a
* hardcoded `"PATH"` would miss the existing key and create a duplicate,
* breaking the child process's PATH.
*/
function findEnvKey(env: NodeJS.ProcessEnv, key: string): string {
const lower = key.toLowerCase();
for (const k of Object.keys(env)) {
if (k.toLowerCase() === lower) return k;
}
return key;
}
export function applySherpaLoaderEnv(
env: NodeJS.ProcessEnv,
platform: NodeJS.Platform = process.platform,
@@ -90,9 +105,10 @@ export function applySherpaLoaderEnv(
};
}
const next = prependEnvPath(env[resolved.key], resolved.libDir);
const changed = next !== (env[resolved.key] ?? "");
env[resolved.key] = next;
const actualKey = findEnvKey(env, resolved.key);
const next = prependEnvPath(env[actualKey], resolved.libDir);
const changed = next !== (env[actualKey] ?? "");
env[actualKey] = next;
return {
changed,
key: resolved.key,
@@ -1,129 +0,0 @@
```typescript
const server = new McpServer({
name: "my-app",
version: "1.0.0",
});
// Simple tool with parameters
server.registerTool(
"calculate-bmi",
{
title: "BMI Calculator",
description: "Calculate Body Mass Index",
inputSchema: {
weightKg: z.number(),
heightM: z.number(),
},
outputSchema: { bmi: z.number() },
},
async ({ weightKg, heightM }) => {
const output = { bmi: weightKg / (heightM * heightM) };
return {
content: [
{
type: "text",
text: JSON.stringify(output),
},
],
structuredContent: output,
};
}
);
// Async tool with external API call
server.registerTool(
"fetch-weather",
{
title: "Weather Fetcher",
description: "Get weather data for a city",
inputSchema: { city: z.string() },
outputSchema: { temperature: z.number(), conditions: z.string() },
},
async ({ city }) => {
const response = await fetch(`https://api.weather.com/${city}`);
const data = await response.json();
const output = { temperature: data.temp, conditions: data.conditions };
return {
content: [{ type: "text", text: JSON.stringify(output) }],
structuredContent: output,
};
}
);
// Tool that returns ResourceLinks
server.registerTool(
"list-files",
{
title: "List Files",
description: "List project files",
inputSchema: { pattern: z.string() },
outputSchema: {
count: z.number(),
files: z.array(z.object({ name: z.string(), uri: z.string() })),
},
},
async ({ pattern }) => {
const output = {
count: 2,
files: [
{ name: "README.md", uri: "file:///project/README.md" },
{ name: "index.ts", uri: "file:///project/src/index.ts" },
],
};
return {
content: [
{ type: "text", text: JSON.stringify(output) },
// ResourceLinks let tools return references without file content
{
type: "resource_link",
uri: "file:///project/README.md",
name: "README.md",
mimeType: "text/markdown",
description: "A README file",
},
{
type: "resource_link",
uri: "file:///project/src/index.ts",
name: "index.ts",
mimeType: "text/typescript",
description: "An index file",
},
],
structuredContent: output,
};
}
);
```
```typescript
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
bench('InMemoryTransport', async () => {
const client = new Client(
{
name: 'test client',
version: '1.0',
},
{
capabilities: {
tools: {},
},
},
);
const server = createServer();
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
server.connect(serverTransport),
]);
const tools = await client.listTools();
if (!tools.tools.length) throw new Error('No tools found');
await client.close();
});
});
```
@@ -1,7 +0,0 @@
export { createTerminalMcpServer, type TerminalMcpServerOptions } from "./server.js";
export { TerminalManager } from "./terminal-manager.js";
export type {
TerminalInfo,
CreateTerminalParams,
CreateTerminalResult,
} from "./terminal-manager.js";
@@ -1,266 +0,0 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { ensureValidJson } from "../json-utils.js";
import { TerminalManager } from "./terminal-manager.js";
export interface TerminalMcpServerOptions {
sessionName: string;
}
/**
* Create and configure the Terminal MCP Server
* Multiple instances can run independently with different session names
*/
export async function createTerminalMcpServer(
options: TerminalMcpServerOptions,
): Promise<McpServer> {
const { sessionName } = options;
const terminalManager = new TerminalManager(sessionName);
// Initialize the session
await terminalManager.initialize();
const server = new McpServer({
name: "terminal-mcp",
version: "1.0.0",
});
// COMMAND-BASED TOOLS
// Tool: execute_command (disabled – commands must be run through coding agents)
// server.registerTool(
// "execute_command",
// {
// title: "Execute Command",
// description:
// "Execute a shell command in a specified directory. Commands are wrapped in bash -c, so you can use pipes, operators, redirects, and all bash features. The command runs until completion or until output stabilizes (for interactive processes). Returns command ID for follow-up interactions, output, exit code (if finished), and whether the process is still running. Windows remain after command exits for inspection.",
// inputSchema: {
// command: z
// .string()
// .describe(
// "The command to execute. Can include pipes (|), operators (&&, ||, ;), redirects (>, >>), and any bash syntax. Examples: 'npm test', 'ls | grep foo', 'cd src && npm run build'"
// ),
// directory: z
// .string()
// .describe(
// "Absolute path to the working directory. Can use ~ for home directory."
// ),
// maxWait: z
// .number()
// .optional()
// .describe(
// "Maximum milliseconds to wait for command completion or output stability (default: 120000 = 2 minutes). For interactive commands, returns when output stabilizes. For one-shot commands, returns when command exits."
// ),
// },
// outputSchema: {
// commandId: z.string(),
// output: z.string(),
// exitCode: z.number().nullable(),
// isDead: z.boolean(),
// },
// },
// async ({ command, directory, maxWait }) => {
// const result = await terminalManager.executeCommand(
// command,
// directory,
// maxWait
// );
// return {
// content: [],
// structuredContent: result,
// };
// }
// );
// Tool: list_commands
server.registerTool(
"list_commands",
{
title: "List Commands",
description:
"List all commands (both running and exited). Shows command ID, working directory, current process, whether it has exited (isDead), exit code (if exited), and last few lines of output. Includes dead commands that remain for inspection until explicitly killed.",
inputSchema: {},
outputSchema: {
commands: z.array(
z.object({
id: z.string(),
name: z.string(),
workingDirectory: z.string(),
currentCommand: z.string(),
isDead: z.boolean(),
exitCode: z.number().nullable(),
lastLines: z.string().nullable(),
}),
),
},
},
async () => {
const commands = await terminalManager.listCommands();
const output = { commands };
return {
content: [],
structuredContent: ensureValidJson(output),
};
},
);
// Tool: capture_command
server.registerTool(
"capture_command",
{
title: "Capture Command Output",
description:
"Capture output from a command by its ID. Works for both running and exited commands. Returns output, exit code (if exited), and whether the command has finished.",
inputSchema: {
commandId: z
.string()
.describe(
"Command ID (window ID like @123) returned from execute_command or list_commands",
),
lines: z.number().optional().describe("Number of lines to capture (default: 200)"),
},
outputSchema: {
output: z.string(),
exitCode: z.number().nullable(),
isDead: z.boolean(),
},
},
async ({ commandId, lines }) => {
const result = await terminalManager.captureCommand(commandId, lines);
return {
content: [],
structuredContent: ensureValidJson(result),
};
},
);
// Tool: send_text_to_command
server.registerTool(
"send_text_to_command",
{
title: "Send Text to Command",
description:
"Send text input to a running command (by ID). Use this for interactive processes like REPLs, prompts, or text-based interfaces. Only works if the command is still running (isDead=false).",
inputSchema: {
commandId: z
.string()
.describe("Command ID (window ID like @123) from execute_command or list_commands"),
text: z.string().describe("Text to send to the command"),
pressEnter: z
.boolean()
.optional()
.describe("Press Enter after typing text (default: false)"),
return_output: z
.object({
lines: z.number().optional().describe("Number of lines to capture (default: 200)"),
waitForSettled: z
.boolean()
.optional()
.describe("Wait for output to stabilize before returning (default: true)"),
maxWait: z
.number()
.optional()
.describe("Maximum milliseconds to wait for stability (default: 120000)"),
})
.optional()
.describe("Capture output after sending text"),
},
outputSchema: {
output: z.string().nullable(),
},
},
async ({ commandId, text, pressEnter, return_output }) => {
const output = await terminalManager.sendTextToCommand(
commandId,
text,
pressEnter,
return_output,
);
const result = { output: output || null };
return {
content: [],
structuredContent: ensureValidJson(result),
};
},
);
// Tool: send_keys_to_command
server.registerTool(
"send_keys_to_command",
{
title: "Send Keys to Command",
description:
"Send special keys or key combinations to a running command. Use for control sequences and navigation. Examples: 'C-c' (Ctrl+C), 'Enter', 'Escape', 'BTab' (Shift+Tab). Only works if command is still running.",
inputSchema: {
commandId: z.string().describe("Command ID (window ID like @123)"),
keys: z
.string()
.describe("Special key or combination: 'C-c', 'Enter', 'Escape', 'BTab', etc."),
repeat: z
.number()
.optional()
.describe("Number of times to repeat the key press (default: 1)"),
return_output: z
.object({
lines: z.number().optional(),
waitForSettled: z.boolean().optional(),
maxWait: z.number().optional(),
})
.optional()
.describe("Capture output after sending keys"),
},
outputSchema: {
output: z.string().nullable(),
},
},
async ({ commandId, keys, repeat, return_output }) => {
const output = await terminalManager.sendKeysToCommand(
commandId,
keys,
repeat,
return_output,
);
const result = { output: output || null };
return {
content: [],
structuredContent: ensureValidJson(result),
};
},
);
// Tool: kill_command
server.registerTool(
"kill_command",
{
title: "Kill Command",
description:
"Kill one or more commands and close their windows. Use this to terminate running processes or clean up finished commands. This removes the commands from list_commands.",
inputSchema: {
commandIds: z
.array(z.string())
.min(1)
.describe(
"Array of command IDs (window IDs like @123) from execute_command or list_commands. Can be a single ID or multiple IDs.",
),
},
outputSchema: {
success: z.boolean(),
killedCount: z.number(),
},
},
async ({ commandIds }) => {
let killedCount = 0;
for (const commandId of commandIds) {
await terminalManager.killCommand(commandId);
killedCount++;
}
const output = { success: true, killedCount };
return {
content: [],
structuredContent: ensureValidJson(output),
};
},
);
return server;
}
@@ -1,475 +0,0 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { TerminalManager } from "./terminal-manager.js";
import { findSessionByName, killSession } from "./tmux.js";
const TEST_SESSION = `test-terminal-manager-${process.pid}-${Date.now().toString(36)}`;
const ANSI_ESCAPE_REGEX = /\u001B[\[\]()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g;
function stripAnsi(value: string): string {
return value.replace(ANSI_ESCAPE_REGEX, "");
}
function expectMissingPathMessage(output: string, missingPath: string): void {
const normalized = stripAnsi(output).toLowerCase();
expect(normalized).toContain(missingPath.toLowerCase());
expect(normalized).toMatch(/cannot access|no such file|not found/);
}
describe("TerminalManager - Command Execution", () => {
let manager: TerminalManager;
beforeAll(async () => {
manager = new TerminalManager(TEST_SESSION);
await manager.initialize();
});
afterAll(async () => {
// Cleanup test session
const session = await findSessionByName(TEST_SESSION);
if (session) {
await killSession(session.id);
}
});
describe("executeCommand", () => {
it("should execute a simple one-shot command and return exit code", async () => {
const result = await manager.executeCommand(
"echo 'Hello World'",
process.env.HOME || "~",
5000,
);
expect(result.commandId).toMatch(/^@\d+$/);
expect(result.output).toContain("Hello World");
expect(result.isDead).toBe(true);
expect(result.exitCode).toBe(0);
});
it("should handle commands with pipes and operators", async () => {
const result = await manager.executeCommand(
"echo 'line1\nline2\nline3' | grep line2",
process.env.HOME || "~",
5000,
);
expect(result.output).toContain("line2");
expect(result.isDead).toBe(true);
expect(result.exitCode).toBe(0);
});
it("should capture non-zero exit codes", async () => {
const result = await manager.executeCommand(
"ls /nonexistent-directory-test 2>&1",
process.env.HOME || "~",
5000,
);
expect(result.isDead).toBe(true);
expect(result.exitCode).not.toBe(0);
expectMissingPathMessage(result.output, "nonexistent-directory-test");
});
it("should handle directory changes in command", async () => {
const result = await manager.executeCommand("cd /tmp && pwd", process.env.HOME || "~", 5000);
expect(result.output).toContain("/tmp");
expect(result.isDead).toBe(true);
expect(result.exitCode).toBe(0);
});
it("should launch interactive command (Python REPL)", async () => {
const result = await manager.executeCommand("python3", process.env.HOME || "~", 5000);
expect(result.commandId).toMatch(/^@\d+$/);
expect(result.output).toContain(">>>"); // Python prompt
expect(result.isDead).toBe(false); // Still running
expect(result.exitCode).toBeNull(); // No exit code yet
// Cleanup
await manager.killCommand(result.commandId);
});
it("should handle command with working directory", async () => {
const result = await manager.executeCommand("pwd", "/tmp", 5000);
expect(result.output).toContain("/tmp");
expect(result.isDead).toBe(true);
expect(result.exitCode).toBe(0);
});
it("should handle long-running command with timeout", async () => {
const result = await manager.executeCommand(
"sleep 0.5 && echo 'Done sleeping'",
process.env.HOME || "~",
3000, // Increased timeout to ensure command completes
);
expect(result.output).toContain("Done sleeping");
// Note: May still be alive if not enough time to detect completion
if (result.isDead) {
expect(result.exitCode).toBe(0);
}
});
});
describe("listCommands", () => {
it("should list all commands including dead ones", async () => {
// Execute a command that finishes
const result1 = await manager.executeCommand("echo 'test1'", process.env.HOME || "~", 5000);
// Execute a command that stays running
const result2 = await manager.executeCommand("python3", process.env.HOME || "~", 5000);
const commands = await manager.listCommands();
expect(commands.length).toBeGreaterThanOrEqual(2);
const cmd1 = commands.find((c) => c.id === result1.commandId);
expect(cmd1).toBeDefined();
expect(cmd1?.isDead).toBe(true);
expect(cmd1?.exitCode).toBe(0);
const cmd2 = commands.find((c) => c.id === result2.commandId);
expect(cmd2).toBeDefined();
expect(cmd2?.isDead).toBe(false);
expect(cmd2?.exitCode).toBeNull();
// Cleanup
await manager.killCommand(result2.commandId);
});
it("should preserve command and working directory info for dead commands", async () => {
const testDir1 = "/tmp";
const testCommand1 = "echo 'test message 1' && pwd";
const testDir2 = process.env.HOME || "~";
const testCommand2 = "echo 'test message 2'";
// Execute two different commands
const result1 = await manager.executeCommand(testCommand1, testDir1, 5000);
const result2 = await manager.executeCommand(testCommand2, testDir2, 5000);
expect(result1.isDead).toBe(true);
expect(result1.exitCode).toBe(0);
expect(result2.isDead).toBe(true);
expect(result2.exitCode).toBe(0);
// List commands - should show the original command and working directory for each
const commands = await manager.listCommands();
const cmd1 = commands.find((c) => c.id === result1.commandId);
const cmd2 = commands.find((c) => c.id === result2.commandId);
// Verify first command
expect(cmd1).toBeDefined();
expect(cmd1?.isDead).toBe(true);
expect(cmd1?.exitCode).toBe(0);
expect(cmd1?.workingDirectory).toBe(testDir1);
expect(cmd1?.currentCommand).toBe(testCommand1);
// Verify second command (should have different values)
expect(cmd2).toBeDefined();
expect(cmd2?.isDead).toBe(true);
expect(cmd2?.exitCode).toBe(0);
expect(cmd2?.workingDirectory).toBe(testDir2);
expect(cmd2?.currentCommand).toBe(testCommand2);
// Ensure they're actually different
expect(cmd1?.workingDirectory).not.toBe(cmd2?.workingDirectory);
expect(cmd1?.currentCommand).not.toBe(cmd2?.currentCommand);
// Cleanup
await manager.killCommand(result1.commandId);
await manager.killCommand(result2.commandId);
});
});
describe("captureCommand", () => {
it("should capture output from finished command", async () => {
const execResult = await manager.executeCommand(
"echo 'Capture test'",
process.env.HOME || "~",
5000,
);
const captureResult = await manager.captureCommand(execResult.commandId, 100);
expect(captureResult.output).toContain("Capture test");
expect(captureResult.isDead).toBe(true);
expect(captureResult.exitCode).toBe(0);
});
it("should capture output from running command", async () => {
const execResult = await manager.executeCommand("python3", process.env.HOME || "~", 5000);
const captureResult = await manager.captureCommand(execResult.commandId, 50);
expect(captureResult.output).toContain(">>>");
expect(captureResult.isDead).toBe(false);
expect(captureResult.exitCode).toBeNull();
// Cleanup
await manager.killCommand(execResult.commandId);
});
});
describe("sendTextToCommand", () => {
it("should send text to running Python REPL", async () => {
// Launch Python
const execResult = await manager.executeCommand("python3", process.env.HOME || "~", 5000);
// Send Python code
const output = await manager.sendTextToCommand(
execResult.commandId,
"print('Hello from Python')",
true, // press Enter
{ lines: 50, maxWait: 3000, waitForSettled: true },
);
expect(output).toContain("Hello from Python");
// Send exit
await manager.sendTextToCommand(execResult.commandId, "exit()", true, {
lines: 50,
maxWait: 2000,
});
// Verify command is now dead
const captureResult = await manager.captureCommand(execResult.commandId);
expect(captureResult.isDead).toBe(true);
});
it("should handle multiple sequential inputs to REPL", async () => {
const execResult = await manager.executeCommand("python3", process.env.HOME || "~", 5000);
await manager.sendTextToCommand(execResult.commandId, "x = 5", true);
await manager.sendTextToCommand(execResult.commandId, "y = 3", true);
const output = await manager.sendTextToCommand(execResult.commandId, "print(x + y)", true, {
lines: 50,
maxWait: 2000,
});
expect(output).toContain("8");
// Cleanup
await manager.sendTextToCommand(execResult.commandId, "exit()", true);
});
});
describe("sendKeysToCommand", () => {
it("should send Ctrl-C to interrupt running command", async () => {
// Start a long-running command
const execResult = await manager.executeCommand(
"sleep 100",
process.env.HOME || "~",
1000, // Short wait, won't finish
);
expect(execResult.isDead).toBe(false);
// Send Ctrl-C
await manager.sendKeysToCommand(execResult.commandId, "C-c", 1, { lines: 50, maxWait: 2000 });
// Wait a bit for signal to process
await new Promise((resolve) => setTimeout(resolve, 500));
// Check if command is now dead
const captureResult = await manager.captureCommand(execResult.commandId);
expect(captureResult.isDead).toBe(true);
});
it("should send Enter key to REPL", async () => {
const execResult = await manager.executeCommand("python3", process.env.HOME || "~", 5000);
// Type without pressing Enter
await manager.sendTextToCommand(
execResult.commandId,
"print('test')",
false, // Don't press Enter
);
// Now send Enter via sendKeys
const output = await manager.sendKeysToCommand(execResult.commandId, "Enter", 1, {
lines: 50,
maxWait: 2000,
});
expect(output).toContain("test");
// Cleanup
await manager.sendTextToCommand(execResult.commandId, "exit()", true);
});
it("should repeat key press multiple times", async () => {
const execResult = await manager.executeCommand("python3", process.env.HOME || "~", 5000);
// Send multiple Ctrl-C (should exit Python)
await manager.sendKeysToCommand(
execResult.commandId,
"C-c",
3, // Repeat 3 times
{ lines: 50, maxWait: 3000 },
);
// Wait longer for Python to exit
await new Promise((resolve) => setTimeout(resolve, 1500));
const captureResult = await manager.captureCommand(execResult.commandId);
// Python might not exit from Ctrl-C depending on state, so just verify we can still capture
expect(captureResult.output).toBeDefined();
// Cleanup if still running
if (!captureResult.isDead) {
await manager.killCommand(execResult.commandId);
}
});
});
describe("killCommand", () => {
it("should kill running command", async () => {
const execResult = await manager.executeCommand("sleep 100", process.env.HOME || "~", 1000);
expect(execResult.isDead).toBe(false);
// Kill the command
await manager.killCommand(execResult.commandId);
// Verify it's gone from list
const commands = await manager.listCommands();
const found = commands.find((c) => c.id === execResult.commandId);
expect(found).toBeUndefined();
});
it("should kill finished command (cleanup)", async () => {
const execResult = await manager.executeCommand(
"echo 'cleanup test'",
process.env.HOME || "~",
5000,
);
expect(execResult.isDead).toBe(true);
// Kill/cleanup the command
await manager.killCommand(execResult.commandId);
// Verify it's gone from list
const commands = await manager.listCommands();
const found = commands.find((c) => c.id === execResult.commandId);
expect(found).toBeUndefined();
});
});
describe("Complex workflows", () => {
it("should handle Node.js REPL workflow", async () => {
// Launch Node REPL
const execResult = await manager.executeCommand("node", process.env.HOME || "~", 5000);
expect(execResult.output).toContain(">"); // Node prompt
expect(execResult.isDead).toBe(false);
await manager.sendTextToCommand(execResult.commandId, "const x = [1, 2, 3]", true);
const output2 = await manager.sendTextToCommand(
execResult.commandId,
"console.log(x.map(n => n * 2).join(','))",
true,
{ lines: 50, maxWait: 2000 },
);
expect(output2).toContain("2,4,6");
// Exit
await manager.sendTextToCommand(execResult.commandId, ".exit", true);
// Verify exited
await new Promise((resolve) => setTimeout(resolve, 500));
const captureResult = await manager.captureCommand(execResult.commandId);
expect(captureResult.isDead).toBe(true);
});
it("should handle command that produces streaming output", async () => {
const execResult = await manager.executeCommand(
"for i in 1 2 3; do echo Line $i; sleep 0.1; done",
process.env.HOME || "~",
5000,
);
expect(execResult.output).toContain("Line 1");
expect(execResult.output).toContain("Line 2");
expect(execResult.output).toContain("Line 3");
expect(execResult.isDead).toBe(true);
expect(execResult.exitCode).toBe(0);
});
it("should handle command with stderr output", async () => {
const execResult = await manager.executeCommand(
"echo 'to stdout'; echo 'to stderr' >&2",
process.env.HOME || "~",
5000,
);
expect(execResult.output).toContain("to stdout");
expect(execResult.output).toContain("to stderr");
expect(execResult.isDead).toBe(true);
expect(execResult.exitCode).toBe(0);
});
});
describe("Edge cases", () => {
it("should handle empty command output", async () => {
const execResult = await manager.executeCommand(
"true", // Command that produces no output
process.env.HOME || "~",
5000,
);
expect(execResult.isDead).toBe(true);
expect(execResult.exitCode).toBe(0);
});
it("should handle command with tilde in directory", async () => {
const execResult = await manager.executeCommand("pwd", "~/", 5000);
expect(execResult.output).toContain(process.env.HOME || "");
expect(execResult.isDead).toBe(true);
});
it("should handle very long output", async () => {
const execResult = await manager.executeCommand(
"seq 1 500", // Generate 500 lines
process.env.HOME || "~",
5000,
);
expect(execResult.output).toContain("1");
expect(execResult.output).toContain("500");
expect(execResult.isDead).toBe(true);
expect(execResult.exitCode).toBe(0);
});
it("should handle special characters in command", async () => {
const execResult = await manager.executeCommand(
"echo 'Special: $test & | ; < >'",
process.env.HOME || "~",
5000,
);
expect(execResult.output).toContain("Special:");
expect(execResult.isDead).toBe(true);
expect(execResult.exitCode).toBe(0);
});
it("should handle command with quotes", async () => {
const execResult = await manager.executeCommand(
`echo 'double and single quotes'`,
process.env.HOME || "~",
5000,
);
expect(execResult.output).toContain("double");
expect(execResult.output).toContain("single");
expect(execResult.isDead).toBe(true);
});
});
});
@@ -1,502 +0,0 @@
import {
findSessionByName,
createSession,
listWindows,
createWindow,
listPanes,
findWindowByName,
capturePaneContent,
extractExitCodeMarkerFromOutput,
sendText as tmuxSendText,
sendKeys as tmuxSendKeys,
renameWindow,
killWindow,
isWindowNameUnique,
getCurrentWorkingDirectory,
getCurrentCommand,
getStoredWorkingDirectory,
getStoredCommand,
waitForPaneActivityToSettle,
executeCommand as tmuxExecuteCommand,
executeTmux,
} from "./tmux.js";
// Terminal model: session → windows (single pane per window)
// Terminals are identified by their unique names, not IDs
export interface TerminalInfo {
name: string;
workingDirectory: string;
currentCommand: string;
lastLines: string | null;
}
export interface CommandInfo {
id: string;
name: string;
workingDirectory: string;
currentCommand: string;
isDead: boolean;
exitCode: number | null;
lastLines: string | null;
}
export interface CreateTerminalParams {
name: string;
workingDirectory: string;
initialCommand?: string;
}
export interface CreateTerminalResult extends TerminalInfo {
commandOutput: string | null;
}
/**
* Terminal manager for a specific tmux session
* Multiple instances can coexist with different session names
*/
export class TerminalManager {
constructor(private sessionName: string) {}
/**
* Initialize the tmux session
* Creates it if it doesn't exist
*/
async initialize(): Promise<void> {
const session = await findSessionByName(this.sessionName);
if (!session) {
await createSession(this.sessionName);
}
}
/**
* List all terminals in the session
* Returns terminal info including name, active status, working directory, and current command
*/
async listTerminals(): Promise<TerminalInfo[]> {
const session = await findSessionByName(this.sessionName);
if (!session) {
throw new Error(`Session '${this.sessionName}' not found. Call initialize() first.`);
}
const windows = await listWindows(session.id);
const terminals: TerminalInfo[] = [];
for (const window of windows) {
// Get the first (and only) pane in this window
const paneId = `${window.id}.0`;
const workingDirectory = await getCurrentWorkingDirectory(paneId);
const currentCommand = await getCurrentCommand(paneId);
const lastLines = await capturePaneContent(paneId, 5, false);
terminals.push({
name: window.name,
workingDirectory,
currentCommand,
lastLines: lastLines || null,
});
}
return terminals;
}
/**
* Create a new terminal (tmux window) with specified name and working directory
* Optionally execute an initial command
*/
async createTerminal(params: CreateTerminalParams): Promise<CreateTerminalResult> {
const session = await findSessionByName(this.sessionName);
if (!session) {
throw new Error(`Session '${this.sessionName}' not found. Call initialize() first.`);
}
// Validate name uniqueness
const isUnique = await isWindowNameUnique(session.id, params.name);
if (!isUnique) {
throw new Error(
`Terminal with name '${params.name}' already exists. Please choose a unique name.`,
);
}
// Create the window
const windowResult = await createWindow(session.id, params.name, {
workingDirectory: params.workingDirectory,
command: params.initialCommand ?? null,
});
if (!windowResult) {
throw new Error(`Failed to create terminal '${params.name}'`);
}
const paneId = windowResult.paneId;
// Get terminal info
const workingDirectory = await getCurrentWorkingDirectory(paneId);
const currentCommand = await getCurrentCommand(paneId);
return {
name: windowResult.name,
workingDirectory,
currentCommand,
commandOutput: windowResult.output || null,
lastLines: null,
};
}
/**
* Capture output from a terminal by name
* Returns the last N lines of terminal content
* If maxWait is provided, waits for terminal activity to settle before capturing
*/
async captureTerminal(
terminalName: string,
lines: number = 200,
maxWait?: number,
): Promise<string> {
const session = await findSessionByName(this.sessionName);
if (!session) {
throw new Error(`Session '${this.sessionName}' not found.`);
}
// Resolve terminal name to window
const window = await findWindowByName(session.id, terminalName);
if (!window) {
const windows = await listWindows(session.id);
const availableNames = windows.map((w) => w.name).join(", ");
throw new Error(
`Terminal '${terminalName}' not found. Available terminals: ${availableNames}`,
);
}
// Get the first pane
const panes = await listPanes(window.id);
const pane = panes[0];
if (!pane) {
throw new Error(`No pane found for terminal ${terminalName}`);
}
// Wait for activity to settle if maxWait is provided
if (maxWait) {
return waitForPaneActivityToSettle(pane.id, maxWait, lines);
}
return capturePaneContent(pane.id, lines, false);
}
/**
* Send text to a terminal by name, optionally press Enter, optionally return output
*/
async sendText(
terminalName: string,
text: string,
pressEnter: boolean = false,
return_output?: { lines?: number; waitForSettled?: boolean; maxWait?: number },
): Promise<string | null> {
const session = await findSessionByName(this.sessionName);
if (!session) {
throw new Error(`Session '${this.sessionName}' not found.`);
}
// Resolve terminal name to window
const window = await findWindowByName(session.id, terminalName);
if (!window) {
const windows = await listWindows(session.id);
const availableNames = windows.map((w) => w.name).join(", ");
throw new Error(
`Terminal '${terminalName}' not found. Available terminals: ${availableNames}`,
);
}
// Get the first pane
const panes = await listPanes(window.id);
const pane = panes[0];
if (!pane) {
throw new Error(`No pane found for terminal ${terminalName}`);
}
return tmuxSendText({
paneId: pane.id,
text,
pressEnter,
return_output,
});
}
/**
* Send special keys or key combinations to a terminal by name
* Useful for TUI navigation, control sequences, and interactive applications
*/
async sendKeys(
terminalName: string,
keys: string,
repeat: number = 1,
return_output?: { lines?: number; waitForSettled?: boolean; maxWait?: number },
): Promise<string | null> {
const session = await findSessionByName(this.sessionName);
if (!session) {
throw new Error(`Session '${this.sessionName}' not found.`);
}
// Resolve terminal name to window
const window = await findWindowByName(session.id, terminalName);
if (!window) {
const windows = await listWindows(session.id);
const availableNames = windows.map((w) => w.name).join(", ");
throw new Error(
`Terminal '${terminalName}' not found. Available terminals: ${availableNames}`,
);
}
// Get the first pane
const panes = await listPanes(window.id);
const pane = panes[0];
if (!pane) {
throw new Error(`No pane found for terminal ${terminalName}`);
}
return tmuxSendKeys({
paneId: pane.id,
keys,
repeat,
return_output,
});
}
/**
* Rename a terminal by name
* Validates that the new name is unique
*/
async renameTerminal(terminalName: string, newName: string): Promise<void> {
const session = await findSessionByName(this.sessionName);
if (!session) {
throw new Error(`Session '${this.sessionName}' not found.`);
}
// renameWindow handles uniqueness validation and name resolution internally
await renameWindow(session.id, terminalName, newName);
}
/**
* Kill (close/destroy) a terminal by name
*/
async killTerminal(terminalName: string): Promise<void> {
const session = await findSessionByName(this.sessionName);
if (!session) {
throw new Error(`Session '${this.sessionName}' not found.`);
}
// Resolve terminal name to window
const window = await findWindowByName(session.id, terminalName);
if (!window) {
const windows = await listWindows(session.id);
const availableNames = windows.map((w) => w.name).join(", ");
throw new Error(
`Terminal '${terminalName}' not found. Available terminals: ${availableNames}`,
);
}
await killWindow(window.id);
}
/**
* Execute a command and return its output, exit code, and running status
* Commands are wrapped in bash -c to support all bash features
* Windows remain after command exits (remain-on-exit) for inspection
*/
async executeCommand(
command: string,
workingDirectory: string,
maxWait?: number,
): Promise<{
commandId: string;
output: string;
exitCode: number | null;
isDead: boolean;
}> {
const session = await findSessionByName(this.sessionName);
if (!session) {
throw new Error(`Session '${this.sessionName}' not found. Call initialize() first.`);
}
const result = await tmuxExecuteCommand({
sessionId: session.id,
command,
workingDirectory,
maxWait,
});
return {
commandId: result.windowId,
output: result.output,
exitCode: result.exitCode,
isDead: result.isDead,
};
}
/**
* List all commands (windows), including those that have exited
* Shows both running and dead commands for inspection
*/
async listCommands(): Promise<CommandInfo[]> {
const session = await findSessionByName(this.sessionName);
if (!session) {
throw new Error(`Session '${this.sessionName}' not found. Call initialize() first.`);
}
const windows = await listWindows(session.id);
const commands: CommandInfo[] = [];
for (const window of windows) {
const paneId = `${window.id}.0`;
// Check if pane is dead first
const deadStatus = await executeTmux(["display-message", "-p", "-t", paneId, "#{pane_dead}"]);
const isDead = deadStatus === "1";
// Use stored values for dead panes, current values for live ones
const workingDirectory = await getStoredWorkingDirectory(window.id, paneId);
const currentCommand = await getStoredCommand(window.id, paneId);
const rawLastLines = await capturePaneContent(paneId, 5, false);
const lastLinesExtracted = extractExitCodeMarkerFromOutput(rawLastLines);
const lastLines = lastLinesExtracted.output;
let exitCode: number | null = null;
if (isDead) {
const exitCodeStr = await executeTmux([
"display-message",
"-p",
"-t",
paneId,
"#{pane_dead_status}",
]);
const parsed = parseInt(exitCodeStr, 10);
exitCode = Number.isFinite(parsed) ? parsed : lastLinesExtracted.exitCode;
}
commands.push({
id: window.id,
name: window.name,
workingDirectory,
currentCommand,
isDead,
exitCode,
lastLines: lastLines || null,
});
}
return commands;
}
/**
* Capture output from a command by ID (window ID)
*/
async captureCommand(
commandId: string,
lines: number = 200,
): Promise<{
output: string;
exitCode: number | null;
isDead: boolean;
}> {
const session = await findSessionByName(this.sessionName);
if (!session) {
throw new Error(`Session '${this.sessionName}' not found.`);
}
const paneId = `${commandId}.0`;
const rawOutput = await capturePaneContent(paneId, lines, false);
const extracted = extractExitCodeMarkerFromOutput(rawOutput);
const output = extracted.output;
const deadStatus = await executeTmux(["display-message", "-p", "-t", paneId, "#{pane_dead}"]);
const isDead = deadStatus === "1";
let exitCode: number | null = null;
if (isDead) {
const exitCodeStr = await executeTmux([
"display-message",
"-p",
"-t",
paneId,
"#{pane_dead_status}",
]);
const parsed = parseInt(exitCodeStr, 10);
exitCode = Number.isFinite(parsed) ? parsed : extracted.exitCode;
}
return {
output,
exitCode,
isDead,
};
}
/**
* Send text to a command by ID (window ID)
*/
async sendTextToCommand(
commandId: string,
text: string,
pressEnter: boolean = false,
return_output?: { lines?: number; waitForSettled?: boolean; maxWait?: number },
): Promise<string | null> {
const session = await findSessionByName(this.sessionName);
if (!session) {
throw new Error(`Session '${this.sessionName}' not found.`);
}
const paneId = `${commandId}.0`;
return tmuxSendText({
paneId,
text,
pressEnter,
return_output,
});
}
/**
* Send keys to a command by ID (window ID)
*/
async sendKeysToCommand(
commandId: string,
keys: string,
repeat: number = 1,
return_output?: { lines?: number; waitForSettled?: boolean; maxWait?: number },
): Promise<string | null> {
const session = await findSessionByName(this.sessionName);
if (!session) {
throw new Error(`Session '${this.sessionName}' not found.`);
}
const paneId = `${commandId}.0`;
return tmuxSendKeys({
paneId,
keys,
repeat,
return_output,
});
}
/**
* Kill a command by ID (window ID)
*/
async killCommand(commandId: string): Promise<void> {
const session = await findSessionByName(this.sessionName);
if (!session) {
throw new Error(`Session '${this.sessionName}' not found.`);
}
await killWindow(commandId);
}
}
File diff suppressed because it is too large Load Diff
@@ -197,6 +197,7 @@ type WebSocketRuntimeCounters = {
hostRejected: number;
};
const SLOW_REQUEST_THRESHOLD_MS = 500;
const EXTERNAL_SESSION_DISCONNECT_GRACE_MS = 90_000;
const HELLO_TIMEOUT_MS = 15_000;
const WS_CLOSE_HELLO_TIMEOUT = 4001;
@@ -275,6 +276,7 @@ export class VoiceAssistantWebSocketServer {
};
private readonly inboundMessageCounts = new Map<string, number>();
private readonly inboundSessionRequestCounts = new Map<string, number>();
private readonly requestLatencies = new Map<string, number[]>();
private runtimeMetricsInterval: ReturnType<typeof setInterval> | null = null;
constructor(
@@ -1056,7 +1058,21 @@ export class VoiceAssistantWebSocketServer {
if (message.type === "session") {
this.recordInboundSessionRequestType(message.message.type);
const startMs = performance.now();
await activeConnection.session.handleMessage(message.message);
const durationMs = performance.now() - startMs;
this.recordRequestLatency(message.message.type, durationMs);
if (durationMs >= SLOW_REQUEST_THRESHOLD_MS) {
activeConnection.connectionLogger.warn(
{
requestType: message.message.type,
durationMs: Math.round(durationMs),
inflightRequests: activeConnection.session.getRuntimeMetrics().inflightRequests,
},
"ws_slow_request",
);
}
}
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
@@ -1147,10 +1163,49 @@ export class VoiceAssistantWebSocketServer {
this.incrementCount(this.inboundSessionRequestCounts, type);
}
private recordRequestLatency(type: string, durationMs: number): void {
let latencies = this.requestLatencies.get(type);
if (!latencies) {
latencies = [];
this.requestLatencies.set(type, latencies);
}
latencies.push(durationMs);
}
private getTopCounts(map: Map<string, number>, limit: number): Array<[string, number]> {
return [...map.entries()].sort((a, b) => b[1] - a[1]).slice(0, limit);
}
private computeLatencyStats(): Array<{
type: string;
count: number;
minMs: number;
maxMs: number;
p50Ms: number;
totalMs: number;
}> {
const stats: Array<{
type: string;
count: number;
minMs: number;
maxMs: number;
p50Ms: number;
totalMs: number;
}> = [];
for (const [type, latencies] of this.requestLatencies) {
if (latencies.length === 0) continue;
latencies.sort((a, b) => a - b);
const count = latencies.length;
const minMs = Math.round(latencies[0]!);
const maxMs = Math.round(latencies[count - 1]!);
const p50Ms = Math.round(latencies[Math.floor(count / 2)]!);
const totalMs = Math.round(latencies.reduce((sum, v) => sum + v, 0));
stats.push({ type, count, minMs, maxMs, p50Ms, totalMs });
}
stats.sort((a, b) => b.totalMs - a.totalMs);
return stats.slice(0, 15);
}
private collectSessionRuntimeMetrics(): SessionRuntimeMetrics {
const uniqueConnections = new Set<SessionConnection>(this.externalSessionsByKey.values());
let checkoutDiffTargetCount = 0;
@@ -1159,6 +1214,8 @@ export class VoiceAssistantWebSocketServer {
let checkoutDiffFallbackRefreshTargetCount = 0;
let terminalDirectorySubscriptionCount = 0;
let terminalSubscriptionCount = 0;
let inflightRequests = 0;
let peakInflightRequests = 0;
for (const connection of uniqueConnections) {
const sessionMetrics = connection.session.getRuntimeMetrics();
@@ -1169,6 +1226,9 @@ export class VoiceAssistantWebSocketServer {
sessionMetrics.checkoutDiffFallbackRefreshTargetCount;
terminalDirectorySubscriptionCount += sessionMetrics.terminalDirectorySubscriptionCount;
terminalSubscriptionCount += sessionMetrics.terminalSubscriptionCount;
inflightRequests += sessionMetrics.inflightRequests;
peakInflightRequests = Math.max(peakInflightRequests, sessionMetrics.peakInflightRequests);
connection.session.resetPeakInflight();
}
return {
@@ -1178,6 +1238,8 @@ export class VoiceAssistantWebSocketServer {
checkoutDiffFallbackRefreshTargetCount,
terminalDirectorySubscriptionCount,
terminalSubscriptionCount,
inflightRequests,
peakInflightRequests,
};
}
@@ -1192,6 +1254,8 @@ export class VoiceAssistantWebSocketServer {
connection.sockets.size === 0 && connection.externalDisconnectCleanupTimeout !== null,
).length;
const sessionMetrics = this.collectSessionRuntimeMetrics();
const latencyStats = this.computeLatencyStats();
const agentSnapshot = this.agentManager.getMetricsSnapshot();
this.logger.info(
{
@@ -1210,6 +1274,8 @@ export class VoiceAssistantWebSocketServer {
inboundMessageTypesTop: this.getTopCounts(this.inboundMessageCounts, 12),
inboundSessionRequestTypesTop: this.getTopCounts(this.inboundSessionRequestCounts, 20),
runtime: sessionMetrics,
latency: latencyStats,
agents: agentSnapshot,
},
"ws_runtime_metrics",
);
@@ -1221,6 +1287,7 @@ export class VoiceAssistantWebSocketServer {
}
this.inboundMessageCounts.clear();
this.inboundSessionRequestCounts.clear();
this.requestLatencies.clear();
this.runtimeWindowStartedAt = now;
}
@@ -73,6 +73,12 @@ describe("TerminalManager", () => {
await expect(manager.getTerminals("tmp")).rejects.toThrow("cwd must be absolute path");
});
it("accepts Windows absolute paths", async () => {
manager = createTerminalManager();
await expect(manager.getTerminals("C:\\Users\\foo\\project")).resolves.not.toThrow();
await expect(manager.getTerminals("D:\\MyProject")).resolves.not.toThrow();
});
it("creates separate terminals for different cwds", async () => {
manager = createTerminalManager();
const tmpTerminals = [await manager.createTerminal({ cwd: "/tmp" })];
@@ -121,6 +127,18 @@ describe("TerminalManager", () => {
);
});
it("does not reject Windows absolute paths as relative", async () => {
manager = createTerminalManager();
// Should pass path validation (not throw "cwd must be absolute path").
// The terminal may or may not spawn successfully on non-Windows hosts,
// so we only assert the validation error is absent.
try {
await manager.createTerminal({ cwd: "C:\\Users\\foo\\project" });
} catch (error) {
expect((error as Error).message).not.toBe("cwd must be absolute path");
}
});
it("inherits registered env for the worktree root cwd", async () => {
await withShell("/bin/sh", async () => {
manager = createTerminalManager();
@@ -1,5 +1,5 @@
import { createTerminal, type TerminalSession } from "./terminal.js";
import { resolve, sep } from "node:path";
import { resolve, sep, win32, posix } from "node:path";
export interface TerminalListItem {
id: string;
@@ -37,7 +37,7 @@ export function createTerminalManager(): TerminalManager {
const defaultEnvByRootCwd = new Map<string, Record<string, string>>();
function assertAbsolutePath(cwd: string): void {
if (!cwd.startsWith("/")) {
if (!posix.isAbsolute(cwd) && !win32.isAbsolute(cwd)) {
throw new Error("cwd must be absolute path");
}
}
@@ -18,6 +18,9 @@ import {
NotGitRepoError,
pushCurrentBranch,
resolveRepositoryDefaultBranch,
parseWorktreeList,
isPaseoWorktreePath,
isDescendantPath,
} from "./checkout-git.js";
import { createWorktree } from "./worktree.js";
import { getPaseoWorktreeMetadataPath } from "./worktree-metadata.js";
@@ -812,4 +815,73 @@ const x = 1;
).rejects.toThrow(/base/i);
await expect(mergeToBase(worktree.worktreePath, {}, { paseoHome })).rejects.toThrow(/base/i);
});
describe("parseWorktreeList", () => {
it("parses porcelain worktree output", () => {
const output = [
"worktree /home/user/repo",
"branch refs/heads/main",
"",
"worktree /home/user/.paseo/worktrees/feature",
"branch refs/heads/feature",
"",
].join("\n");
const entries = parseWorktreeList(output);
expect(entries).toHaveLength(2);
expect(entries[0]).toEqual({ path: "/home/user/repo", branchRef: "refs/heads/main" });
expect(entries[1]).toEqual({
path: "/home/user/.paseo/worktrees/feature",
branchRef: "refs/heads/feature",
});
});
it("detects bare repos", () => {
const output = ["worktree /home/user/repo.git", "bare", ""].join("\n");
const entries = parseWorktreeList(output);
expect(entries).toHaveLength(1);
expect(entries[0]?.isBare).toBe(true);
});
});
describe("isPaseoWorktreePath", () => {
it("matches Unix .paseo/worktrees/ paths", () => {
expect(isPaseoWorktreePath("/home/user/.paseo/worktrees/feature")).toBe(true);
});
it("matches Windows .paseo\\worktrees\\ paths", () => {
expect(isPaseoWorktreePath("C:\\Users\\dev\\.paseo\\worktrees\\feature")).toBe(true);
});
it("rejects paths without .paseo/worktrees segment", () => {
expect(isPaseoWorktreePath("/home/user/repo")).toBe(false);
expect(isPaseoWorktreePath("C:\\Users\\dev\\repo")).toBe(false);
});
});
describe("isDescendantPath", () => {
it("detects children with Unix separators", () => {
expect(isDescendantPath("/home/user/repo/child", "/home/user/repo")).toBe(true);
});
it("detects children with Windows separators", () => {
expect(isDescendantPath("C:\\repos\\child", "C:\\repos")).toBe(true);
});
it("rejects the parent itself", () => {
expect(isDescendantPath("/home/user/repo", "/home/user/repo")).toBe(false);
});
it("rejects siblings that share a prefix", () => {
expect(isDescendantPath("/home/user/repo-extra", "/home/user/repo")).toBe(false);
});
it("handles mixed separators", () => {
expect(isDescendantPath("C:/repo/child", "C:\\repo")).toBe(true);
});
it("is case insensitive on Windows paths", () => {
expect(isDescendantPath("c:\\repo\\child", "C:\\repo")).toBe(true);
});
});
});
+23 -4
View File
@@ -603,20 +603,39 @@ async function getMainRepoRoot(cwd: string): Promise<string> {
});
const worktrees = parseWorktreeList(worktreeOut);
const nonBareNonPaseo = worktrees.filter(
(wt) => !wt.isBare && !wt.path.includes("/.paseo/worktrees/"),
(wt) => !wt.isBare && !isPaseoWorktreePath(wt.path),
);
const childrenOfBareRepo = nonBareNonPaseo.filter((wt) => wt.path.startsWith(normalized + "/"));
const childrenOfBareRepo = nonBareNonPaseo.filter((wt) => isDescendantPath(wt.path, normalized));
const mainChild = childrenOfBareRepo.find((wt) => basename(wt.path) === "main");
return mainChild?.path ?? childrenOfBareRepo[0]?.path ?? nonBareNonPaseo[0]?.path ?? normalized;
}
type GitWorktreeEntry = {
export type GitWorktreeEntry = {
path: string;
branchRef?: string;
isBare?: boolean;
};
function parseWorktreeList(output: string): GitWorktreeEntry[] {
/** Check whether a path contains a `.paseo/worktrees/` segment (both `/` and `\`). */
export function isPaseoWorktreePath(p: string): boolean {
return /[/\\]\.paseo[/\\]worktrees[/\\]/.test(p);
}
/** True when `child` is strictly inside `parent` (handles both `/` and `\`). */
export function isDescendantPath(child: string, parent: string): boolean {
let c = child.replace(/\\/g, "/").replace(/\/+$/, "");
let p = parent.replace(/\\/g, "/").replace(/\/+$/, "");
// Case-insensitive on Windows (drive letter like C: or D:)
if (/^[A-Za-z]:/.test(c) || /^[A-Za-z]:/.test(p)) {
c = c.toLowerCase();
p = p.toLowerCase();
}
if (!c.startsWith(p)) return false;
if (c.length === p.length) return false;
return c[p.length] === "/";
}
export function parseWorktreeList(output: string): GitWorktreeEntry[] {
const entries: GitWorktreeEntry[] = [];
let current: GitWorktreeEntry | null = null;
for (const line of output.split("\n")) {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.1.33",
"version": "0.1.37",
"private": true,
"type": "module",
"scripts": {
Binary file not shown.

After

Width:  |  Height:  |  Size: 862 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 592 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

@@ -0,0 +1,122 @@
import * as React from "react";
import { motion, AnimatePresence } from "framer-motion";
interface CommandDialogProps {
trigger: React.ReactNode;
title: string;
description?: string;
command: string;
footnote?: React.ReactNode;
}
export function CommandDialog({
trigger,
title,
description,
command,
footnote,
}: CommandDialogProps) {
const [open, setOpen] = React.useState(false);
const ref = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") setOpen(false);
}
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleKeyDown);
};
}, []);
return (
<div className="relative" ref={ref}>
<button onClick={() => setOpen(!open)}>{trigger}</button>
<AnimatePresence>
{open && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 z-40 bg-black/60 backdrop-blur-sm"
onClick={() => setOpen(false)}
/>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2, ease: "easeOut" }}
className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-50 w-full max-w-md rounded-xl border border-white/20 bg-background p-6 space-y-4"
>
<div className="space-y-2">
<p className="text-base font-medium text-white">{title}</p>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
<CodeBlock>{command}</CodeBlock>
{footnote && (
<p className="text-xs text-white/30">{footnote}</p>
)}
</motion.div>
</>
)}
</AnimatePresence>
</div>
);
}
function CodeBlock({ children }: { children: string }) {
const [copied, setCopied] = React.useState(false);
function handleCopy() {
navigator.clipboard.writeText(children);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
return (
<div className="bg-black/30 backdrop-blur-sm rounded-lg p-3 md:p-4 font-mono text-sm flex items-center justify-between gap-2">
<div>
<span className="text-muted-foreground select-none">$ </span>
<span className="text-foreground">{children}</span>
</div>
<button
onClick={handleCopy}
className="text-muted-foreground hover:text-foreground transition-colors p-1"
title="Copy to clipboard"
>
{copied ? (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
viewBox="0 0 256 256"
>
<path d="M229.66,77.66l-128,128a8,8,0,0,1-11.32,0l-56-56a8,8,0,0,1,11.32-11.32L96,188.69,218.34,66.34a8,8,0,0,1,11.32,11.32Z" />
</svg>
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
viewBox="0 0 256 256"
>
<path d="M216,28H88A20,20,0,0,0,68,48V76H40A20,20,0,0,0,20,96V216a20,20,0,0,0,20,20H168a20,20,0,0,0,20-20V188h28a20,20,0,0,0,20-20V48A20,20,0,0,0,216,28ZM164,212H44V100H164Zm48-48H188V96a20,20,0,0,0-20-20H92V52H212Z" />
</svg>
)}
</button>
</div>
);
}
+105 -347
View File
@@ -1,73 +1,20 @@
import * as React from "react";
import { motion, AnimatePresence } from "framer-motion";
import { CursorFieldProvider } from "~/components/butterfly";
import websitePackage from "../../package.json";
import { CommandDialog } from "~/components/command-dialog";
import {
appStoreUrl,
playStoreUrl,
webAppUrl,
downloadOptions,
useDetectedPlatform,
AppleIcon,
AndroidIcon,
TerminalIcon,
GlobeIcon,
} from "~/downloads";
import "~/styles.css";
const desktopVersion = websitePackage.version;
const releaseBase = `https://github.com/getpaseo/paseo/releases/download/v${desktopVersion}`;
type Platform = "mac-silicon" | "mac-intel" | "windows" | "linux";
interface DownloadOption {
platform: Platform;
label: string;
href: string;
icon: (props: React.SVGProps<SVGSVGElement>) => React.ReactElement;
}
const downloadOptions: DownloadOption[] = [
{
platform: "mac-silicon",
label: "Mac",
href: `${releaseBase}/Paseo-${desktopVersion}-arm64.dmg`,
icon: AppleIcon,
},
{
platform: "mac-intel",
label: "Mac Intel",
href: `${releaseBase}/Paseo-${desktopVersion}.dmg`,
icon: AppleIcon,
},
{
platform: "windows",
label: "Windows",
href: `${releaseBase}/Paseo-Setup-${desktopVersion}.exe`,
icon: WindowsIcon,
},
{
platform: "linux",
label: "Linux",
href: `${releaseBase}/Paseo-${desktopVersion}.AppImage`,
icon: LinuxIcon,
},
];
function useDetectedPlatform(): Platform {
const [platform, setPlatform] = React.useState<Platform>("mac-silicon");
React.useEffect(() => {
const ua = navigator.userAgent.toLowerCase();
if (ua.includes("win")) {
setPlatform("windows");
} else if (ua.includes("linux")) {
setPlatform("linux");
} else if (ua.includes("mac")) {
// Check for Apple Silicon vs Intel
// navigator.platform is deprecated but still the most reliable check
const isARM =
/arm|aarch64/i.test(navigator.platform) ||
// Chrome/Edge on Apple Silicon report x86 platform but have ARM in userAgentData
(navigator as unknown as { userAgentData?: { architecture?: string } }).userAgentData
?.architecture === "arm";
setPlatform(isARM ? "mac-silicon" : "mac-silicon"); // Default to Silicon for modern Macs
}
}, []);
return platform;
}
interface LandingPageProps {
title: React.ReactNode;
subtitle: string;
@@ -96,7 +43,7 @@ export function LandingPage({ title, subtitle }: LandingPageProps) {
>
<div className="max-w-6xl lg:max-w-7xl xl:max-w-[90rem] mx-auto">
<img
src="/paseo-mockup.png"
src="/hero-mockup.png"
alt="Paseo app showing agent management interface"
className="w-full rounded-lg shadow-2xl"
/>
@@ -109,6 +56,7 @@ export function LandingPage({ title, subtitle }: LandingPageProps) {
<main className="p-6 md:p-20 md:pt-8 max-w-5xl mx-auto">
<div className="space-y-24">
<Features />
<MobileSection />
<CLISection />
<FAQ />
<SponsorCTA />
@@ -193,7 +141,7 @@ export function LandingPage({ title, subtitle }: LandingPageProps) {
<p className="text-white/60 font-medium">Download</p>
<div className="space-y-2">
<a
href="https://apps.apple.com/app/paseo-pocket-engineer/id6758887924"
href={appStoreUrl}
target="_blank"
rel="noopener noreferrer"
className="block text-white/40 hover:text-white/60 transition-colors"
@@ -201,7 +149,7 @@ export function LandingPage({ title, subtitle }: LandingPageProps) {
App Store
</a>
<a
href="https://play.google.com/store/apps/details?id=sh.paseo"
href={playStoreUrl}
target="_blank"
rel="noopener noreferrer"
className="block text-white/40 hover:text-white/60 transition-colors"
@@ -217,7 +165,7 @@ export function LandingPage({ title, subtitle }: LandingPageProps) {
Desktop
</a>
<a
href="https://app.paseo.sh"
href={webAppUrl}
target="_blank"
rel="noopener noreferrer"
className="block text-white/40 hover:text-white/60 transition-colors"
@@ -255,6 +203,12 @@ function Nav() {
>
Changelog
</a>
<a
href="/download"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Download
</a>
<a
href="https://discord.gg/jz8T2uahpH"
target="_blank"
@@ -453,7 +407,7 @@ function Features() {
<span className="text-white/40">{feature.icon}</span>
<p className="font-medium text-lg">{feature.title}</p>
</div>
<p className="text-sm text-white/60 leading-relaxed">{feature.description}</p>
<p className="text-sm text-muted-foreground leading-relaxed">{feature.description}</p>
</motion.div>
))}
</div>
@@ -487,7 +441,7 @@ function GetStarted() {
<div className="flex flex-row flex-wrap gap-3">
<DownloadButton />
<a
href="https://app.paseo.sh"
href={webAppUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center gap-2 rounded-lg border border-white/20 px-4 py-2 text-sm font-medium text-white hover:bg-white/10 transition-colors"
@@ -496,7 +450,7 @@ function GetStarted() {
Web App
</a>
<a
href="https://apps.apple.com/app/paseo-pocket-engineer/id6758887924"
href={appStoreUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center rounded-lg border border-white/20 px-3 py-2 text-white hover:bg-white/10 transition-colors"
@@ -505,7 +459,7 @@ function GetStarted() {
<AppleIcon className="h-5 w-5" />
</a>
<a
href="https://play.google.com/store/apps/details?id=sh.paseo"
href={playStoreUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center rounded-lg border border-white/20 px-3 py-2 text-white hover:bg-white/10 transition-colors"
@@ -515,7 +469,15 @@ function GetStarted() {
</a>
<ServerInstallButton />
</div>
<div className="flex items-center gap-2 pt-8">
<div className="pt-3">
<a
href="/download"
className="text-xs text-white/40 hover:text-white/70 transition-colors"
>
All download options
</a>
</div>
<div className="flex items-center gap-2 pt-6">
<span className="text-xs text-white/40">Supports</span>
<div className="flex items-center gap-1">
<AgentBadge name="Claude Code" icon={<ClaudeCodeIcon className="h-6 w-6" />} />
@@ -529,133 +491,40 @@ function GetStarted() {
function DownloadButton() {
const detectedPlatform = useDetectedPlatform();
const [open, setOpen] = React.useState(false);
const ref = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const primary = downloadOptions.find((o) => o.platform === detectedPlatform)!;
const secondaryOptions = downloadOptions.filter((o) => o.platform !== detectedPlatform);
const PrimaryIcon = primary.icon;
return (
<div className="relative" ref={ref}>
<div className="flex items-stretch">
<a
href={primary.href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 rounded-l-lg bg-foreground px-4 py-2 text-sm font-medium text-background hover:bg-foreground/90 transition-colors"
>
<PrimaryIcon className="h-4 w-4" />
Download for {primary.label}
</a>
<button
onClick={() => setOpen(!open)}
className="inline-flex items-center justify-center rounded-r-lg border-l border-background/20 bg-foreground px-2 py-2 text-background hover:bg-foreground/90 transition-colors"
aria-label="More download options"
>
<ChevronDownIcon className="h-4 w-4" />
</button>
</div>
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.15, ease: "easeOut" }}
className="absolute left-0 top-full mt-1 z-50 min-w-[200px] rounded-lg border border-white/20 bg-black/90 backdrop-blur-sm py-1"
>
{secondaryOptions.map((option) => {
const Icon = option.icon;
return (
<a
key={option.platform}
href={option.href}
target="_blank"
rel="noopener noreferrer"
onClick={() => setOpen(false)}
className="flex items-center gap-2 px-4 py-2 text-sm text-white/80 hover:bg-white/10 transition-colors"
>
<Icon className="h-4 w-4" />
{option.label}
</a>
);
})}
</motion.div>
)}
</AnimatePresence>
</div>
<a
href={primary.href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 rounded-lg bg-foreground px-4 py-2 text-sm font-medium text-background hover:bg-foreground/90 transition-colors"
>
<PrimaryIcon className="h-4 w-4" />
Download for {primary.label}
</a>
);
}
function ServerInstallButton() {
const [open, setOpen] = React.useState(false);
const ref = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
return (
<div className="relative" ref={ref}>
<button
onClick={() => setOpen(!open)}
className="inline-flex items-center justify-center rounded-lg border border-white/20 px-3 py-2 text-white hover:bg-white/10 transition-colors"
aria-label="Install on a server"
>
<TerminalIcon className="h-5 w-5" />
</button>
<AnimatePresence>
{open && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 z-40 bg-black/60 backdrop-blur-sm"
onClick={() => setOpen(false)}
/>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2, ease: "easeOut" }}
className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-50 w-full max-w-md rounded-xl border border-white/20 bg-background p-6 space-y-4"
>
<div className="space-y-2">
<p className="text-base font-medium text-white">Run agents on a remote machine</p>
<p className="text-sm text-white/60">
For headless machines you want to connect to from the Paseo apps. The desktop app
already includes a built-in daemon.
</p>
</div>
<CodeBlock>npm install -g @getpaseo/cli && paseo</CodeBlock>
<p className="text-xs text-white/30">
Requires Node.js 18+. Run <span className="font-mono text-white/40">paseo</span> to
start the daemon.
</p>
</motion.div>
</>
)}
</AnimatePresence>
</div>
<CommandDialog
trigger={
<span className="inline-flex items-center justify-center rounded-lg border border-white/20 px-3 py-2 text-white hover:bg-white/10 transition-colors">
<TerminalIcon className="h-5 w-5" />
</span>
}
title="Run agents on a remote machine"
description="For headless machines you want to connect to from the Paseo apps. The desktop app already includes a built-in daemon."
command="npm install -g @getpaseo/cli && paseo"
footnote={
<>
Requires Node.js 18+. Run <span className="font-mono text-white/40">paseo</span> to
start the daemon.
</>
}
/>
);
}
@@ -708,34 +577,6 @@ function OpenCodeIcon(props: React.SVGProps<SVGSVGElement>) {
);
}
function AppleIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
{...props}
>
<path d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701" />
</svg>
);
}
function AndroidIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512"
fill="currentColor"
aria-hidden="true"
{...props}
>
<path d="M380.91,199l42.47-73.57a8.63,8.63,0,0,0-3.12-11.76,8.52,8.52,0,0,0-11.71,3.12l-43,74.52c-32.83-15-69.78-23.35-109.52-23.35s-76.69,8.36-109.52,23.35l-43-74.52a8.6,8.6,0,1,0-14.88,8.64L131,199C57.8,238.64,8.19,312.77,0,399.55H512C503.81,312.77,454.2,238.64,380.91,199ZM138.45,327.65a21.46,21.46,0,1,1,21.46-21.46A21.47,21.47,0,0,1,138.45,327.65Zm235,0A21.46,21.46,0,1,1,395,306.19,21.47,21.47,0,0,1,373.49,327.65Z" />
</svg>
);
}
function AppStoreIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
@@ -762,57 +603,6 @@ function AppStoreIcon(props: React.SVGProps<SVGSVGElement>) {
);
}
function WindowsIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
{...props}
>
<path d="M0,0H11.377V11.372H0ZM12.623,0H24V11.372H12.623ZM0,12.623H11.377V24H0Zm12.623,0H24V24H12.623" />
</svg>
);
}
function LinuxIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
aria-hidden="true"
{...props}
>
<path d="M8.996 4.497c.104-.076.1-.168.186-.158s.022.102-.098.207c-.12.104-.308.243-.46.323-.291.152-.631.336-.993.336s-.647-.167-.853-.33c-.102-.082-.186-.162-.248-.221-.11-.086-.096-.207-.052-.204.075.01.087.109.134.153.064.06.144.137.241.214.195.154.454.304.778.304s.702-.19.932-.32c.13-.073.297-.204.433-.304M7.34 3.781c.055-.02.123-.031.174-.003.011.006.024.021.02.034-.012.038-.074.032-.11.05-.032.017-.057.052-.093.054-.034 0-.086-.012-.09-.046-.007-.044.058-.072.1-.089m.581-.003c.05-.028.119-.018.173.003.041.017.106.045.1.09-.004.033-.057.046-.09.045-.036-.002-.062-.037-.093-.053-.036-.019-.098-.013-.11-.051-.004-.013.008-.028.02-.034" />
<path
fillRule="evenodd"
d="M8.446.019c2.521.003 2.38 2.66 2.364 4.093-.01.939.509 1.574 1.04 2.244.474.56 1.095 1.38 1.45 2.32.29.765.402 1.613.115 2.465a.8.8 0 0 1 .254.152l.001.002c.207.175.271.447.329.698.058.252.112.488.224.615.344.382.494.667.48.922-.015.254-.203.43-.435.57-.465.28-1.164.491-1.586 1.002-.443.527-.99.83-1.505.871a1.25 1.25 0 0 1-1.256-.716v-.001a1 1 0 0 1-.078-.21c-.67.038-1.252-.165-1.718-.128-.687.038-1.116.204-1.506.206-.151.331-.445.547-.808.63-.5.114-1.126 0-1.743-.324-.577-.306-1.31-.278-1.85-.39-.27-.057-.51-.157-.626-.384-.116-.226-.095-.538.07-.988.051-.16.012-.398-.026-.648a2.5 2.5 0 0 1-.037-.369c0-.133.022-.265.087-.386v-.002c.14-.266.368-.377.577-.451s.397-.125.53-.258c.143-.15.27-.374.443-.56q.036-.037.073-.07c-.081-.538.007-1.105.192-1.662.393-1.18 1.223-2.314 1.811-3.014.502-.713.65-1.287.701-2.016.042-.997-.705-3.974 2.112-4.2q.168-.015.321-.013m2.596 10.866-.03.016c-.223.121-.348.337-.427.656-.08.32-.107.733-.13 1.206v.001c-.023.37-.192.824-.31 1.267s-.176.862-.036 1.128v.002c.226.452.608.636 1.051.601s.947-.304 1.36-.795c.474-.576 1.218-.796 1.638-1.05.21-.126.324-.242.333-.4.009-.157-.097-.403-.425-.767-.17-.192-.217-.462-.274-.71-.056-.247-.122-.468-.26-.585l-.001-.001c-.18-.157-.356-.17-.565-.164q-.069.001-.14.005c-.239.275-.805.612-1.197.508-.359-.09-.562-.508-.587-.918m-7.204.03H3.83c-.189.002-.314.09-.44.225-.149.158-.276.382-.445.56v.002h-.002c-.183.184-.414.239-.61.31-.195.069-.353.143-.46.35v.002c-.085.155-.066.378-.029.624.038.245.096.507.018.746v.002l-.001.002c-.157.427-.155.678-.082.822.074.143.235.22.48.272.493.103 1.26.069 1.906.41.583.305 1.168.404 1.598.305.431-.098.712-.369.75-.867v-.002c.029-.292-.195-.673-.485-1.052-.29-.38-.633-.752-.795-1.09v-.002l-.61-1.11c-.21-.286-.43-.462-.68-.5a1 1 0 0 0-.106-.008M9.584 4.85c-.14.2-.386.37-.695.467-.147.048-.302.17-.495.28a1.3 1.3 0 0 1-.74.19.97.97 0 0 1-.582-.227c-.14-.113-.25-.237-.394-.322a3 3 0 0 1-.192-.126c-.063 1.179-.85 2.658-1.226 3.511a5.4 5.4 0 0 0-.43 1.917c-.68-.906-.184-2.066.081-2.568.297-.55.343-.701.27-.649-.266.436-.685 1.13-.848 1.844-.085.372-.1.749.01 1.097.11.349.345.67.766.931.573.351.963.703 1.193 1.015s.302.584.23.777a.4.4 0 0 1-.212.22.7.7 0 0 1-.307.056l.184.235c.094.124.186.249.266.375 1.179.805 2.567.496 3.568-.218.1-.342.197-.664.212-.903.024-.474.05-.896.136-1.245s.244-.634.53-.791a1 1 0 0 1 .138-.061q.005-.045.013-.087c.082-.546.569-.572 1.18-.303.588.266.81.499.71.814h.13c.122-.398-.133-.69-.822-1.025l-.137-.06a2.35 2.35 0 0 0-.012-1.113c-.188-.79-.704-1.49-1.098-1.838-.072-.003-.065.06.081.203.363.333 1.156 1.532.727 2.644a1.2 1.2 0 0 0-.342-.043c-.164-.907-.543-1.66-.735-2.014-.359-.668-.918-2.036-1.158-2.983M7.72 3.503a1 1 0 0 0-.312.053c-.268.093-.447.286-.559.391-.022.021-.05.04-.119.091s-.172.126-.321.238q-.198.151-.13.38c.046.15.192.325.459.476.166.098.28.23.41.334a1 1 0 0 0 .215.133.9.9 0 0 0 .298.066c.282.017.49-.068.673-.173s.34-.233.518-.29c.365-.115.627-.345.709-.564a.37.37 0 0 0-.01-.309c-.048-.096-.148-.187-.318-.257h-.001c-.354-.151-.507-.162-.705-.29-.321-.207-.587-.28-.807-.279m-.89-1.122h-.025a.4.4 0 0 0-.278.135.76.76 0 0 0-.191.334 1.2 1.2 0 0 0-.051.445v.001c.01.162.041.299.102.436.05.116.109.204.183.274l.089-.065.117-.09-.023-.018a.4.4 0 0 1-.11-.161.7.7 0 0 1-.054-.22v-.01a.7.7 0 0 1 .014-.234.4.4 0 0 1 .08-.179q.056-.069.126-.073h.013a.18.18 0 0 1 .123.05c.045.04.08.09.11.162a.7.7 0 0 1 .054.22v.01a.7.7 0 0 1-.002.17 1.1 1.1 0 0 1 .317-.143 1.3 1.3 0 0 0 .002-.194V3.23a1.2 1.2 0 0 0-.102-.437.8.8 0 0 0-.227-.31.4.4 0 0 0-.268-.102m1.95-.155a.63.63 0 0 0-.394.14.9.9 0 0 0-.287.376 1.2 1.2 0 0 0-.1.51v.015q0 .079.01.152c.114.027.278.074.406.138a1 1 0 0 1-.011-.172.8.8 0 0 1 .058-.278.5.5 0 0 1 .139-.2.26.26 0 0 1 .182-.069.26.26 0 0 1 .178.081c.055.054.094.12.124.21.029.086.042.17.04.27l-.002.012a.8.8 0 0 1-.057.277c-.024.059-.089.106-.122.145.046.016.09.03.146.052a5 5 0 0 1 .248.102 1.2 1.2 0 0 0 .244-.763 1.2 1.2 0 0 0-.11-.495.9.9 0 0 0-.294-.37.64.64 0 0 0-.39-.133z"
/>
</svg>
);
}
function TerminalIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
{...props}
>
<polyline points="4 17 10 11 4 5" />
<line x1="12" x2="20" y1="19" y2="19" />
</svg>
);
}
function ChevronDownIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
@@ -831,27 +621,6 @@ function ChevronDownIcon(props: React.SVGProps<SVGSVGElement>) {
);
}
function GlobeIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
{...props}
>
<circle cx="12" cy="12" r="9" />
<path d="M3 12h18" />
<path d="M12 3a15 15 0 0 1 0 18" />
<path d="M12 3a15 15 0 0 0 0 18" />
</svg>
);
}
function Step({ number, children }: { number: number; children: React.ReactNode }) {
return (
<div className="flex gap-4">
@@ -863,52 +632,6 @@ function Step({ number, children }: { number: number; children: React.ReactNode
);
}
function CodeBlock({ children }: { children: React.ReactNode }) {
const [copied, setCopied] = React.useState(false);
const text = typeof children === "string" ? children : "";
function handleCopy() {
navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
return (
<div className="bg-black/30 backdrop-blur-sm rounded-lg p-3 md:p-4 font-mono text-sm flex items-center justify-between gap-2">
<div>
<span className="text-muted-foreground select-none">$ </span>
<span className="text-foreground">{children}</span>
</div>
<button
onClick={handleCopy}
className="text-muted-foreground hover:text-foreground transition-colors p-1"
title="Copy to clipboard"
>
{copied ? (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
viewBox="0 0 256 256"
>
<path d="M229.66,77.66l-128,128a8,8,0,0,1-11.32,0l-56-56a8,8,0,0,1,11.32-11.32L96,188.69,218.34,66.34a8,8,0,0,1,11.32,11.32Z" />
</svg>
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
viewBox="0 0 256 256"
>
<path d="M216,28H88A20,20,0,0,0,68,48V76H40A20,20,0,0,0,20,96V216a20,20,0,0,0,20,20H168a20,20,0,0,0,20-20V188h28a20,20,0,0,0,20-20V48A20,20,0,0,0,216,28ZM164,212H44V100H164Zm48-48H188V96a20,20,0,0,0-20-20H92V52H212Z" />
</svg>
)}
</button>
</div>
);
}
const bashKeywords = new Set([
"while",
@@ -1195,6 +918,28 @@ done`,
},
];
function MobileSection() {
return (
<section className="space-y-6">
<div className="space-y-2">
<h2 className="text-2xl font-medium">Mobile-first</h2>
<p className="text-sm text-muted-foreground max-w-lg">
The mobile app has full feature parity with desktop. Launch agents, review diffs, talk through problems with voice, all from your phone.
</p>
</div>
<div className="-mx-[calc(50vw-50%)] px-6 md:px-8 overflow-x-auto">
<div className="max-w-5xl lg:max-w-6xl mx-auto">
<img
src="/mobile-mockup.png"
alt="Paseo mobile app screens"
className="min-w-[900px] w-full rounded-lg"
/>
</div>
</div>
</section>
);
}
function CLISection() {
const [activeIndex, setActiveIndex] = React.useState(0);
const active = cliExamples[activeIndex];
@@ -1209,7 +954,7 @@ function CLISection() {
>
<div className="space-y-2">
<h2 className="text-2xl font-medium">CLI</h2>
<p className="text-sm text-white/60 max-w-lg">
<p className="text-sm text-muted-foreground max-w-lg">
Everything you can do in the app, you can do from the terminal.
</p>
</div>
@@ -1231,7 +976,7 @@ function CLISection() {
</div>
<div className="space-y-3">
<p className="text-sm text-white/50">{active.description}</p>
<p className="text-sm text-muted-foreground">{active.description}</p>
<CLICodeBlock>{active.code}</CLICodeBlock>
</div>
@@ -1288,7 +1033,7 @@ function FAQ() {
</FAQItem>
<FAQItem question="Do I need the desktop app?">
No. You can run the daemon headless with{" "}
<code className="font-mono text-white/50">npm install -g @getpaseo/cli && paseo</code> and
<code className="font-mono text-muted-foreground">npm install -g @getpaseo/cli && paseo</code> and
use the CLI, web app, or mobile app to connect. The desktop app just bundles the daemon
with a UI.
</FAQItem>
@@ -1345,13 +1090,26 @@ function SponsorCTA() {
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-60px" }}
transition={{ duration: 0.5, ease: "easeOut" }}
className="rounded-xl bg-white/5 border border-white/10 p-8 md:p-10 text-center space-y-4"
className="rounded-xl bg-white/5 border border-white/10 p-8 md:p-10 text-left space-y-4 max-w-xl mx-auto"
>
<p className="text-lg font-medium">Paseo is an independent project</p>
<p className="text-sm text-white/50 max-w-md mx-auto leading-relaxed">
I built Paseo because the existing tools weren't good enough for me. There's no VC or big
team behind this. If it saves you time, sponsoring keeps development going.
</p>
<div className="text-sm text-muted-foreground leading-relaxed space-y-3">
<p>
I believe that open source and freedom of choice always win for developer tools.
</p>
<p>
Paseo has no VC funding and no big team behind it. I built it because the existing tools
weren't good enough for me. No tracking, no telemetry, no forced accounts, no vendor lock-in.
</p>
<p>
The monetization story is still taking shape. The obvious path is optional hosted
infrastructure like cloud sandboxes for teams. But Paseo itself will always be FOSS.
</p>
<p>
If you like Paseo, consider sponsoring development.
</p>
<p>- Mo</p>
</div>
<div className="pt-2">
<a
href="https://github.com/sponsors/boudra"
@@ -1384,7 +1142,7 @@ function FAQItem({ question, children }: { question: string; children: React.Rea
<span className="font-mono text-white/40 hidden group-open:inline">-</span>
{question}
</summary>
<div className="text-sm text-white/60 space-y-2 mt-2 ml-4">{children}</div>
<div className="text-sm text-muted-foreground space-y-2 mt-2 ml-4">{children}</div>
</details>
);
}
+174
View File
@@ -0,0 +1,174 @@
import * as React from "react";
import websitePackage from "../package.json";
export const desktopVersion = websitePackage.version;
export const releaseBase = `https://github.com/getpaseo/paseo/releases/download/v${desktopVersion}`;
export const macAppleSiliconDownloadUrl = `${releaseBase}/Paseo-${desktopVersion}-arm64.dmg`;
export const macIntelDownloadUrl = `${releaseBase}/Paseo-${desktopVersion}-x64.dmg`;
export const linuxAppImageDownloadUrl = `${releaseBase}/Paseo-${desktopVersion}-x86_64.AppImage`;
export const linuxDebDownloadUrl = `${releaseBase}/Paseo-${desktopVersion}-amd64.deb`;
export const linuxRpmDownloadUrl = `${releaseBase}/Paseo-${desktopVersion}-x86_64.rpm`;
export const appStoreUrl = "https://apps.apple.com/app/paseo-pocket-engineer/id6758887924";
export const playStoreUrl = "https://play.google.com/store/apps/details?id=sh.paseo";
export const webAppUrl = "https://app.paseo.sh";
type Platform = "mac-silicon" | "mac-intel" | "windows" | "linux";
export interface DownloadOption {
platform: Platform;
label: string;
href: string;
icon: (props: React.SVGProps<SVGSVGElement>) => React.ReactElement;
}
export const downloadOptions: DownloadOption[] = [
{
platform: "mac-silicon",
label: "Mac",
href: macAppleSiliconDownloadUrl,
icon: AppleIcon,
},
{
platform: "mac-intel",
label: "Mac Intel",
href: macIntelDownloadUrl,
icon: AppleIcon,
},
{
platform: "windows",
label: "Windows",
href: `${releaseBase}/Paseo-Setup-${desktopVersion}.exe`,
icon: WindowsIcon,
},
{
platform: "linux",
label: "Linux",
href: linuxAppImageDownloadUrl,
icon: LinuxIcon,
},
];
export function useDetectedPlatform(): Platform {
const [platform, setPlatform] = React.useState<Platform>("mac-silicon");
React.useEffect(() => {
const ua = navigator.userAgent.toLowerCase();
if (ua.includes("win")) {
setPlatform("windows");
} else if (ua.includes("linux")) {
setPlatform("linux");
} else if (ua.includes("mac")) {
// Check for Apple Silicon vs Intel
// navigator.platform is deprecated but still the most reliable check
const isARM =
/arm|aarch64/i.test(navigator.platform) ||
// Chrome/Edge on Apple Silicon report x86 platform but have ARM in userAgentData
(navigator as unknown as { userAgentData?: { architecture?: string } }).userAgentData
?.architecture === "arm";
setPlatform(isARM ? "mac-silicon" : "mac-silicon"); // Default to Silicon for modern Macs
}
}, []);
return platform;
}
export function AppleIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
{...props}
>
<path d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701" />
</svg>
);
}
export function AndroidIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512"
fill="currentColor"
aria-hidden="true"
{...props}
>
<path d="M380.91,199l42.47-73.57a8.63,8.63,0,0,0-3.12-11.76,8.52,8.52,0,0,0-11.71,3.12l-43,74.52c-32.83-15-69.78-23.35-109.52-23.35s-76.69,8.36-109.52,23.35l-43-74.52a8.6,8.6,0,1,0-14.88,8.64L131,199C57.8,238.64,8.19,312.77,0,399.55H512C503.81,312.77,454.2,238.64,380.91,199ZM138.45,327.65a21.46,21.46,0,1,1,21.46-21.46A21.47,21.47,0,0,1,138.45,327.65Zm235,0A21.46,21.46,0,1,1,395,306.19,21.47,21.47,0,0,1,373.49,327.65Z" />
</svg>
);
}
export function WindowsIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
{...props}
>
<path d="M0,0H11.377V11.372H0ZM12.623,0H24V11.372H12.623ZM0,12.623H11.377V24H0Zm12.623,0H24V24H12.623" />
</svg>
);
}
export function LinuxIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
aria-hidden="true"
{...props}
>
<path d="M8.996 4.497c.104-.076.1-.168.186-.158s.022.102-.098.207c-.12.104-.308.243-.46.323-.291.152-.631.336-.993.336s-.647-.167-.853-.33c-.102-.082-.186-.162-.248-.221-.11-.086-.096-.207-.052-.204.075.01.087.109.134.153.064.06.144.137.241.214.195.154.454.304.778.304s.702-.19.932-.32c.13-.073.297-.204.433-.304M7.34 3.781c.055-.02.123-.031.174-.003.011.006.024.021.02.034-.012.038-.074.032-.11.05-.032.017-.057.052-.093.054-.034 0-.086-.012-.09-.046-.007-.044.058-.072.1-.089m.581-.003c.05-.028.119-.018.173.003.041.017.106.045.1.09-.004.033-.057.046-.09.045-.036-.002-.062-.037-.093-.053-.036-.019-.098-.013-.11-.051-.004-.013.008-.028.02-.034" />
<path
fillRule="evenodd"
d="M8.446.019c2.521.003 2.38 2.66 2.364 4.093-.01.939.509 1.574 1.04 2.244.474.56 1.095 1.38 1.45 2.32.29.765.402 1.613.115 2.465a.8.8 0 0 1 .254.152l.001.002c.207.175.271.447.329.698.058.252.112.488.224.615.344.382.494.667.48.922-.015.254-.203.43-.435.57-.465.28-1.164.491-1.586 1.002-.443.527-.99.83-1.505.871a1.25 1.25 0 0 1-1.256-.716v-.001a1 1 0 0 1-.078-.21c-.67.038-1.252-.165-1.718-.128-.687.038-1.116.204-1.506.206-.151.331-.445.547-.808.63-.5.114-1.126 0-1.743-.324-.577-.306-1.31-.278-1.85-.39-.27-.057-.51-.157-.626-.384-.116-.226-.095-.538.07-.988.051-.16.012-.398-.026-.648a2.5 2.5 0 0 1-.037-.369c0-.133.022-.265.087-.386v-.002c.14-.266.368-.377.577-.451s.397-.125.53-.258c.143-.15.27-.374.443-.56q.036-.037.073-.07c-.081-.538.007-1.105.192-1.662.393-1.18 1.223-2.314 1.811-3.014.502-.713.65-1.287.701-2.016.042-.997-.705-3.974 2.112-4.2q.168-.015.321-.013m2.596 10.866-.03.016c-.223.121-.348.337-.427.656-.08.32-.107.733-.13 1.206v.001c-.023.37-.192.824-.31 1.267s-.176.862-.036 1.128v.002c.226.452.608.636 1.051.601s.947-.304 1.36-.795c.474-.576 1.218-.796 1.638-1.05.21-.126.324-.242.333-.4.009-.157-.097-.403-.425-.767-.17-.192-.217-.462-.274-.71-.056-.247-.122-.468-.26-.585l-.001-.001c-.18-.157-.356-.17-.565-.164q-.069.001-.14.005c-.239.275-.805.612-1.197.508-.359-.09-.562-.508-.587-.918m-7.204.03H3.83c-.189.002-.314.09-.44.225-.149.158-.276.382-.445.56v.002h-.002c-.183.184-.414.239-.61.31-.195.069-.353.143-.46.35v.002c-.085.155-.066.378-.029.624.038.245.096.507.018.746v.002l-.001.002c-.157.427-.155.678-.082.822.074.143.235.22.48.272.493.103 1.26.069 1.906.41.583.305 1.168.404 1.598.305.431-.098.712-.369.75-.867v-.002c.029-.292-.195-.673-.485-1.052-.29-.38-.633-.752-.795-1.09v-.002l-.61-1.11c-.21-.286-.43-.462-.68-.5a1 1 0 0 0-.106-.008M9.584 4.85c-.14.2-.386.37-.695.467-.147.048-.302.17-.495.28a1.3 1.3 0 0 1-.74.19.97.97 0 0 1-.582-.227c-.14-.113-.25-.237-.394-.322a3 3 0 0 1-.192-.126c-.063 1.179-.85 2.658-1.226 3.511a5.4 5.4 0 0 0-.43 1.917c-.68-.906-.184-2.066.081-2.568.297-.55.343-.701.27-.649-.266.436-.685 1.13-.848 1.844-.085.372-.1.749.01 1.097.11.349.345.67.766.931.573.351.963.703 1.193 1.015s.302.584.23.777a.4.4 0 0 1-.212.22.7.7 0 0 1-.307.056l.184.235c.094.124.186.249.266.375 1.179.805 2.567.496 3.568-.218.1-.342.197-.664.212-.903.024-.474.05-.896.136-1.245s.244-.634.53-.791a1 1 0 0 1 .138-.061q.005-.045.013-.087c.082-.546.569-.572 1.18-.303.588.266.81.499.71.814h.13c.122-.398-.133-.69-.822-1.025l-.137-.06a2.35 2.35 0 0 0-.012-1.113c-.188-.79-.704-1.49-1.098-1.838-.072-.003-.065.06.081.203.363.333 1.156 1.532.727 2.644a1.2 1.2 0 0 0-.342-.043c-.164-.907-.543-1.66-.735-2.014-.359-.668-.918-2.036-1.158-2.983M7.72 3.503a1 1 0 0 0-.312.053c-.268.093-.447.286-.559.391-.022.021-.05.04-.119.091s-.172.126-.321.238q-.198.151-.13.38c.046.15.192.325.459.476.166.098.28.23.41.334a1 1 0 0 0 .215.133.9.9 0 0 0 .298.066c.282.017.49-.068.673-.173s.34-.233.518-.29c.365-.115.627-.345.709-.564a.37.37 0 0 0-.01-.309c-.048-.096-.148-.187-.318-.257h-.001c-.354-.151-.507-.162-.705-.29-.321-.207-.587-.28-.807-.279m-.89-1.122h-.025a.4.4 0 0 0-.278.135.76.76 0 0 0-.191.334 1.2 1.2 0 0 0-.051.445v.001c.01.162.041.299.102.436.05.116.109.204.183.274l.089-.065.117-.09-.023-.018a.4.4 0 0 1-.11-.161.7.7 0 0 1-.054-.22v-.01a.7.7 0 0 1 .014-.234.4.4 0 0 1 .08-.179q.056-.069.126-.073h.013a.18.18 0 0 1 .123.05c.045.04.08.09.11.162a.7.7 0 0 1 .054.22v.01a.7.7 0 0 1-.002.17 1.1 1.1 0 0 1 .317-.143 1.3 1.3 0 0 0 .002-.194V3.23a1.2 1.2 0 0 0-.102-.437.8.8 0 0 0-.227-.31.4.4 0 0 0-.268-.102m1.95-.155a.63.63 0 0 0-.394.14.9.9 0 0 0-.287.376 1.2 1.2 0 0 0-.1.51v.015q0 .079.01.152c.114.027.278.074.406.138a1 1 0 0 1-.011-.172.8.8 0 0 1 .058-.278.5.5 0 0 1 .139-.2.26.26 0 0 1 .182-.069.26.26 0 0 1 .178.081c.055.054.094.12.124.21.029.086.042.17.04.27l-.002.012a.8.8 0 0 1-.057.277c-.024.059-.089.106-.122.145.046.016.09.03.146.052a5 5 0 0 1 .248.102 1.2 1.2 0 0 0 .244-.763 1.2 1.2 0 0 0-.11-.495.9.9 0 0 0-.294-.37.64.64 0 0 0-.39-.133z"
/>
</svg>
);
}
export function TerminalIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
{...props}
>
<polyline points="4 17 10 11 4 5" />
<line x1="12" x2="20" y1="19" y2="19" />
</svg>
);
}
export function GlobeIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
{...props}
>
<circle cx="12" cy="12" r="9" />
<path d="M3 12h18" />
<path d="M12 3a15 15 0 0 1 0 18" />
<path d="M12 3a15 15 0 0 0 0 18" />
</svg>
);
}
+305 -284
View File
@@ -8,332 +8,352 @@
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from "./routes/__root";
import { Route as PrivacyRouteImport } from "./routes/privacy";
import { Route as OpencodeRouteImport } from "./routes/opencode";
import { Route as DocsRouteImport } from "./routes/docs";
import { Route as CodexRouteImport } from "./routes/codex";
import { Route as ClaudeCodeRouteImport } from "./routes/claude-code";
import { Route as ChangelogRouteImport } from "./routes/changelog";
import { Route as IndexRouteImport } from "./routes/index";
import { Route as DocsIndexRouteImport } from "./routes/docs/index";
import { Route as DocsWorktreesRouteImport } from "./routes/docs/worktrees";
import { Route as DocsVoiceRouteImport } from "./routes/docs/voice";
import { Route as DocsUpdatesRouteImport } from "./routes/docs/updates";
import { Route as DocsSecurityRouteImport } from "./routes/docs/security";
import { Route as DocsConfigurationRouteImport } from "./routes/docs/configuration";
import { Route as DocsCliRouteImport } from "./routes/docs/cli";
import { Route as DocsBestPracticesRouteImport } from "./routes/docs/best-practices";
import { Route as rootRouteImport } from './routes/__root'
import { Route as PrivacyRouteImport } from './routes/privacy'
import { Route as OpencodeRouteImport } from './routes/opencode'
import { Route as DownloadRouteImport } from './routes/download'
import { Route as DocsRouteImport } from './routes/docs'
import { Route as CodexRouteImport } from './routes/codex'
import { Route as ClaudeCodeRouteImport } from './routes/claude-code'
import { Route as ChangelogRouteImport } from './routes/changelog'
import { Route as IndexRouteImport } from './routes/index'
import { Route as DocsIndexRouteImport } from './routes/docs/index'
import { Route as DocsWorktreesRouteImport } from './routes/docs/worktrees'
import { Route as DocsVoiceRouteImport } from './routes/docs/voice'
import { Route as DocsUpdatesRouteImport } from './routes/docs/updates'
import { Route as DocsSecurityRouteImport } from './routes/docs/security'
import { Route as DocsConfigurationRouteImport } from './routes/docs/configuration'
import { Route as DocsCliRouteImport } from './routes/docs/cli'
import { Route as DocsBestPracticesRouteImport } from './routes/docs/best-practices'
const PrivacyRoute = PrivacyRouteImport.update({
id: "/privacy",
path: "/privacy",
id: '/privacy',
path: '/privacy',
getParentRoute: () => rootRouteImport,
} as any);
} as any)
const OpencodeRoute = OpencodeRouteImport.update({
id: "/opencode",
path: "/opencode",
id: '/opencode',
path: '/opencode',
getParentRoute: () => rootRouteImport,
} as any);
} as any)
const DownloadRoute = DownloadRouteImport.update({
id: '/download',
path: '/download',
getParentRoute: () => rootRouteImport,
} as any)
const DocsRoute = DocsRouteImport.update({
id: "/docs",
path: "/docs",
id: '/docs',
path: '/docs',
getParentRoute: () => rootRouteImport,
} as any);
} as any)
const CodexRoute = CodexRouteImport.update({
id: "/codex",
path: "/codex",
id: '/codex',
path: '/codex',
getParentRoute: () => rootRouteImport,
} as any);
} as any)
const ClaudeCodeRoute = ClaudeCodeRouteImport.update({
id: "/claude-code",
path: "/claude-code",
id: '/claude-code',
path: '/claude-code',
getParentRoute: () => rootRouteImport,
} as any);
} as any)
const ChangelogRoute = ChangelogRouteImport.update({
id: "/changelog",
path: "/changelog",
id: '/changelog',
path: '/changelog',
getParentRoute: () => rootRouteImport,
} as any);
} as any)
const IndexRoute = IndexRouteImport.update({
id: "/",
path: "/",
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any);
} as any)
const DocsIndexRoute = DocsIndexRouteImport.update({
id: "/",
path: "/",
id: '/',
path: '/',
getParentRoute: () => DocsRoute,
} as any);
} as any)
const DocsWorktreesRoute = DocsWorktreesRouteImport.update({
id: "/worktrees",
path: "/worktrees",
id: '/worktrees',
path: '/worktrees',
getParentRoute: () => DocsRoute,
} as any);
} as any)
const DocsVoiceRoute = DocsVoiceRouteImport.update({
id: "/voice",
path: "/voice",
id: '/voice',
path: '/voice',
getParentRoute: () => DocsRoute,
} as any);
} as any)
const DocsUpdatesRoute = DocsUpdatesRouteImport.update({
id: "/updates",
path: "/updates",
id: '/updates',
path: '/updates',
getParentRoute: () => DocsRoute,
} as any);
} as any)
const DocsSecurityRoute = DocsSecurityRouteImport.update({
id: "/security",
path: "/security",
id: '/security',
path: '/security',
getParentRoute: () => DocsRoute,
} as any);
} as any)
const DocsConfigurationRoute = DocsConfigurationRouteImport.update({
id: "/configuration",
path: "/configuration",
id: '/configuration',
path: '/configuration',
getParentRoute: () => DocsRoute,
} as any);
} as any)
const DocsCliRoute = DocsCliRouteImport.update({
id: "/cli",
path: "/cli",
id: '/cli',
path: '/cli',
getParentRoute: () => DocsRoute,
} as any);
} as any)
const DocsBestPracticesRoute = DocsBestPracticesRouteImport.update({
id: "/best-practices",
path: "/best-practices",
id: '/best-practices',
path: '/best-practices',
getParentRoute: () => DocsRoute,
} as any);
} as any)
export interface FileRoutesByFullPath {
"/": typeof IndexRoute;
"/changelog": typeof ChangelogRoute;
"/claude-code": typeof ClaudeCodeRoute;
"/codex": typeof CodexRoute;
"/docs": typeof DocsRouteWithChildren;
"/opencode": typeof OpencodeRoute;
"/privacy": typeof PrivacyRoute;
"/docs/best-practices": typeof DocsBestPracticesRoute;
"/docs/cli": typeof DocsCliRoute;
"/docs/configuration": typeof DocsConfigurationRoute;
"/docs/security": typeof DocsSecurityRoute;
"/docs/updates": typeof DocsUpdatesRoute;
"/docs/voice": typeof DocsVoiceRoute;
"/docs/worktrees": typeof DocsWorktreesRoute;
"/docs/": typeof DocsIndexRoute;
'/': typeof IndexRoute
'/changelog': typeof ChangelogRoute
'/claude-code': typeof ClaudeCodeRoute
'/codex': typeof CodexRoute
'/docs': typeof DocsRouteWithChildren
'/download': typeof DownloadRoute
'/opencode': typeof OpencodeRoute
'/privacy': typeof PrivacyRoute
'/docs/best-practices': typeof DocsBestPracticesRoute
'/docs/cli': typeof DocsCliRoute
'/docs/configuration': typeof DocsConfigurationRoute
'/docs/security': typeof DocsSecurityRoute
'/docs/updates': typeof DocsUpdatesRoute
'/docs/voice': typeof DocsVoiceRoute
'/docs/worktrees': typeof DocsWorktreesRoute
'/docs/': typeof DocsIndexRoute
}
export interface FileRoutesByTo {
"/": typeof IndexRoute;
"/changelog": typeof ChangelogRoute;
"/claude-code": typeof ClaudeCodeRoute;
"/codex": typeof CodexRoute;
"/opencode": typeof OpencodeRoute;
"/privacy": typeof PrivacyRoute;
"/docs/best-practices": typeof DocsBestPracticesRoute;
"/docs/cli": typeof DocsCliRoute;
"/docs/configuration": typeof DocsConfigurationRoute;
"/docs/security": typeof DocsSecurityRoute;
"/docs/updates": typeof DocsUpdatesRoute;
"/docs/voice": typeof DocsVoiceRoute;
"/docs/worktrees": typeof DocsWorktreesRoute;
"/docs": typeof DocsIndexRoute;
'/': typeof IndexRoute
'/changelog': typeof ChangelogRoute
'/claude-code': typeof ClaudeCodeRoute
'/codex': typeof CodexRoute
'/download': typeof DownloadRoute
'/opencode': typeof OpencodeRoute
'/privacy': typeof PrivacyRoute
'/docs/best-practices': typeof DocsBestPracticesRoute
'/docs/cli': typeof DocsCliRoute
'/docs/configuration': typeof DocsConfigurationRoute
'/docs/security': typeof DocsSecurityRoute
'/docs/updates': typeof DocsUpdatesRoute
'/docs/voice': typeof DocsVoiceRoute
'/docs/worktrees': typeof DocsWorktreesRoute
'/docs': typeof DocsIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport;
"/": typeof IndexRoute;
"/changelog": typeof ChangelogRoute;
"/claude-code": typeof ClaudeCodeRoute;
"/codex": typeof CodexRoute;
"/docs": typeof DocsRouteWithChildren;
"/opencode": typeof OpencodeRoute;
"/privacy": typeof PrivacyRoute;
"/docs/best-practices": typeof DocsBestPracticesRoute;
"/docs/cli": typeof DocsCliRoute;
"/docs/configuration": typeof DocsConfigurationRoute;
"/docs/security": typeof DocsSecurityRoute;
"/docs/updates": typeof DocsUpdatesRoute;
"/docs/voice": typeof DocsVoiceRoute;
"/docs/worktrees": typeof DocsWorktreesRoute;
"/docs/": typeof DocsIndexRoute;
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/changelog': typeof ChangelogRoute
'/claude-code': typeof ClaudeCodeRoute
'/codex': typeof CodexRoute
'/docs': typeof DocsRouteWithChildren
'/download': typeof DownloadRoute
'/opencode': typeof OpencodeRoute
'/privacy': typeof PrivacyRoute
'/docs/best-practices': typeof DocsBestPracticesRoute
'/docs/cli': typeof DocsCliRoute
'/docs/configuration': typeof DocsConfigurationRoute
'/docs/security': typeof DocsSecurityRoute
'/docs/updates': typeof DocsUpdatesRoute
'/docs/voice': typeof DocsVoiceRoute
'/docs/worktrees': typeof DocsWorktreesRoute
'/docs/': typeof DocsIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath;
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| "/"
| "/changelog"
| "/claude-code"
| "/codex"
| "/docs"
| "/opencode"
| "/privacy"
| "/docs/best-practices"
| "/docs/cli"
| "/docs/configuration"
| "/docs/security"
| "/docs/updates"
| "/docs/voice"
| "/docs/worktrees"
| "/docs/";
fileRoutesByTo: FileRoutesByTo;
| '/'
| '/changelog'
| '/claude-code'
| '/codex'
| '/docs'
| '/download'
| '/opencode'
| '/privacy'
| '/docs/best-practices'
| '/docs/cli'
| '/docs/configuration'
| '/docs/security'
| '/docs/updates'
| '/docs/voice'
| '/docs/worktrees'
| '/docs/'
fileRoutesByTo: FileRoutesByTo
to:
| "/"
| "/changelog"
| "/claude-code"
| "/codex"
| "/opencode"
| "/privacy"
| "/docs/best-practices"
| "/docs/cli"
| "/docs/configuration"
| "/docs/security"
| "/docs/updates"
| "/docs/voice"
| "/docs/worktrees"
| "/docs";
| '/'
| '/changelog'
| '/claude-code'
| '/codex'
| '/download'
| '/opencode'
| '/privacy'
| '/docs/best-practices'
| '/docs/cli'
| '/docs/configuration'
| '/docs/security'
| '/docs/updates'
| '/docs/voice'
| '/docs/worktrees'
| '/docs'
id:
| "__root__"
| "/"
| "/changelog"
| "/claude-code"
| "/codex"
| "/docs"
| "/opencode"
| "/privacy"
| "/docs/best-practices"
| "/docs/cli"
| "/docs/configuration"
| "/docs/security"
| "/docs/updates"
| "/docs/voice"
| "/docs/worktrees"
| "/docs/";
fileRoutesById: FileRoutesById;
| '__root__'
| '/'
| '/changelog'
| '/claude-code'
| '/codex'
| '/docs'
| '/download'
| '/opencode'
| '/privacy'
| '/docs/best-practices'
| '/docs/cli'
| '/docs/configuration'
| '/docs/security'
| '/docs/updates'
| '/docs/voice'
| '/docs/worktrees'
| '/docs/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute;
ChangelogRoute: typeof ChangelogRoute;
ClaudeCodeRoute: typeof ClaudeCodeRoute;
CodexRoute: typeof CodexRoute;
DocsRoute: typeof DocsRouteWithChildren;
OpencodeRoute: typeof OpencodeRoute;
PrivacyRoute: typeof PrivacyRoute;
IndexRoute: typeof IndexRoute
ChangelogRoute: typeof ChangelogRoute
ClaudeCodeRoute: typeof ClaudeCodeRoute
CodexRoute: typeof CodexRoute
DocsRoute: typeof DocsRouteWithChildren
DownloadRoute: typeof DownloadRoute
OpencodeRoute: typeof OpencodeRoute
PrivacyRoute: typeof PrivacyRoute
}
declare module "@tanstack/react-router" {
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
"/privacy": {
id: "/privacy";
path: "/privacy";
fullPath: "/privacy";
preLoaderRoute: typeof PrivacyRouteImport;
parentRoute: typeof rootRouteImport;
};
"/opencode": {
id: "/opencode";
path: "/opencode";
fullPath: "/opencode";
preLoaderRoute: typeof OpencodeRouteImport;
parentRoute: typeof rootRouteImport;
};
"/docs": {
id: "/docs";
path: "/docs";
fullPath: "/docs";
preLoaderRoute: typeof DocsRouteImport;
parentRoute: typeof rootRouteImport;
};
"/codex": {
id: "/codex";
path: "/codex";
fullPath: "/codex";
preLoaderRoute: typeof CodexRouteImport;
parentRoute: typeof rootRouteImport;
};
"/claude-code": {
id: "/claude-code";
path: "/claude-code";
fullPath: "/claude-code";
preLoaderRoute: typeof ClaudeCodeRouteImport;
parentRoute: typeof rootRouteImport;
};
"/changelog": {
id: "/changelog";
path: "/changelog";
fullPath: "/changelog";
preLoaderRoute: typeof ChangelogRouteImport;
parentRoute: typeof rootRouteImport;
};
"/": {
id: "/";
path: "/";
fullPath: "/";
preLoaderRoute: typeof IndexRouteImport;
parentRoute: typeof rootRouteImport;
};
"/docs/": {
id: "/docs/";
path: "/";
fullPath: "/docs/";
preLoaderRoute: typeof DocsIndexRouteImport;
parentRoute: typeof DocsRoute;
};
"/docs/worktrees": {
id: "/docs/worktrees";
path: "/worktrees";
fullPath: "/docs/worktrees";
preLoaderRoute: typeof DocsWorktreesRouteImport;
parentRoute: typeof DocsRoute;
};
"/docs/voice": {
id: "/docs/voice";
path: "/voice";
fullPath: "/docs/voice";
preLoaderRoute: typeof DocsVoiceRouteImport;
parentRoute: typeof DocsRoute;
};
"/docs/updates": {
id: "/docs/updates";
path: "/updates";
fullPath: "/docs/updates";
preLoaderRoute: typeof DocsUpdatesRouteImport;
parentRoute: typeof DocsRoute;
};
"/docs/security": {
id: "/docs/security";
path: "/security";
fullPath: "/docs/security";
preLoaderRoute: typeof DocsSecurityRouteImport;
parentRoute: typeof DocsRoute;
};
"/docs/configuration": {
id: "/docs/configuration";
path: "/configuration";
fullPath: "/docs/configuration";
preLoaderRoute: typeof DocsConfigurationRouteImport;
parentRoute: typeof DocsRoute;
};
"/docs/cli": {
id: "/docs/cli";
path: "/cli";
fullPath: "/docs/cli";
preLoaderRoute: typeof DocsCliRouteImport;
parentRoute: typeof DocsRoute;
};
"/docs/best-practices": {
id: "/docs/best-practices";
path: "/best-practices";
fullPath: "/docs/best-practices";
preLoaderRoute: typeof DocsBestPracticesRouteImport;
parentRoute: typeof DocsRoute;
};
'/privacy': {
id: '/privacy'
path: '/privacy'
fullPath: '/privacy'
preLoaderRoute: typeof PrivacyRouteImport
parentRoute: typeof rootRouteImport
}
'/opencode': {
id: '/opencode'
path: '/opencode'
fullPath: '/opencode'
preLoaderRoute: typeof OpencodeRouteImport
parentRoute: typeof rootRouteImport
}
'/download': {
id: '/download'
path: '/download'
fullPath: '/download'
preLoaderRoute: typeof DownloadRouteImport
parentRoute: typeof rootRouteImport
}
'/docs': {
id: '/docs'
path: '/docs'
fullPath: '/docs'
preLoaderRoute: typeof DocsRouteImport
parentRoute: typeof rootRouteImport
}
'/codex': {
id: '/codex'
path: '/codex'
fullPath: '/codex'
preLoaderRoute: typeof CodexRouteImport
parentRoute: typeof rootRouteImport
}
'/claude-code': {
id: '/claude-code'
path: '/claude-code'
fullPath: '/claude-code'
preLoaderRoute: typeof ClaudeCodeRouteImport
parentRoute: typeof rootRouteImport
}
'/changelog': {
id: '/changelog'
path: '/changelog'
fullPath: '/changelog'
preLoaderRoute: typeof ChangelogRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/docs/': {
id: '/docs/'
path: '/'
fullPath: '/docs/'
preLoaderRoute: typeof DocsIndexRouteImport
parentRoute: typeof DocsRoute
}
'/docs/worktrees': {
id: '/docs/worktrees'
path: '/worktrees'
fullPath: '/docs/worktrees'
preLoaderRoute: typeof DocsWorktreesRouteImport
parentRoute: typeof DocsRoute
}
'/docs/voice': {
id: '/docs/voice'
path: '/voice'
fullPath: '/docs/voice'
preLoaderRoute: typeof DocsVoiceRouteImport
parentRoute: typeof DocsRoute
}
'/docs/updates': {
id: '/docs/updates'
path: '/updates'
fullPath: '/docs/updates'
preLoaderRoute: typeof DocsUpdatesRouteImport
parentRoute: typeof DocsRoute
}
'/docs/security': {
id: '/docs/security'
path: '/security'
fullPath: '/docs/security'
preLoaderRoute: typeof DocsSecurityRouteImport
parentRoute: typeof DocsRoute
}
'/docs/configuration': {
id: '/docs/configuration'
path: '/configuration'
fullPath: '/docs/configuration'
preLoaderRoute: typeof DocsConfigurationRouteImport
parentRoute: typeof DocsRoute
}
'/docs/cli': {
id: '/docs/cli'
path: '/cli'
fullPath: '/docs/cli'
preLoaderRoute: typeof DocsCliRouteImport
parentRoute: typeof DocsRoute
}
'/docs/best-practices': {
id: '/docs/best-practices'
path: '/best-practices'
fullPath: '/docs/best-practices'
preLoaderRoute: typeof DocsBestPracticesRouteImport
parentRoute: typeof DocsRoute
}
}
}
interface DocsRouteChildren {
DocsBestPracticesRoute: typeof DocsBestPracticesRoute;
DocsCliRoute: typeof DocsCliRoute;
DocsConfigurationRoute: typeof DocsConfigurationRoute;
DocsSecurityRoute: typeof DocsSecurityRoute;
DocsUpdatesRoute: typeof DocsUpdatesRoute;
DocsVoiceRoute: typeof DocsVoiceRoute;
DocsWorktreesRoute: typeof DocsWorktreesRoute;
DocsIndexRoute: typeof DocsIndexRoute;
DocsBestPracticesRoute: typeof DocsBestPracticesRoute
DocsCliRoute: typeof DocsCliRoute
DocsConfigurationRoute: typeof DocsConfigurationRoute
DocsSecurityRoute: typeof DocsSecurityRoute
DocsUpdatesRoute: typeof DocsUpdatesRoute
DocsVoiceRoute: typeof DocsVoiceRoute
DocsWorktreesRoute: typeof DocsWorktreesRoute
DocsIndexRoute: typeof DocsIndexRoute
}
const DocsRouteChildren: DocsRouteChildren = {
@@ -345,9 +365,9 @@ const DocsRouteChildren: DocsRouteChildren = {
DocsVoiceRoute: DocsVoiceRoute,
DocsWorktreesRoute: DocsWorktreesRoute,
DocsIndexRoute: DocsIndexRoute,
};
}
const DocsRouteWithChildren = DocsRoute._addFileChildren(DocsRouteChildren);
const DocsRouteWithChildren = DocsRoute._addFileChildren(DocsRouteChildren)
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
@@ -355,18 +375,19 @@ const rootRouteChildren: RootRouteChildren = {
ClaudeCodeRoute: ClaudeCodeRoute,
CodexRoute: CodexRoute,
DocsRoute: DocsRouteWithChildren,
DownloadRoute: DownloadRoute,
OpencodeRoute: OpencodeRoute,
PrivacyRoute: PrivacyRoute,
};
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>();
._addFileTypes<FileRouteTypes>()
import type { getRouter } from "./router.tsx";
import type { createStart } from "@tanstack/react-start";
declare module "@tanstack/react-start" {
import type { getRouter } from './router.tsx'
import type { createStart } from '@tanstack/react-start'
declare module '@tanstack/react-start' {
interface Register {
ssr: true;
router: Awaited<ReturnType<typeof getRouter>>;
ssr: true
router: Awaited<ReturnType<typeof getRouter>>
}
}
+331
View File
@@ -0,0 +1,331 @@
import { Link, createFileRoute } from "@tanstack/react-router";
import { CommandDialog } from "~/components/command-dialog";
import { pageMeta } from "~/meta";
import {
desktopVersion,
linuxAppImageDownloadUrl,
linuxDebDownloadUrl,
linuxRpmDownloadUrl,
releaseBase,
macAppleSiliconDownloadUrl,
macIntelDownloadUrl,
appStoreUrl,
playStoreUrl,
webAppUrl,
AppleIcon,
AndroidIcon,
WindowsIcon,
LinuxIcon,
TerminalIcon,
GlobeIcon,
} from "~/downloads";
import "~/styles.css";
export const Route = createFileRoute("/download")({
head: () => ({
meta: pageMeta(
"Download - Paseo",
"Download Paseo for macOS, Windows, Linux, iOS, and Android. Your dev environment, in your pocket.",
),
}),
component: Download,
});
function Download() {
return (
<div className="min-h-screen bg-background">
<div className="max-w-3xl mx-auto p-6 md:p-12">
<header className="flex items-center justify-between gap-4 mb-12">
<Link to="/" className="flex items-center gap-3">
<img src="/logo.svg" alt="Paseo" className="w-6 h-6" />
<span className="text-lg font-medium">Paseo</span>
</Link>
<div className="flex items-center gap-4">
<Link
to="/docs"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Docs
</Link>
<Link
to="/changelog"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Changelog
</Link>
<a
href="https://discord.gg/jz8T2uahpH"
target="_blank"
rel="noopener noreferrer"
aria-label="Discord"
className="text-muted-foreground hover:text-foreground transition-colors inline-flex items-center"
>
<svg
role="img"
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z" />
</svg>
</a>
<a
href="https://github.com/getpaseo/paseo"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub"
className="text-muted-foreground hover:text-foreground transition-colors inline-flex items-center"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M12 0C5.37 0 0 5.484 0 12.252c0 5.418 3.438 10.013 8.205 11.637.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.738-4.042-1.61-4.042-1.61-.546-1.403-1.333-1.776-1.333-1.776-1.089-.756.084-.741.084-.741 1.205.087 1.838 1.262 1.838 1.262 1.07 1.87 2.809 1.33 3.495 1.017.108-.79.417-1.33.76-1.636-2.665-.31-5.467-1.35-5.467-6.005 0-1.327.465-2.413 1.235-3.262-.124-.31-.535-1.556.117-3.243 0 0 1.008-.33 3.3 1.248a11.2 11.2 0 0 1 3.003-.404c1.02.005 2.045.138 3.003.404 2.29-1.578 3.297-1.248 3.297-1.248.653 1.687.242 2.933.118 3.243.77.85 1.233 1.935 1.233 3.262 0 4.667-2.807 5.692-5.48 5.995.43.38.823 1.133.823 2.285 0 1.65-.015 2.98-.015 3.386 0 .315.218.694.825.576C20.565 22.26 24 17.667 24 12.252 24 5.484 18.627 0 12 0z" />
</svg>
</a>
</div>
</header>
<h1 className="text-3xl md:text-4xl font-semibold tracking-tight mb-2">Download</h1>
<p className="text-muted-foreground mb-10">
v{desktopVersion}
</p>
{/* Desktop */}
<section className="rounded-xl border border-border bg-card/40 p-6 md:p-8 mb-6">
<div className="flex items-center justify-between mb-8">
<h2 className="text-2xl font-semibold">Desktop</h2>
<MonitorIcon className="h-5 w-5 text-muted-foreground" />
</div>
<div className="divide-y divide-border">
{/* macOS */}
<div className="flex items-center justify-between py-5 first:pt-0 last:pb-0">
<div className="flex items-center gap-3">
<AppleIcon className="h-5 w-5 text-foreground" />
<span className="font-medium">macOS</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<DownloadPill href={macAppleSiliconDownloadUrl} label="Apple Silicon" />
<DownloadPill href={macIntelDownloadUrl} label="Intel" />
<CommandDialog
trigger={
<span className="inline-flex items-center justify-center rounded-full bg-foreground px-4 py-1.5 text-sm font-medium text-background hover:bg-foreground/85 transition-colors">
Homebrew
</span>
}
title="Install via Homebrew"
command="brew install --cask paseo"
/>
</div>
</div>
{/* Windows */}
<div className="flex items-center justify-between py-5 first:pt-0 last:pb-0">
<div className="flex items-center gap-3">
<WindowsIcon className="h-5 w-5 text-foreground" />
<span className="font-medium">Windows</span>
</div>
<div className="flex flex-wrap gap-2">
<DownloadPill
href={`${releaseBase}/Paseo-Setup-${desktopVersion}.exe`}
label="Download"
/>
</div>
</div>
{/* Linux */}
<div className="flex items-center justify-between py-5 first:pt-0 last:pb-0">
<div className="flex items-center gap-3">
<LinuxIcon className="h-5 w-5 text-foreground" />
<span className="font-medium">Linux</span>
</div>
<div className="flex flex-wrap gap-2">
<DownloadPill
href={linuxAppImageDownloadUrl}
label="AppImage"
/>
<DownloadPill href={linuxDebDownloadUrl} label="DEB" />
<DownloadPill href={linuxRpmDownloadUrl} label="RPM" />
</div>
</div>
</div>
</section>
{/* Mobile */}
<section className="rounded-xl border border-border bg-card/40 p-6 md:p-8 mb-6">
<div className="flex items-center justify-between mb-8">
<h2 className="text-2xl font-semibold">Mobile</h2>
<PhoneIcon className="h-5 w-5 text-muted-foreground" />
</div>
<div className="divide-y divide-border">
{/* Android */}
<div className="flex items-center justify-between py-5 first:pt-0 last:pb-0">
<div className="flex items-center gap-3">
<AndroidIcon className="h-5 w-5 text-foreground" />
<span className="font-medium">Android</span>
</div>
<div className="flex flex-wrap gap-2">
<DownloadPill
href={playStoreUrl}
label="Play Store"
external
/>
<DownloadPill
href={`${releaseBase}/paseo-v${desktopVersion}-android.apk`}
label="APK"
/>
</div>
</div>
{/* iOS */}
<div className="flex items-center justify-between py-5 first:pt-0 last:pb-0">
<div className="flex items-center gap-3">
<AppleIcon className="h-5 w-5 text-foreground" />
<span className="font-medium">iOS</span>
</div>
<div className="flex flex-wrap gap-2">
<DownloadPill
href={appStoreUrl}
label="App Store"
external
/>
</div>
</div>
</div>
</section>
{/* Web & CLI */}
<section className="rounded-xl border border-border bg-card/40 p-6 md:p-8">
<div className="flex items-center justify-between mb-8">
<h2 className="text-2xl font-semibold">Web & CLI</h2>
<TerminalIcon className="h-5 w-5 text-muted-foreground" />
</div>
<div className="divide-y divide-border">
<div className="flex items-center justify-between py-5 first:pt-0 last:pb-0">
<div className="flex items-center gap-3">
<GlobeIcon className="h-5 w-5 text-foreground" />
<span className="font-medium">Web App</span>
</div>
<div className="flex flex-wrap gap-2">
<DownloadPill
href={webAppUrl}
label="Open"
external
/>
</div>
</div>
<div className="flex items-center justify-between py-5 first:pt-0 last:pb-0">
<div className="flex items-center gap-3">
<TerminalIcon className="h-5 w-5 text-foreground" />
<span className="font-medium">CLI</span>
</div>
<div className="flex flex-wrap gap-2">
<code className="text-sm text-muted-foreground font-mono bg-muted px-3 py-1.5 rounded-lg">
npm install -g @getpaseo/cli
</code>
</div>
</div>
</div>
</section>
<p className="text-center text-xs text-muted-foreground mt-8">
All releases are available on{" "}
<a
href="https://github.com/getpaseo/paseo/releases"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-foreground transition-colors"
>
GitHub
</a>
.
</p>
</div>
</div>
);
}
function DownloadPill({
href,
label,
external,
}: {
href: string;
label: string;
external?: boolean;
}) {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center rounded-full bg-foreground px-4 py-1.5 text-sm font-medium text-background hover:bg-foreground/85 transition-colors"
>
{label}
{external && (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="ml-1.5 h-3 w-3"
aria-hidden="true"
>
<path d="M7 17L17 7" />
<path d="M7 7h10v10" />
</svg>
)}
</a>
);
}
function MonitorIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
{...props}
>
<rect width="20" height="14" x="2" y="3" rx="2" />
<line x1="8" x2="16" y1="21" y2="21" />
<line x1="12" x2="12" y1="17" y2="21" />
</svg>
);
}
function PhoneIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
{...props}
>
<rect width="14" height="20" x="5" y="2" rx="2" ry="2" />
<path d="M12 18h.01" />
</svg>
);
}

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