Compare commits

...
Author SHA1 Message Date
Mohamed Boudra 979326c04f Install local speech runtime on demand
The daemon no longer bundles sherpa-onnx, sherpa-onnx-node, or
onnxruntime-node. On first boot they are downloaded into
$PASEO_HOME/runtime/local-speech alongside the existing voice models,
non-blocking. Users who don't enable local voice never pay the size
cost.

The wire protocol is unchanged. server_info.capabilities.voice.*
already gates voice in the client; readiness now reports
runtime_install_in_progress / runtime_missing / runtime_install_failed
on the same channel as the existing model_download_* states.

The native runtime lives under packages/server/src/server/speech/
providers/local/runtime and exposes a small surface: configure the
home once at speech-service init, ensureInstalled, loadPackage,
getStatus, plus a typed error. Provider configs do not know about
the install path.
2026-05-21 10:44:50 +00:00
paseo-ai[bot] 6dc22483c5 fix: update lockfile signatures and Nix hash [skip ci] 2026-05-21 10:11:01 +00:00
Mohamed Boudra a9f5b8ea4d chore(release): cut 0.1.79 2026-05-21 17:03:25 +07:00
Mohamed Boudra 1ce30bac05 docs(changelog): draft 0.1.79 entry 2026-05-21 16:51:15 +07:00
Mohamed Boudra 754f5a1f0b Tweak import session tile copy 2026-05-21 16:27:49 +07:00
Mohamed Boudra 698390bf1c feat(app): copy resume command for pi agents 2026-05-21 16:23:23 +07:00
Mohamed Boudra 63b24ec261 Merge branch 'main' of github.com:getpaseo/paseo 2026-05-21 16:19:04 +07:00
Mohamed Boudra ad060e2f86 feat: import existing CLI sessions from the home screen
Home screen now leads with action tiles (Add a project, Import session,
Setup providers, Pair device) instead of a single button, giving new
users a clear path in even before they've added a project.

Import session is the onboarding hook: the sheet now works without a
workspace context and lists recent sessions from every provider's native
history (Claude, Codex, OpenCode, Pi, ACP). Picking one imports the
agent and lands the user on its workspace.

To make that work end-to-end, the daemon upserts the imported agent's
cwd as a workspace so the sidebar surfaces it even when the user never
explicitly opened the folder.

Also adds a Home entry to cmd+K and a refresh button to the import
sheet header for explicit re-fetch.
2026-05-21 16:15:28 +07:00
paseo-ai[bot] 1e595ad9cb fix: update lockfile signatures and Nix hash [skip ci] 2026-05-21 08:21:38 +00:00
Mohamed Boudra 9fd93f8308 Merge remote-tracking branch 'origin/main' into release-beta-0-1-79-beta-3 2026-05-21 15:17:35 +07:00
paseo-ai[bot] fac81f568a fix: update lockfile signatures and Nix hash [skip ci] 2026-05-21 05:51:50 +00:00
Mohamed Boudra 57de8c3807 chore(release): cut 0.1.79-beta.3 2026-05-21 12:50:43 +07:00
Mohamed Boudra 0310fd3c3c Merge branch 'main' of github.com:getpaseo/paseo 2026-05-21 12:48:30 +07:00
Mohamed Boudra a837d3e5d1 fix(app): keep React aligned with native renderer 2026-05-21 12:44:19 +07:00
paseo-ai[bot] b5a78b7261 fix: update lockfile signatures and Nix hash [skip ci] 2026-05-21 04:26:52 +00:00
Mohamed Boudra e630e205db chore(release): cut 0.1.79-beta.2 2026-05-21 11:23:34 +07:00
Mohamed Boudra 8865f41d58 fix(app): unblock android APK build by aligning native deps
The 0.1.79-beta.1 APK build failed on EAS because react-native-unistyles
3.2.4 (bumped in #1103) references JHybridObject::CxxPart, which only
exists in react-native-nitro-modules 0.35.x. We were still on 0.33.8 so
the Android NDK compile died with "no member named CxxPart". Web and
desktop builds didn't catch it because the unistyles C++ layer is
Android-only.

Bump nitro to 0.35.5 to match unistyles' expected API. While re-resolving
the tree, react-native 0.81.6 also tightens its react peer to ^19.1.4,
so bump react/react-dom alongside so npm install stays clean.

Verified with a local production-apk gradle build using EAS's exact
gradleCommand — assembleRelease completes and the APK lands.
2026-05-21 11:19:52 +07:00
Mohamed Boudra b94527fe1f fix(app): show empty state in import sheet when no sessions match 2026-05-21 11:19:52 +07:00
Mohamed Boudra dee8f485de Recover stale daemon connections with liveness probes (#1124)
Use top-level WebSocket ping/pong for client liveness so session RPC timeouts stay scoped to the operation that timed out. Back off inactive connection probes while a host already has a healthy active connection.
2026-05-21 11:43:22 +08:00
Mohamed Boudra 28415b3542 docs(release): make betas silent, add release-beta/stable skills 2026-05-21 10:20:21 +07:00
Mohamed Boudra 3dd305082e docs(alternatives): add hero mockup to alternative comparison pages 2026-05-21 10:12:04 +07:00
Mohamed Boudra cae3deb6e7 fix(website): SEO canonicals, host redirects, sitemap, app noindex
Addresses the high-volume issues from the Ahrefs audit:
- Worker entrypoint 301s every request not on https://paseo.sh
  (www subdomain and http variants), killing the duplicate-page
  errors and the HTTP→HTTPS internal-link notices.
- pageMeta() emits <link rel="canonical"> and og:url per route;
  every head() call now passes its canonical path.
- /blog stops rewriting itself to /blog?drafts=false; the search
  param only appears when explicitly true.
- Sitemap includes /blog and each post.
- Trim homepage title under SERP truncation, rewrite /agents meta
  description under 160 chars, pad short descriptions on
  /privacy, /download, /cloud, /changelog, /docs.
- app.paseo.sh gets <meta robots=noindex,nofollow> and a
  Disallow-all robots.txt so the SPA shell stops polluting the
  crawl with H1-missing / no-OG / low-word-count warnings.
2026-05-21 10:07:41 +07:00
Mohamed Boudra ea36f0879f docs(website): rewrite agent pages for SEO and desktop coverage
Every agent landing page now mentions both mobile and desktop, leads with
"open source", and uses a search-friendly hero title. Renames Pi to "Pi Agent"
and disambiguates other common-word names in titles.
2026-05-20 22:31:28 +07:00
Mohamed Boudra 3bca1a72e8 Create agents in worktrees with auto-archive (#1120)
* Add daemon worktree auto-archive spawning

* Refactor create agent lifecycle dispatch

* Stabilize Claude autonomous turn test
2026-05-20 22:41:15 +08:00
paseo-ai[bot] 68d88f0928 fix: update lockfile signatures and Nix hash [skip ci] 2026-05-20 14:39:26 +00:00
82 changed files with 3079 additions and 570 deletions
+11
View File
@@ -0,0 +1,11 @@
---
name: release-beta
description: Cut a beta release of Paseo. Use when the user says "release beta", "cut a beta", "ship a beta", "beta release", or "/release-beta". Betas are silent release candidates — no changelog, no website move.
user-invocable: true
---
# Release beta
Read `docs/release.md` in the Paseo repo and follow the **Beta flow** section end-to-end. Run the **Beta release** completion checklist at the bottom of that doc.
Key rule the doc enforces — betas don't touch `CHANGELOG.md`. Don't draft release notes.
+11
View File
@@ -0,0 +1,11 @@
---
name: release-stable
description: Cut a stable release of Paseo (fresh patch or promote from beta). Use when the user says "release stable", "ship stable", "promote", "release:patch", "release:promote", or "/release-stable".
user-invocable: true
---
# Release stable
Read `docs/release.md` in the Paseo repo and follow the **Standard release (patch)** flow if cutting fresh, or the **Beta flow** promotion step if promoting an existing beta. Run the **Stable release (or promotion)** completion checklist at the bottom of that doc.
The doc covers the changelog (required for stable), the pre-release sanity check (required for stable), and the post-release babysit pattern. Don't skip steps.
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/release-beta
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/release-stable
+9 -7
View File
@@ -1,16 +1,18 @@
# Changelog
## 0.1.79-beta.1 - 2026-05-20
## 0.1.79 - 2026-05-21
### Added
- **Pi has been revamped with first-class support**
- Pi now runs in a separate process, isolated from the rest of the daemon
- Pi runs through your installed Pi CLI, so your Pi extensions and configuration work exactly the same as launching Pi from your terminal
- Pi agents can call Paseo tools — create Paseo agents, work on worktrees, run anything in the Paseo MCP — when you have the Pi MCP extension installed
- Past Pi sessions can be imported into Paseo from Pi's saved history
- Runs through your installed Pi CLI, so your Pi extensions and configuration carry over
- Pi agents can call Paseo tools when you have the Pi MCP extension installed
- Import a Pi session you started in the terminal
- Copy Pi's resume command from any agent to continue the session in your terminal
- Windows: Pi sessions match correctly across symlinked and junctioned workspace paths
- **Set a custom system prompt for every agent you start** A daemon-wide system prompt gets appended to every supported agent provider
- **New home screen with quick tiles for adding a project, importing a session, setting up providers, and pairing a device**
- **Create an agent directly into a fresh worktree that auto-archives when the run finishes**
- **Set a custom system prompt that applies to every agent you start**
- **Rename workspaces, terminals, and agent tabs** ([#531](https://github.com/getpaseo/paseo/pull/531))
- **DeepSeek TUI in the ACP provider catalog** ([#1096](https://github.com/getpaseo/paseo/pull/1096))
- **Kiro CLI in the ACP provider catalog** (by [@huhusmang](https://github.com/huhusmang))
@@ -20,6 +22,7 @@
### Improved
- **Stale host connections recover automatically without a manual refresh**
- Paseo opens to the workspace you were on last time you used it ([#1101](https://github.com/getpaseo/paseo/pull/1101))
- Workspaces remember which editor you opened them in
- Outdated daemons now suggest an upgrade when they receive a command they don't understand
@@ -40,7 +43,6 @@
- Claude file links resolve correctly for projects whose paths need SDK encoding
- Duplicate Claude result text no longer appears in chat ([#1095](https://github.com/getpaseo/paseo/pull/1095))
- Dynamic UI styles no longer leak CSS rules across the page ([#1103](https://github.com/getpaseo/paseo/pull/1103))
- CLI queries daemon status and pairing offers through the proper RPC path (by [@yzx9](https://github.com/yzx9))
- Relay handshakes reject sessions that try to change encryption keys mid-flight ([#1037](https://github.com/getpaseo/paseo/pull/1037) by [@joaosa](https://github.com/joaosa))
## 0.1.78 - 2026-05-18
+2
View File
@@ -27,6 +27,8 @@ Both look the same in storage. This is an accepted limitation — see [Limitatio
Archive is a **soft delete**: the agent record stays on disk with `archivedAt` set, the runtime is closed, and the agent disappears from active lists. Archive is **global** — it lives on the server and propagates to every connected client.
`create_agent_request` can opt an agent into `autoArchive`. In that mode the daemon archives the agent after the first terminal turn event (`turn_completed`, `turn_failed`, or `turn_canceled`). If the same request created a Paseo worktree through its `worktree` field, auto-archive archives that worktree too, which removes the agent records inside the worktree.
Archiving runs through `AgentManager.archiveAgent` (`packages/server/src/server/agent/agent-manager.ts`):
1. Snapshot the current session into the registry
+4
View File
@@ -38,6 +38,10 @@ npx cross-env APP_VARIANT=production expo run:android --variant=release
rm -rf android
```
### React version lockstep
Keep `react` and `react-dom` pinned to the React version embedded by the current `react-native` release. React Native `0.81.x` embeds `react-native-renderer` `19.1.0`, so `packages/app` must use React `19.1.0`. Bumping React to a newer patch can build successfully but crash at JS startup on Android with `Incompatible React versions`, leaving the app on the native splash screen.
## Screenshots
```bash
+2
View File
@@ -146,6 +146,8 @@ There is no dedicated welcome message; the server emits a `status` session messa
**Top-level WS envelopes** are `hello`, `recording_state`, `ping`/`pong`, and `session` (which wraps the rich union of session messages).
Client liveness checks use the top-level JSON `ping`/`pong` envelope, not a session RPC and not RFC6455 protocol ping. The app runs through browser and React Native WebSocket APIs, which do not expose protocol ping, so this envelope is the portable way to test the direct or relay data path. Session RPC timeouts are operation failures and must not be treated as proof that the socket is dead.
New session RPCs use dotted names with `.request` and `.response` suffixes, such as `checkout.github.set_auto_merge.request` and `checkout.github.set_auto_merge.response`. See [rpc-namespacing.md](rpc-namespacing.md) for the convention and migration rules for older flat RPC names.
**Notable session message types:**
+25 -7
View File
@@ -173,7 +173,25 @@ All fields are optional with sensible defaults.
---
## 3. Schedule
## 3. Local Speech Runtime
**Path:** `$PASEO_HOME/runtime/local-speech/node_modules/`
Native local voice packages are installed on demand at daemon bootstrap, not bundled with the daemon package. The runtime installer downloads npm tarballs into `$PASEO_HOME/runtime/local-speech/.downloads`, extracts them into a temporary directory, verifies required files, then atomically moves each package into `node_modules`.
Installed packages:
- `sherpa-onnx`
- `sherpa-onnx-node`
- `sherpa-onnx-<platform>-<arch>` for the current host
- `onnxruntime-common`
- `onnxruntime-node`
The daemon starts without waiting for these packages. While they are missing, `server_info.capabilities.voice.*` reports local voice unavailable through the existing capability fields. Once the runtime and required local speech models are present, the next speech runtime reconcile makes local voice available without restarting the daemon.
---
## 4. Schedule
**Path:** `$PASEO_HOME/schedules/{id}.json`
@@ -221,7 +239,7 @@ One file per schedule. ID is 8 hex characters. Writes are direct (not atomic).
---
## 4. Chat
## 5. Chat
**Path:** `$PASEO_HOME/chat/rooms.json`
@@ -258,7 +276,7 @@ Single file containing all rooms and messages.
---
## 5. Loop
## 6. Loop
**Path:** `$PASEO_HOME/loops/loops.json`
@@ -347,7 +365,7 @@ Single file containing an array of all loop records. Writes are direct (not atom
---
## 6. Project Registry
## 7. Project Registry
**Path:** `$PASEO_HOME/projects/projects.json`
@@ -370,7 +388,7 @@ emptied duplicate.
---
## 7. Workspace Registry
## 8. Workspace Registry
**Path:** `$PASEO_HOME/projects/workspaces.json`
@@ -389,7 +407,7 @@ Array of workspace records. A workspace is a specific working directory within a
---
## 8. Push Token Store
## 9. Push Token Store
**Path:** `$PASEO_HOME/push-tokens.json`
@@ -403,7 +421,7 @@ Simple set of Expo push notification tokens. Loaded with permissive parsing (fil
---
## 9. Daemon meta files
## 10. Daemon meta files
These small files are not validated as full Zod schemas but are persisted under `$PASEO_HOME` for daemon identity and runtime coordination.
+17 -15
View File
@@ -7,7 +7,7 @@ All workspaces share one version and release together.
There are two supported ways to ship from `main`:
1. **Direct stable release**: you are ready to ship the current `main` commit to everyone immediately.
2. **Beta flow**: you want public test builds first, but you are not ready for the website, npm, or production mobile release flows to move yet.
2. **Beta flow**: silent release candidates. Betas don't touch the changelog, don't move the website, and don't publish npm or production mobile builds.
## Standard release (patch)
@@ -51,10 +51,11 @@ npm run release:promote # Promote X.Y.Z-beta.N to stable X.Y.Z
- `release:promote` creates a fresh stable tag like `v0.1.41`; the final release never reuses the beta tag
- Desktop assets now come from the Electron package at `packages/desktop`
- Beta releases use Electron's `beta` update channel. Users on the stable channel only receive stable releases; users on the beta channel receive beta releases and the final stable release when it is published.
- **Do create a changelog entry for betas.** The beta entry is temporary and gets updated in place until promotion.
- **Betas don't touch `CHANGELOG.md`.** Beta GitHub releases ship with empty notes — that's intentional. The changelog entry is written once, at promotion time, covering the full stable-to-stable diff. The release-notes sync script skips betas cleanly because no matching section exists.
Use the beta path when you need to:
- smoke a build yourself before promoting it to everyone
- test a build manually in a Linux or Windows VM
- send a build to a user who is hitting a specific problem
- iterate on `beta.1`, `beta.2`, `beta.3`, and so on before deciding to ship broadly
@@ -264,18 +265,15 @@ Release notes depend on the changelog heading format. The heading **must** be st
```
## X.Y.Z - YYYY-MM-DD
## X.Y.Z-beta.N - YYYY-MM-DD
```
No prefix (`v`), no extra text. The parser matches the first `## X.Y.Z` line to extract the version. A malformed heading will break download links on the homepage.
## Changelog policy
- `CHANGELOG.md` includes stable releases and the current beta line.
- The first beta inserts a top entry like `## 0.1.60-beta.1 - YYYY-MM-DD`.
- The next beta updates that same top entry in place, for example from `0.1.60-beta.1` to `0.1.60-beta.2`.
- Stable promotion updates that same entry in place, for example from `0.1.60-beta.2` to `0.1.60`.
- Do not create duplicate entries for each beta on the same version line.
- `CHANGELOG.md` only lists stable releases. Betas are silent.
- The changelog entry is authored once, at stable promotion time, with the date set to the promotion day.
- It covers the full diff from the previous stable tag, regardless of how many betas were cut in between.
## Changelog ownership
@@ -359,7 +357,7 @@ Entries within each section (Added, Improved, Fixed) are ordered by user impact:
## Pre-release sanity check
Before cutting any release (beta or stable), run a Codex review of the diff as a last line of defence against shipping bugs.
Before cutting a **stable** release, run a Codex review of the diff as a last line of defence against shipping bugs. Skip this for betas — the beta itself is the smoke test, and gating each beta on a code review defeats the point of using betas as fast release candidates.
Load the `paseo` skill and launch a **Codex 5.4** agent with a prompt like:
@@ -375,15 +373,19 @@ The agent's job is a deep sanity check, not a full code review. If it flags anyt
## Changelog scope
The changelog always covers **stable-to-HEAD**:
- **Beta release**: the diff and release notes cover `latest stable tag -> HEAD`. The current beta changelog entry is updated in place.
- **Stable release**: the same changelog entry is promoted in place. It still captures the full delta from the previous stable release, not just what changed since the last beta.
In other words, betas are checkpoints along the way; the changelog entry remains the single record for the final jump from one stable version to the next.
The changelog covers **stable-to-stable**. Betas are not represented. When you promote, draft the entry from the diff between the previous stable tag and `HEAD`, ignoring beta tag boundaries — they're just checkpoints along the way.
## Completion checklist
### Beta release
- [ ] Working tree is clean and the intended commit is on `main`
- [ ] `npm run release:beta:patch` (or `:next`) completes successfully
- [ ] GitHub `Desktop Release` workflow for the `v*-beta.N` tag is green
- [ ] GitHub `Android APK Release` workflow for the same tag is green
### Stable release (or promotion)
- [ ] Run the pre-release sanity check (see above) and address any findings
- [ ] Ensure the intended release commit is already committed and the git worktree is clean before running any `release:*` patch/promote command
- [ ] Ensure local `npm run typecheck` passes on that exact commit before running any `release:*` patch/promote command
+1 -1
View File
@@ -1 +1 @@
sha256-33/pCJZ4eQjAv6qUKGHJm8cOQlQwP+CfW4dgtYpjZMI=
sha256-ipuV4cM3LUqMQ39LyB4X3ezz5UgxyHGEhpBOq7en2Jw=
+50 -27
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.79-beta.1",
"version": "0.1.79",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.79-beta.1",
"version": "0.1.79",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -13864,6 +13864,7 @@
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz",
"integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0"
@@ -15185,6 +15186,7 @@
"resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz",
"integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==",
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
"dev": true,
"license": "MIT"
},
"node_modules/bplist-creator": {
@@ -17051,6 +17053,7 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz",
"integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==",
"dev": true,
"license": "MIT"
},
"node_modules/detect-node-es": {
@@ -18154,6 +18157,7 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz",
"integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==",
"dev": true,
"license": "MIT"
},
"node_modules/esbuild": {
@@ -22695,6 +22699,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz",
"integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"boolean": "^3.0.1",
@@ -22712,6 +22717,7 @@
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz",
"integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==",
"dev": true,
"license": "MIT",
"dependencies": {
"type-fest": "^0.13.1"
@@ -22727,6 +22733,7 @@
"version": "0.13.1",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz",
"integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==",
"dev": true,
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=10"
@@ -22752,6 +22759,7 @@
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz",
"integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"define-properties": "^1.2.1",
@@ -25406,6 +25414,7 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
"integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==",
"dev": true,
"license": "ISC"
},
"node_modules/json5": {
@@ -26425,6 +26434,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz",
"integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==",
"dev": true,
"license": "MIT",
"dependencies": {
"escape-string-regexp": "^4.0.0"
@@ -29065,12 +29075,14 @@
"version": "1.24.3",
"resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.24.3.tgz",
"integrity": "sha512-GeuPZO6U/LBJXvwdaqHbuUmoXiEdeCjWi/EG7Y1HNnDwJYuk6WUbNXpF6luSUY8yASul3cmUlLGrCCL1ZgVXqA==",
"dev": true,
"license": "MIT"
},
"node_modules/onnxruntime-node": {
"version": "1.24.3",
"resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.24.3.tgz",
"integrity": "sha512-JH7+czbc8ALA819vlTgcV+Q214/+VjGeBHDjX81+ZCD0PCVCIFGFNtT0V4sXG/1JXypKPgScQcB3ij/hk3YnTg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"os": [
@@ -30916,6 +30928,16 @@
"react-native": ">=0.50.4"
}
},
"node_modules/react-native-nitro-modules": {
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/react-native-nitro-modules/-/react-native-nitro-modules-0.35.5.tgz",
"integrity": "sha512-aa03UzC5dLg5qFfyBkVK+JGSwHTjmK7jUZzyRz11r1Yk9C/nJTFe59EeHPxxNNTagkiwQTM6p3sySgD/TDRC7Q==",
"license": "MIT",
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/react-native-reanimated": {
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.1.6.tgz",
@@ -31985,6 +32007,7 @@
"version": "2.15.4",
"resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz",
"integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"boolean": "^3.0.1",
@@ -32002,6 +32025,7 @@
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
"integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/rollup": {
@@ -32293,6 +32317,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz",
"integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==",
"dev": true,
"license": "MIT"
},
"node_modules/send": {
@@ -32582,6 +32607,7 @@
"version": "1.12.28",
"resolved": "https://registry.npmjs.org/sherpa-onnx/-/sherpa-onnx-1.12.28.tgz",
"integrity": "sha512-PaOuTCUA/Vh5o3zcq+z3j1Zpv2VlP7iubOgYoVODDyeFDicRxjskiEQqERdWEcpQUi6vh47q3A63bS7Y9FRHNg==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/sherpa-onnx-darwin-arm64": {
@@ -32591,6 +32617,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -32604,6 +32631,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -32617,6 +32645,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -32630,6 +32659,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -32640,6 +32670,7 @@
"version": "1.12.28",
"resolved": "https://registry.npmjs.org/sherpa-onnx-node/-/sherpa-onnx-node-1.12.28.tgz",
"integrity": "sha512-EHSB3EG6hKyXaTNh6GU/bwh6i3dncCH6ZCU2mScNzkxRbVStZ7QmNj0Oo4E9XrGGo9jX9pKg9MPHEPyjdK+ApA==",
"dev": true,
"license": "Apache-2.0",
"optionalDependencies": {
"sherpa-onnx-darwin-arm64": "^1.12.28",
@@ -32657,6 +32688,7 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -32670,6 +32702,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -36400,7 +36433,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.79-beta.1",
"version": "0.1.79",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -36460,7 +36493,7 @@
"react-native-gesture-handler": "~2.28.0",
"react-native-keyboard-controller": "^1.19.2",
"react-native-markdown-display": "^7.0.2",
"react-native-nitro-modules": "0.33.8",
"react-native-nitro-modules": "0.35.5",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
@@ -36507,16 +36540,6 @@
"react-native": "*"
}
},
"packages/app/node_modules/react-native-nitro-modules": {
"version": "0.33.8",
"resolved": "https://registry.npmjs.org/react-native-nitro-modules/-/react-native-nitro-modules-0.33.8.tgz",
"integrity": "sha512-1BffckbrPpxg6Een1Dj5EvijC9ctxEiEHvCBRiJWEnKKa5c24/JDgjfJFm1GyuaGtH5jlGVUbPzCcbIwiwEtuw==",
"license": "MIT",
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"packages/app/node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
@@ -36528,10 +36551,10 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.79-beta.1",
"version": "0.1.79",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/server": "0.1.79-beta.1",
"@getpaseo/server": "0.1.79",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -36886,7 +36909,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.79-beta.1",
"version": "0.1.79",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "*",
@@ -37247,7 +37270,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.79-beta.1",
"version": "0.1.79",
"license": "MIT",
"devDependencies": {
"@types/react": "^18.0.25",
@@ -37283,7 +37306,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.79-beta.1",
"version": "0.1.79",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5",
@@ -37621,7 +37644,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.79-beta.1",
"version": "0.1.79",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -37948,12 +37971,12 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.79-beta.1",
"version": "0.1.79",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.2.133",
"@getpaseo/highlight": "0.1.79-beta.1",
"@getpaseo/relay": "0.1.79-beta.1",
"@getpaseo/highlight": "0.1.79",
"@getpaseo/relay": "0.1.79",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
@@ -37967,7 +37990,6 @@
"fast-deep-equal": "^3.1.3",
"mnemonic-id": "^3.2.7",
"node-pty": "1.2.0-beta.11",
"onnxruntime-node": "^1.23.0",
"openai": "^4.20.0",
"p-limit": "^7.3.0",
"p-memoize": "^8.0.0",
@@ -37975,8 +37997,6 @@
"pino-pretty": "^13.1.3",
"qrcode": "^1.5.4",
"rotating-file-stream": "^3.2.9",
"sherpa-onnx": "1.12.28",
"sherpa-onnx-node": "1.12.28",
"strip-ansi": "^7.1.2",
"tree-kill": "^1.2.2",
"uuid": "^9.0.1",
@@ -37994,6 +38014,9 @@
"@types/ws": "^8.5.8",
"@vitest/ui": "^4.1.6",
"cross-env": "^10.1.0",
"onnxruntime-node": "^1.23.0",
"sherpa-onnx": "1.12.28",
"sherpa-onnx-node": "1.12.28",
"tsx": "^4.6.0",
"typescript": "^5.2.2",
"vitest": "^4.1.6"
@@ -38827,7 +38850,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.79-beta.1",
"version": "0.1.79",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.79-beta.1",
"version": "0.1.79",
"private": true,
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
"keywords": [
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.1.79-beta.1",
"version": "0.1.79",
"private": true,
"main": "index.ts",
"scripts": {
@@ -85,7 +85,7 @@
"react-native-gesture-handler": "~2.28.0",
"react-native-keyboard-controller": "^1.19.2",
"react-native-markdown-display": "^7.0.2",
"react-native-nitro-modules": "0.33.8",
"react-native-nitro-modules": "0.35.5",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
+1
View File
@@ -3,6 +3,7 @@
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="robots" content="noindex,nofollow" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, shrink-to-fit=no, viewport-fit=cover"
+2
View File
@@ -0,0 +1,2 @@
User-agent: *
Disallow: /
@@ -8,7 +8,7 @@ import {
type PressableStateCallbackType,
} from "react-native";
import { memo, useCallback, useEffect, useMemo, useRef, type ReactNode } from "react";
import { Plus, Settings } from "lucide-react-native";
import { Home, Plus, Settings } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useCommandCenter } from "@/hooks/use-command-center";
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
@@ -106,6 +106,8 @@ function CommandCenterActionRow({
actionIcon = <Plus size={16} strokeWidth={2.4} color={theme.colors.foregroundMuted} />;
} else if (action.icon === "settings") {
actionIcon = <Settings size={16} strokeWidth={2.2} color={theme.colors.foregroundMuted} />;
} else if (action.icon === "home") {
actionIcon = <Home size={16} strokeWidth={2.2} color={theme.colors.foregroundMuted} />;
}
const titleStyle = useMemo(
() => [styles.title, { color: theme.colors.foreground }],
@@ -7,7 +7,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { DaemonClient, FetchRecentProviderSessionEntry } from "@server/client/daemon-client";
import type { ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types";
import { afterEach, describe, expect, it, vi } from "vitest";
import { WorkspaceImportSheet } from "@/screens/workspace/workspace-import-sheet";
import { ImportSessionSheet } from "@/components/import-session-sheet";
const { theme } = vi.hoisted(() => ({
theme: {
@@ -61,9 +61,21 @@ vi.mock("@/components/provider-icons", () => ({
getProviderIcon: () => () => null,
}));
vi.mock("lucide-react-native", () => {
const icon = (name: string) => {
const Icon = () => React.createElement("span", { "data-icon": name });
Icon.displayName = name;
return Icon;
};
return {
Inbox: icon("Inbox"),
RotateCw: icon("RotateCw"),
};
});
vi.mock("@/components/ui/loading-spinner", () => ({
LoadingSpinner: () =>
React.createElement("span", { "data-testid": "workspace-import-loading-spinner" }),
React.createElement("span", { "data-testid": "import-session-loading-spinner" }),
}));
vi.mock("@/components/ui/segmented-control", () => ({
@@ -100,18 +112,19 @@ vi.mock("@/components/ui/segmented-control", () => ({
vi.mock("@/components/adaptive-modal-sheet", () => ({
AdaptiveModalSheet: ({
visible,
title,
header,
children,
testID,
}: {
visible: boolean;
title: string;
header?: { title: string; actions?: ReactNode };
children: ReactNode;
testID?: string;
}) =>
visible ? (
<section data-testid={testID}>
<h1>{title}</h1>
<h1>{header?.title}</h1>
{header?.actions}
{children}
</section>
) : null,
@@ -146,6 +159,8 @@ interface RenderOptions {
visible?: boolean;
onClose?: () => void;
onImportedAgent?: (agentId: string) => void;
onImported?: (agent: Awaited<ReturnType<DaemonClient["importAgent"]>>) => void;
cwd?: string | null;
snapshot?: {
entries?: ProviderSnapshotEntry[];
supportsSnapshot?: boolean;
@@ -168,15 +183,18 @@ function renderSheet(
},
});
const cwd = options && "cwd" in options ? (options.cwd ?? undefined) : "/repo/paseo";
return render(
<QueryClientProvider client={queryClient}>
<WorkspaceImportSheet
<ImportSessionSheet
visible={options?.visible ?? true}
client={client}
serverId="server-1"
workspaceDirectory="/repo/paseo"
cwd={cwd}
onClose={options?.onClose ?? vi.fn()}
onImportedAgent={options?.onImportedAgent ?? vi.fn()}
onImported={options?.onImported}
/>
</QueryClientProvider>,
);
@@ -254,7 +272,7 @@ function createSnapshotEntry(
};
}
describe("WorkspaceImportSheet", () => {
describe("ImportSessionSheet", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
@@ -420,11 +438,11 @@ describe("WorkspaceImportSheet", () => {
function TestSheet({ visible }: { visible: boolean }) {
return (
<QueryClientProvider client={queryClient}>
<WorkspaceImportSheet
<ImportSessionSheet
visible={visible}
client={client}
serverId="server-1"
workspaceDirectory="/repo/paseo"
cwd="/repo/paseo"
onClose={vi.fn()}
onImportedAgent={vi.fn()}
/>
@@ -472,7 +490,7 @@ describe("WorkspaceImportSheet", () => {
},
);
fireEvent.click(await screen.findByTestId("workspace-import-session-claude-provider-thread-1"));
fireEvent.click(await screen.findByTestId("import-session-session-claude-provider-thread-1"));
await waitFor(() => {
expect(importAgent).toHaveBeenCalledWith({
@@ -508,7 +526,7 @@ describe("WorkspaceImportSheet", () => {
},
);
fireEvent.click(await screen.findByTestId("workspace-import-session-claude-provider-thread-1"));
fireEvent.click(await screen.findByTestId("import-session-session-claude-provider-thread-1"));
await screen.findByText("Could not import selected session.");
expect(importAgent).toHaveBeenCalledWith({
@@ -654,12 +672,12 @@ describe("WorkspaceImportSheet", () => {
await screen.findByText("Session claude");
await screen.findByText("Session codex");
fireEvent.click(screen.getByTestId("workspace-import-filter-codex"));
fireEvent.click(screen.getByTestId("import-session-filter-codex"));
screen.getByText("Session codex");
expect(screen.queryByText("Session claude")).toBeNull();
fireEvent.click(screen.getByTestId("workspace-import-filter-all"));
fireEvent.click(screen.getByTestId("import-session-filter-all"));
screen.getByText("Session claude");
screen.getByText("Session codex");
@@ -691,8 +709,8 @@ describe("WorkspaceImportSheet", () => {
await waitFor(() => {
expect(fetchRecentProviderSessions).toHaveBeenCalled();
});
expect(screen.queryByTestId("workspace-import-filters")).toBeNull();
expect(screen.queryByTestId("workspace-import-filter-all")).toBeNull();
expect(screen.queryByTestId("import-session-filters")).toBeNull();
expect(screen.queryByTestId("import-session-filter-all")).toBeNull();
});
it("shows a no-importable-providers message when snapshot has no enabled importable providers", async () => {
@@ -720,4 +738,119 @@ describe("WorkspaceImportSheet", () => {
await screen.findByText("No importable providers are enabled.");
expect(fetchRecentProviderSessions).not.toHaveBeenCalled();
});
it("omits cwd from fetch and renders the session cwd on each row when cwd is unset", async () => {
const fetchRecentProviderSessions = vi.fn(async () => ({
requestId: "recent-provider-sessions",
entries: [
createProviderSessionEntry({
providerId: "claude",
providerLabel: "Claude Code",
cwd: "/home/me/work/other-project",
title: "Cross-project session",
}),
],
}));
const importAgent = vi.fn();
renderSheet(
{ fetchRecentProviderSessions, importAgent } as Pick<
DaemonClient,
"fetchRecentProviderSessions" | "importAgent"
>,
{
cwd: null,
snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] },
},
);
await waitFor(() => {
expect(fetchRecentProviderSessions).toHaveBeenCalledWith({
providers: ["claude"],
limit: 15,
});
});
expect(fetchRecentProviderSessions).not.toHaveBeenCalledWith(
expect.objectContaining({ cwd: expect.anything() }),
);
await screen.findByText("/home/me/work/other-project");
});
it("uses the session's cwd when importing in cwd-less mode and fires onImported", async () => {
const fetchRecentProviderSessions = vi.fn(async () => ({
requestId: "recent-provider-sessions",
entries: [
createProviderSessionEntry({
providerId: "claude",
providerLabel: "Claude Code",
cwd: "/home/me/work/other-project",
}),
],
}));
const importAgent = vi.fn(async () => createImportedAgentSnapshot("agent-imported"));
const onImported = vi.fn();
const onImportedAgent = vi.fn();
const onClose = vi.fn();
renderSheet(
{ fetchRecentProviderSessions, importAgent } as Pick<
DaemonClient,
"fetchRecentProviderSessions" | "importAgent"
>,
{
cwd: null,
onClose,
onImported,
onImportedAgent,
snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] },
},
);
fireEvent.click(await screen.findByTestId("import-session-session-claude-provider-thread-1"));
await waitFor(() => {
expect(importAgent).toHaveBeenCalledWith({
providerId: "claude",
providerHandleId: "provider-thread-1",
cwd: "/home/me/work/other-project",
});
});
expect(onImported).toHaveBeenCalledTimes(1);
expect(onImported).toHaveBeenCalledWith(expect.objectContaining({ id: "agent-imported" }));
expect(onImportedAgent).toHaveBeenCalledWith("agent-imported");
expect(onClose).toHaveBeenCalledTimes(1);
});
it("refetches sessions when the refresh button is clicked", async () => {
const fetchRecentProviderSessions = vi.fn(async () => ({
requestId: "recent-provider-sessions",
entries: [
createProviderSessionEntry({
providerId: "claude",
providerLabel: "Claude Code",
title: "Refreshable session",
}),
],
}));
const importAgent = vi.fn();
renderSheet(
{ fetchRecentProviderSessions, importAgent } as Pick<
DaemonClient,
"fetchRecentProviderSessions" | "importAgent"
>,
{
snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] },
},
);
await screen.findByText("Refreshable session");
expect(fetchRecentProviderSessions).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByTestId("import-session-refresh"));
await waitFor(() => {
expect(fetchRecentProviderSessions).toHaveBeenCalledTimes(2);
});
});
});
@@ -4,6 +4,7 @@ import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
import type { DaemonClient, FetchRecentProviderSessionEntry } from "@server/client/daemon-client";
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
import { IMPORTABLE_PROVIDERS } from "@server/shared/importable-providers";
import { Inbox, RotateCw } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
@@ -13,7 +14,6 @@ import { formatTimeAgo } from "@/utils/time";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
const IMPORTABLE_PROVIDER_IDS: Set<string> = new Set(IMPORTABLE_PROVIDERS);
const IMPORT_SESSION_HEADER: SheetHeader = { title: "Import session" };
const PER_PROVIDER_LIMIT = 15;
const IMPORT_SHEET_SNAP_POINTS = ["70%", "92%"];
const DISABLED_ACCESSIBILITY_STATE = { disabled: true };
@@ -24,13 +24,16 @@ type RecentProviderSessionsClient = Pick<
"fetchRecentProviderSessions" | "importAgent"
>;
interface WorkspaceImportSheetProps {
type ImportedAgent = Awaited<ReturnType<RecentProviderSessionsClient["importAgent"]>>;
interface ImportSessionSheetProps {
visible: boolean;
client: RecentProviderSessionsClient | null;
serverId: string | null;
workspaceDirectory: string | null;
cwd?: string | null;
onClose: () => void;
onImportedAgent: (agentId: string) => void;
onImportedAgent?: (agentId: string) => void;
onImported?: (agent: ImportedAgent) => void;
}
type RecentSessionsResponse = Awaited<
@@ -83,20 +86,20 @@ function buildSessionsQueriesConfig(args: {
sessionsQueryRoot: ReadonlyArray<string | null>;
visible: boolean;
client: RecentProviderSessionsClient | null;
workspaceDirectory: string | null;
cwd: string | null | undefined;
}): SessionsQueryConfig[] {
const { providersToFetch, sessionsQueryRoot, visible, client, workspaceDirectory } = args;
const { providersToFetch, sessionsQueryRoot, visible, client, cwd } = args;
if (providersToFetch === null) return [];
const enabled = visible && Boolean(client && workspaceDirectory);
const enabled = visible && Boolean(client);
return providersToFetch.map((provider) => ({
queryKey: [...sessionsQueryRoot, provider],
enabled,
queryFn: async () => {
if (!client || !workspaceDirectory) {
if (!client) {
throw new Error("Host is not connected");
}
return await client.fetchRecentProviderSessions({
cwd: workspaceDirectory,
...(cwd ? { cwd } : {}),
providers: [provider],
limit: PER_PROVIDER_LIMIT,
});
@@ -172,8 +175,6 @@ interface SheetStatusMessagesProps {
allQueriesErrored: boolean;
erroredProviderLabels: ReadonlyArray<string>;
importErrored: boolean;
showEmptyState: boolean;
allAlreadyImported: boolean;
}
function SheetStatusMessages({
@@ -184,12 +185,10 @@ function SheetStatusMessages({
allQueriesErrored,
erroredProviderLabels,
importErrored,
showEmptyState,
allAlreadyImported,
}: SheetStatusMessagesProps) {
const { theme } = useUnistyles();
if (!isClientReady) {
return <Text style={styles.statusText}>Connect to a workspace to import sessions</Text>;
return <Text style={styles.statusText}>Connect to a host to import sessions</Text>;
}
if (isSnapshotUnsupported) {
return <Text style={styles.statusText}>Update the host to import sessions.</Text>;
@@ -216,45 +215,117 @@ function SheetStatusMessages({
{importErrored ? (
<Text style={styles.statusText}>Could not import selected session.</Text>
) : null}
{showEmptyState ? (
<Text style={styles.statusText}>
{allAlreadyImported
? "All recent sessions are already imported."
: "No recent sessions to import."}
</Text>
) : null}
</>
);
}
interface EmptyStateInputs {
isLoadingSessions: boolean;
allQueriesErrored: boolean;
isQueryingProviders: boolean;
allQueriesSettled: boolean;
selectedProvider: string;
aggregatedCount: number;
visibleCount: number;
totalAlreadyImportedCount: number;
providerLabelById: ReadonlyMap<string, string>;
}
function computeEmptyState(input: EmptyStateInputs): {
showEmptyState: boolean;
emptyStateTitle: string;
} {
const showEmptyState =
!input.isLoadingSessions &&
!input.allQueriesErrored &&
input.isQueryingProviders &&
input.allQueriesSettled &&
input.visibleCount === 0;
if (!showEmptyState) {
return { showEmptyState, emptyStateTitle: "" };
}
const isFilteredEmpty = input.selectedProvider !== ALL_FILTER_VALUE && input.aggregatedCount > 0;
if (isFilteredEmpty) {
const label = input.providerLabelById.get(input.selectedProvider) ?? input.selectedProvider;
return { showEmptyState, emptyStateTitle: `No ${label} sessions found.` };
}
if (input.totalAlreadyImportedCount > 0) {
return { showEmptyState, emptyStateTitle: "All recent sessions are already imported." };
}
return { showEmptyState, emptyStateTitle: "No recent sessions to import." };
}
function RefreshAction({ isRefreshing, onPress }: { isRefreshing: boolean; onPress: () => void }) {
const { theme } = useUnistyles();
const pressableStyle = useCallback(
({ pressed }: PressableStateCallbackType) => [
styles.refreshButton,
pressed && styles.refreshButtonPressed,
],
[],
);
return (
<Pressable
onPress={onPress}
disabled={isRefreshing}
accessibilityLabel="Refresh sessions"
accessibilityRole="button"
testID="import-session-refresh"
style={pressableStyle}
>
<View style={styles.refreshIconSlot}>
{isRefreshing ? (
<LoadingSpinner color={theme.colors.foregroundMuted} />
) : (
<RotateCw size={16} color={theme.colors.foregroundMuted} />
)}
</View>
</Pressable>
);
}
function SheetEmptyState({ title }: { title: string }) {
const { theme } = useUnistyles();
return (
<View style={styles.emptyState} testID="import-session-empty-state">
<View style={styles.emptyStateIcon}>
<Inbox size={theme.iconSize.lg} color={theme.colors.foregroundMuted} strokeWidth={1.5} />
</View>
<Text style={styles.emptyStateTitle}>{title}</Text>
</View>
);
}
function buildProviderFilterOptions(
providers: ReadonlyArray<string>,
providerLabelById: ReadonlyMap<string, string>,
): SegmentedControlOption<string>[] {
const options: SegmentedControlOption<string>[] = [
{ value: ALL_FILTER_VALUE, label: "All", testID: "workspace-import-filter-all" },
{ value: ALL_FILTER_VALUE, label: "All", testID: "import-session-filter-all" },
];
for (const provider of providers) {
const ProviderIcon = getProviderIcon(provider);
options.push({
value: provider,
label: providerLabelById.get(provider) ?? provider,
testID: `workspace-import-filter-${provider}`,
testID: `import-session-filter-${provider}`,
icon: ({ color, size }) => <ProviderIcon color={color} size={size} />,
});
}
return options;
}
function WorkspaceImportSheetRow({
function ImportSessionSheetRow({
entry,
disabled,
importing,
showCwd,
onImportSession,
}: {
entry: FetchRecentProviderSessionEntry;
disabled: boolean;
importing: boolean;
showCwd: boolean;
onImportSession: (entry: FetchRecentProviderSessionEntry) => void;
}) {
const { theme } = useUnistyles();
@@ -285,7 +356,7 @@ function WorkspaceImportSheetRow({
accessibilityRole="button"
accessibilityState={accessibilityState}
style={pressableStyle}
testID={`workspace-import-session-${entry.providerId}-${entry.providerHandleId}`}
testID={`import-session-session-${entry.providerId}-${entry.providerHandleId}`}
>
<View style={styles.rowIconWrap}>
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
@@ -300,19 +371,25 @@ function WorkspaceImportSheetRow({
<Text style={styles.rowPreview} numberOfLines={2}>
{promptPreview}
</Text>
{showCwd && entry.cwd ? (
<Text style={styles.rowCwd} numberOfLines={1}>
{entry.cwd}
</Text>
) : null}
</View>
</Pressable>
);
}
export function WorkspaceImportSheet({
export function ImportSessionSheet({
visible,
client,
serverId,
workspaceDirectory,
cwd,
onClose,
onImportedAgent,
}: WorkspaceImportSheetProps) {
onImported,
}: ImportSessionSheetProps) {
const queryClient = useQueryClient();
const { entries: snapshotEntries, supportsSnapshot } = useProvidersSnapshot(serverId, {
@@ -330,8 +407,8 @@ export function WorkspaceImportSheet({
);
const sessionsQueryRoot = useMemo(
() => ["recent-provider-sessions", workspaceDirectory] as const,
[workspaceDirectory],
() => ["recent-provider-sessions", cwd ?? null] as const,
[cwd],
);
const queriesConfig = useMemo(
@@ -341,9 +418,9 @@ export function WorkspaceImportSheet({
sessionsQueryRoot,
visible,
client,
workspaceDirectory,
cwd,
}),
[providersToFetch, sessionsQueryRoot, visible, client, workspaceDirectory],
[providersToFetch, sessionsQueryRoot, visible, client, cwd],
);
const queries = useQueries({ queries: queriesConfig });
@@ -379,20 +456,25 @@ export function WorkspaceImportSheet({
const importMutation = useMutation({
mutationFn: async (entry: FetchRecentProviderSessionEntry) => {
if (!client || !workspaceDirectory) {
if (!client) {
throw new Error("Host is not connected");
}
const effectiveCwd = cwd ?? entry.cwd;
if (!effectiveCwd) {
throw new Error("Session is missing a working directory");
}
const agent = await client.importAgent({
providerId: entry.providerId,
providerHandleId: entry.providerHandleId,
cwd: workspaceDirectory,
cwd: effectiveCwd,
});
return agent;
},
onSuccess: async (agent) => {
await queryClient.invalidateQueries({ queryKey: sessionsQueryRoot });
onClose();
onImportedAgent(agent.id);
onImportedAgent?.(agent.id);
onImported?.(agent);
},
});
@@ -413,6 +495,20 @@ export function WorkspaceImportSheet({
[queries, providersToFetch, providerLabelById],
);
const isRefreshing = queries.some((query) => query.isFetching);
const handleRefresh = useCallback(() => {
void queryClient.invalidateQueries({ queryKey: sessionsQueryRoot });
}, [queryClient, sessionsQueryRoot]);
const header = useMemo<SheetHeader>(
() => ({
title: "Import session",
actions: <RefreshAction isRefreshing={isRefreshing} onPress={handleRefresh} />,
}),
[isRefreshing, handleRefresh],
);
const isSnapshotUnsupported = !supportsSnapshot;
const isWaitingForSnapshot = supportsSnapshot && snapshotEntries === undefined;
const hasNoImportableProviders = providersToFetch !== null && providersToFetch.length === 0;
@@ -423,21 +519,25 @@ export function WorkspaceImportSheet({
const allQueriesErrored = isQueryingProviders && queries.every((query) => query.isError);
const allQueriesSettled =
isQueryingProviders && queries.every((query) => !query.isLoading && !query.isPending);
const showEmptyState =
!isLoadingSessions &&
!allQueriesErrored &&
isQueryingProviders &&
allQueriesSettled &&
aggregatedEntries.length === 0;
const allAlreadyImported = showEmptyState && totalAlreadyImportedCount > 0;
const { showEmptyState, emptyStateTitle } = computeEmptyState({
isLoadingSessions,
allQueriesErrored,
isQueryingProviders,
allQueriesSettled,
selectedProvider,
aggregatedCount: aggregatedEntries.length,
visibleCount: visibleEntries.length,
totalAlreadyImportedCount,
providerLabelById,
});
const showFilter = filterProviders.length > 1;
return (
<AdaptiveModalSheet
visible={visible}
onClose={onClose}
header={IMPORT_SESSION_HEADER}
testID="workspace-import-sheet"
header={header}
testID="import-session-sheet"
desktopMaxWidth={560}
snapPoints={IMPORT_SHEET_SNAP_POINTS}
>
@@ -448,7 +548,7 @@ export function WorkspaceImportSheet({
contentContainerStyle={styles.filterRow}
>
<SegmentedControl
testID="workspace-import-filters"
testID="import-session-filters"
size="sm"
options={filterOptions}
value={selectedProvider}
@@ -457,29 +557,29 @@ export function WorkspaceImportSheet({
</ScrollView>
) : null}
<SheetStatusMessages
isClientReady={Boolean(client && workspaceDirectory)}
isClientReady={Boolean(client)}
isSnapshotUnsupported={isSnapshotUnsupported}
hasNoImportableProviders={hasNoImportableProviders}
isLoadingSessions={isLoadingSessions}
allQueriesErrored={allQueriesErrored}
erroredProviderLabels={erroredProviderLabels}
importErrored={importMutation.isError}
showEmptyState={showEmptyState}
allAlreadyImported={allAlreadyImported}
/>
{visibleEntries.length > 0 ? (
<View style={styles.list}>
{visibleEntries.map((entry) => (
<WorkspaceImportSheetRow
<ImportSessionSheetRow
key={`${entry.providerId}:${entry.providerHandleId}`}
entry={entry}
disabled={importMutation.isPending}
importing={importingSessionKey === `${entry.providerId}:${entry.providerHandleId}`}
showCwd={!cwd}
onImportSession={handleImportSession}
/>
))}
</View>
) : null}
{showEmptyState ? <SheetEmptyState title={emptyStateTitle} /> : null}
</AdaptiveModalSheet>
);
}
@@ -539,6 +639,10 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm,
lineHeight: 20,
},
rowCwd: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
statusRow: {
flexDirection: "row",
alignItems: "center",
@@ -549,4 +653,34 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
emptyState: {
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[2],
paddingVertical: theme.spacing[8],
paddingHorizontal: theme.spacing[4],
},
emptyStateIcon: {
opacity: 0.6,
marginBottom: theme.spacing[1],
},
emptyStateTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
textAlign: "center",
},
refreshButton: {
padding: theme.spacing[2],
marginRight: theme.spacing[1],
borderRadius: theme.borderRadius.lg,
},
refreshButtonPressed: {
backgroundColor: theme.colors.surface2,
},
refreshIconSlot: {
width: 16,
height: 16,
alignItems: "center",
justifyContent: "center",
},
}));
+33 -15
View File
@@ -10,7 +10,7 @@ import {
clearCommandCenterFocusRestoreElement,
takeCommandCenterFocusRestoreElement,
} from "@/utils/command-center-focus-restore";
import { buildSettingsRoute } from "@/utils/host-routes";
import { buildHostOpenProjectRoute, buildSettingsRoute } from "@/utils/host-routes";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { chordStringToShortcutKeys } from "@/keyboard/shortcut-string";
import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts";
@@ -52,10 +52,10 @@ function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number {
interface CommandCenterActionDefinition {
id: string;
title: string;
icon?: "plus" | "settings";
icon?: "plus" | "settings" | "home";
actionId?: string;
keywords: string[];
routeKind: "settings" | "none";
routeKind: "settings" | "home" | "none";
}
const COMMAND_CENTER_ACTIONS: readonly CommandCenterActionDefinition[] = [
@@ -67,6 +67,13 @@ const COMMAND_CENTER_ACTIONS: readonly CommandCenterActionDefinition[] = [
keywords: ["open", "project", "folder", "workspace", "repo"],
routeKind: "none",
},
{
id: "home",
title: "Home",
icon: "home",
keywords: ["home", "start", "import", "session", "pair", "device", "providers"],
routeKind: "home",
},
{
id: "settings",
title: "Settings",
@@ -89,7 +96,7 @@ export interface CommandCenterActionItem {
kind: "action";
id: string;
title: string;
icon?: "plus" | "settings";
icon?: "plus" | "settings" | "home";
route?: Href;
shortcutKeys?: ShortcutKey[][];
}
@@ -155,21 +162,32 @@ export function useCommandCenter() {
return buildSettingsRoute();
}, []);
const homeRoute = useMemo<Href | undefined>(() => {
if (!routeActiveServerId) return undefined;
return buildHostOpenProjectRoute(routeActiveServerId) as Href;
}, [routeActiveServerId]);
const actionItems = useMemo(() => {
if (!open) {
return EMPTY_ACTION_ITEMS;
}
return COMMAND_CENTER_ACTIONS.filter((action) =>
matchesActionQuery(query, action),
).map<CommandCenterActionItem>((action) => ({
kind: "action",
id: action.id,
title: action.title,
icon: action.icon,
route: action.routeKind === "settings" ? settingsRoute : undefined,
shortcutKeys: resolveActionShortcutKeys(action.actionId, overrides),
}));
}, [open, query, settingsRoute, overrides]);
return COMMAND_CENTER_ACTIONS.filter((action) => {
if (action.routeKind === "home" && !homeRoute) return false;
return matchesActionQuery(query, action);
}).map<CommandCenterActionItem>((action) => {
let route: Href | undefined;
if (action.routeKind === "settings") route = settingsRoute;
else if (action.routeKind === "home") route = homeRoute;
return {
kind: "action",
id: action.id,
title: action.title,
icon: action.icon,
route,
shortcutKeys: resolveActionShortcutKeys(action.actionId, overrides),
};
});
}, [open, query, settingsRoute, homeRoute, overrides]);
const items = useMemo(() => {
if (!open) {
@@ -76,6 +76,10 @@ class FakeDaemonClient {
return { rttMs: 0 };
}
async checkLiveness(): Promise<{ rttMs: number }> {
return this.ping();
}
setReconnectEnabled(_enabled: boolean): void {}
setConnectionState(next: ConnectionState): void {
@@ -620,6 +624,85 @@ describe("HostRuntimeController", () => {
expect((initialClient as unknown as FakeDaemonClient | null)?.closeCalls).toBe(1);
});
it("uses liveness probes instead of session RPC timeouts for active connection health", async () => {
const relay: HostConnection = {
id: "relay:relay.paseo.sh:443",
type: "relay",
relayEndpoint: "relay.paseo.sh:443",
useTls: true,
daemonPublicKeyB64: "pk_test",
};
const host = makeHost({
connections: [relay],
preferredConnectionId: relay.id,
});
const clients: FakeDaemonClient[] = [];
const controller = new HostRuntimeController({
host,
deps: makeDeps({ [relay.id]: 12 }, clients),
});
await controller.start({ autoProbe: false });
expect(controller.getSnapshot().connectionStatus).toBe("online");
const activeClient = controller.getSnapshot().client as unknown as FakeDaemonClient;
activeClient.ping = async () => {
throw new Error("Timeout waiting for message (5000ms)");
};
activeClient.checkLiveness = async () => ({ rttMs: 11 });
clearProbeBackoff(controller);
await controller.runProbeCycleNow();
const snapshot = controller.getSnapshot();
expect(snapshot.probeByConnectionId.get(relay.id)).toEqual({
status: "available",
latencyMs: 11,
});
expect(snapshot.connectionStatus).toBe("online");
});
it("backs off inactive connection probes while a host is online", async () => {
const host = makeHost({ preferredConnectionId: "direct:lan:6767" });
const clients: FakeDaemonClient[] = [];
const latencies: Record<string, number | Error> = {
"direct:lan:6767": 10,
"relay:relay.paseo.sh:443": 50,
};
const controller = new HostRuntimeController({
host,
deps: makeDeps(latencies, clients),
});
await controller.start({ autoProbe: false });
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
const initialClientCount = clients.length;
const initialRelayProbe = controller
.getSnapshot()
.probeByConnectionId.get("relay:relay.paseo.sh:443");
latencies["direct:lan:6767"] = 12;
latencies["relay:relay.paseo.sh:443"] = 25;
const lastProbedAt = (
controller as unknown as {
connectionLastProbedAt: Map<string, number>;
}
).connectionLastProbedAt;
const now = performance.now();
lastProbedAt.set("direct:lan:6767", now - 60_000);
lastProbedAt.set("relay:relay.paseo.sh:443", now - 60_000);
await controller.runProbeCycleNow();
const snapshot = controller.getSnapshot();
expect(clients.length).toBe(initialClientCount);
expect(snapshot.probeByConnectionId.get("direct:lan:6767")).toEqual({
status: "available",
latencyMs: 12,
});
expect(snapshot.probeByConnectionId.get("relay:relay.paseo.sh:443")).toEqual(initialRelayProbe);
});
it("switches only after the faster alternative wins consecutive probes", async () => {
const host = makeHost({ preferredConnectionId: "direct:lan:6767" });
const clients: FakeDaemonClient[] = [];
+13 -2
View File
@@ -142,6 +142,7 @@ export interface HostRuntimeStartOptions {
const PROBE_TICK_MS = 2_000;
const PROBE_STEADY_MS = 10_000;
const PROBE_MAX_BACKOFF_MS = 30_000;
const PROBE_INACTIVE_WHILE_ONLINE_MS = 120_000;
const ADAPTIVE_SWITCH_THRESHOLD_MS = 40;
const ADAPTIVE_SWITCH_CONSECUTIVE_PROBES = 3;
const DEFAULT_AGENT_DIRECTORY_PAGE_LIMIT = 200;
@@ -434,11 +435,15 @@ function findConnectionById(host: HostProfile, connectionId: string | null): Hos
function probeIntervalForConnection(
firstSeenAt: number,
isActiveOnline: boolean,
hasActiveOnlineConnection: boolean,
now: number,
): number {
if (isActiveOnline) {
return PROBE_STEADY_MS;
}
if (hasActiveOnlineConnection) {
return PROBE_INACTIVE_WHILE_ONLINE_MS;
}
const age = now - firstSeenAt;
if (age < 10_000) return 2_000;
if (age < 30_000) return 5_000;
@@ -702,6 +707,7 @@ export class HostRuntimeController {
const now = performance.now();
const isOnline = this.snapshot.connectionStatus === "online";
const activeConnectionId = this.snapshot.activeConnectionId;
const hasActiveOnlineConnection = isOnline && activeConnectionId !== null;
const connectionsToProbe = this.host.connections.filter((connection) => {
const lastProbed = this.connectionLastProbedAt.get(connection.id);
@@ -710,7 +716,12 @@ export class HostRuntimeController {
}
const firstSeen = this.connectionFirstSeenAt.get(connection.id) ?? now;
const isActiveOnline = isOnline && connection.id === activeConnectionId;
const interval = probeIntervalForConnection(firstSeen, isActiveOnline, now);
const interval = probeIntervalForConnection(
firstSeen,
isActiveOnline,
hasActiveOnlineConnection,
now,
);
return now - lastProbed >= interval;
});
@@ -899,7 +910,7 @@ export class HostRuntimeController {
const activated = await maybeActivateFirstAvailable(connection.id, connectedClient);
shouldCloseClient = shouldCloseClient && !activated;
const { rttMs } = await connectedClient.ping({ timeoutMs: 5000 });
const { rttMs } = await connectedClient.checkLiveness({ timeoutMs: 5000 });
if (!this.isCurrentProbeRequest(requestVersion)) {
return;
}
+144 -47
View File
@@ -1,14 +1,12 @@
import { useCallback, useEffect, useState } from "react";
import { View, Text } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { FolderOpen, Smartphone } from "lucide-react-native";
import { useCallback, useEffect, useState, type ComponentType } from "react";
import { View, Text, Pressable } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useRouter } from "expo-router";
import { FolderOpen, Inbox, Plug, Smartphone } from "lucide-react-native";
import { PaseoLogo } from "@/components/icons/paseo-logo";
import { Button } from "@/components/ui/button";
import { MenuHeader } from "@/components/headers/menu-header";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { usePanelStore } from "@/stores/panel-store";
import { useSessionStore } from "@/stores/session-store";
import { useHasWorkspaces } from "@/stores/session-store-hooks";
import {
useIsCompactFormFactor,
HEADER_INNER_HEIGHT,
@@ -18,14 +16,21 @@ import {
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
import { PairDeviceModal } from "@/desktop/components/pair-device-modal";
import { buildHostAgentDetailRoute, buildSettingsHostRoute } from "@/utils/host-routes";
import { ImportSessionSheet } from "@/components/import-session-sheet";
import { useHostRuntimeClient } from "@/runtime/host-runtime";
import { useOpenProject } from "@/hooks/use-open-project";
import type { Href } from "expo-router";
export function OpenProjectScreen({ serverId }: { serverId: string }) {
const router = useRouter();
const openDesktopAgentList = usePanelStore((s) => s.openDesktopAgentList);
const openProjectPicker = useOpenProjectPicker(serverId);
const hasHydrated = useSessionStore((s) => s.sessions[serverId]?.hasHydratedWorkspaces ?? false);
const hasProjects = useHasWorkspaces(serverId);
const isLocalDaemon = useIsLocalDaemon(serverId);
const client = useHostRuntimeClient(serverId);
const openProject = useOpenProject(serverId);
const [isPairDeviceOpen, setIsPairDeviceOpen] = useState(false);
const [isImportSheetOpen, setIsImportSheetOpen] = useState(false);
const isCompactLayout = useIsCompactFormFactor();
@@ -42,40 +47,62 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
const handleOpenPairDevice = useCallback(() => setIsPairDeviceOpen(true), []);
const handleClosePairDevice = useCallback(() => setIsPairDeviceOpen(false), []);
const handleOpenImportSession = useCallback(() => setIsImportSheetOpen(true), []);
const handleCloseImportSession = useCallback(() => setIsImportSheetOpen(false), []);
const handleImported = useCallback(
(agent: { id: string; cwd: string }) => {
void (async () => {
await openProject(agent.cwd);
router.push(buildHostAgentDetailRoute(serverId, agent.id) as Href);
})();
},
[openProject, router, serverId],
);
const handleOpenProviders = useCallback(() => {
router.push(buildSettingsHostRoute(serverId));
}, [router, serverId]);
return (
<View style={styles.container}>
<MenuHeader borderless />
<View style={styles.content}>
<TitlebarDragRegion />
<View style={styles.logo}>
<PaseoLogo size={56} />
<PaseoLogo size={52} />
</View>
<View style={styles.headingGroup}>
<Text style={styles.heading}>What shall we build today?</Text>
{hasHydrated && !hasProjects ? (
<Text style={styles.subtitle}>
Add a project folder to start running agents on your codebase
</Text>
) : null}
</View>
<View style={styles.cta}>
<Button
variant="default"
leftIcon={FolderOpen}
<View style={styles.tiles}>
<HomeTile
icon={FolderOpen}
title="Add a project"
description="Open a folder on your machine"
onPress={handleOpenPicker}
testID="open-project-submit"
>
Add a project
</Button>
accent
/>
<HomeTile
icon={Inbox}
title="Import session"
description="Bring in recent external CLI sessions"
onPress={handleOpenImportSession}
testID="open-project-import-session"
/>
<HomeTile
icon={Plug}
title="Setup providers"
description="Configure Claude Code, Codex, and more"
onPress={handleOpenProviders}
testID="open-project-setup-providers"
/>
{isLocalDaemon ? (
<Button
variant="outline"
leftIcon={Smartphone}
<HomeTile
icon={Smartphone}
title="Pair device"
description="Connect your phone to this daemon"
onPress={handleOpenPairDevice}
testID="open-project-pair-device"
>
Pair device
</Button>
/>
) : null}
</View>
</View>
@@ -84,10 +111,61 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
onClose={handleClosePairDevice}
testID="open-project-pair-device-modal"
/>
<ImportSessionSheet
visible={isImportSheetOpen}
client={client}
serverId={serverId}
onClose={handleCloseImportSession}
onImported={handleImported}
/>
</View>
);
}
interface HomeTileProps {
icon: ComponentType<{ size: number; color: string }>;
title: string;
description: string;
onPress: () => void;
testID?: string;
accent?: boolean;
}
function HomeTile({ icon: Icon, title, description, onPress, testID, accent }: HomeTileProps) {
// useUnistyles is acceptable here: leaf component, off the hot path (home screen renders once).
const { theme } = useUnistyles();
const [hovered, setHovered] = useState(false);
const handleHoverIn = useCallback(() => setHovered(true), []);
const handleHoverOut = useCallback(() => setHovered(false), []);
const iconColor = accent ? theme.colors.accent : theme.colors.foregroundMuted;
const pressableStyle = useCallback(
({ pressed }: { pressed: boolean }) => [
styles.tile,
hovered && styles.tileHovered,
pressed && styles.tilePressed,
],
[hovered],
);
return (
<Pressable
onPress={onPress}
onHoverIn={handleHoverIn}
onHoverOut={handleHoverOut}
testID={testID}
style={pressableStyle}
>
<Icon size={20} color={iconColor} />
<View style={styles.tileText}>
<Text style={styles.tileTitle}>{title}</Text>
<Text style={styles.tileDescription}>{description}</Text>
</View>
</Pressable>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
@@ -97,10 +175,11 @@ const styles = StyleSheet.create((theme) => ({
content: {
position: "relative",
flex: 1,
justifyContent: "center",
justifyContent: { xs: "flex-start", md: "center" },
alignItems: "center",
gap: 0,
padding: theme.spacing[6],
paddingTop: { xs: theme.spacing[12], md: theme.spacing[6] },
paddingBottom: {
xs: HEADER_INNER_HEIGHT_MOBILE + HEADER_TOP_PADDING_MOBILE + theme.spacing[6],
md: HEADER_INNER_HEIGHT + theme.spacing[6],
@@ -109,25 +188,43 @@ const styles = StyleSheet.create((theme) => ({
logo: {
marginBottom: theme.spacing[8],
},
headingGroup: {
alignItems: "center",
tiles: {
marginTop: { xs: theme.spacing[6], md: theme.spacing[12] },
width: "100%",
maxWidth: 452,
flexDirection: "row",
flexWrap: "wrap",
justifyContent: "flex-start",
gap: theme.spacing[3],
},
cta: {
marginTop: theme.spacing[12],
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
tile: {
width: { xs: "100%", md: 220 },
minHeight: { xs: 0, md: 132 },
padding: theme.spacing[4],
backgroundColor: theme.colors.surface1,
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.xl,
gap: theme.spacing[3],
},
heading: {
tileHovered: {
backgroundColor: theme.colors.surface2,
borderColor: theme.colors.borderAccent,
},
tilePressed: {
opacity: 0.85,
},
tileText: {
gap: theme.spacing[1],
},
tileTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize["2xl"],
fontWeight: theme.fontWeight.normal,
textAlign: "center",
},
subtitle: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
textAlign: "center",
fontWeight: theme.fontWeight.normal,
},
tileDescription: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
lineHeight: 18,
},
}));
@@ -59,7 +59,7 @@ import { SourceControlPanelIcon } from "@/components/icons/source-control-panel-
import { WorkspaceGitActions } from "@/git/workspace-actions";
import { WorkspaceOpenInEditorButton } from "@/screens/workspace/workspace-open-in-editor-button";
import { WorkspaceScriptsButton } from "@/screens/workspace/workspace-scripts-button";
import { WorkspaceImportSheet } from "@/screens/workspace/workspace-import-sheet";
import { ImportSessionSheet } from "@/components/import-session-sheet";
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
import { useToast } from "@/contexts/toast-context";
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
@@ -3358,11 +3358,11 @@ function WorkspaceScreenContent({
/>
) : null}
</View>
<WorkspaceImportSheet
<ImportSessionSheet
visible={isImportSheetVisible}
client={client}
serverId={normalizedServerId}
workspaceDirectory={workspaceDirectory}
cwd={workspaceDirectory}
onClose={closeImportSheet}
onImportedAgent={handleImportedAgent}
/>
@@ -17,6 +17,9 @@ export const PROVIDER_COMMAND_TEMPLATES: Record<
claude: {
resume: "claude --resume {sessionId}",
},
pi: {
resume: "pi --session {sessionId}",
},
};
function renderTemplate(template: string, vars: Record<string, string>): string {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.79-beta.1",
"version": "0.1.79",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -24,7 +24,7 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/server": "0.1.79-beta.1",
"@getpaseo/server": "0.1.79",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.1.79-beta.1",
"version": "0.1.79",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.79-beta.1",
"version": "0.1.79",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.1.79-beta.1",
"version": "0.1.79",
"files": [
"dist"
],
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.1.79-beta.1",
"version": "0.1.79",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist"
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.1.79-beta.1",
"version": "0.1.79",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -63,8 +63,8 @@
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.2.133",
"@getpaseo/highlight": "0.1.79-beta.1",
"@getpaseo/relay": "0.1.79-beta.1",
"@getpaseo/highlight": "0.1.79",
"@getpaseo/relay": "0.1.79",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
@@ -78,7 +78,6 @@
"fast-deep-equal": "^3.1.3",
"mnemonic-id": "^3.2.7",
"node-pty": "1.2.0-beta.11",
"onnxruntime-node": "^1.23.0",
"openai": "^4.20.0",
"p-limit": "^7.3.0",
"p-memoize": "^8.0.0",
@@ -86,8 +85,6 @@
"pino-pretty": "^13.1.3",
"qrcode": "^1.5.4",
"rotating-file-stream": "^3.2.9",
"sherpa-onnx": "1.12.28",
"sherpa-onnx-node": "1.12.28",
"strip-ansi": "^7.1.2",
"tree-kill": "^1.2.2",
"uuid": "^9.0.1",
@@ -105,6 +102,9 @@
"@types/ws": "^8.5.8",
"@vitest/ui": "^4.1.6",
"cross-env": "^10.1.0",
"onnxruntime-node": "^1.23.0",
"sherpa-onnx": "1.12.28",
"sherpa-onnx-node": "1.12.28",
"tsx": "^4.6.0",
"typescript": "^5.2.2",
"vitest": "^4.1.6"
@@ -281,6 +281,84 @@ test("does not reconnect after close when ensureConnected is called", async () =
expect(client.getConnectionState().status).toBe("disposed");
});
test("keeps the transport connected when a session RPC ping times out", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
expect(client.getConnectionState().status).toBe("connected");
await expect(client.ping({ timeoutMs: 1 })).rejects.toThrow("Timeout waiting for message");
expect(client.getConnectionState().status).toBe("connected");
});
test("reconnects after repeated top-level liveness checks time out", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
expect(client.getConnectionState().status).toBe("connected");
await expect(client.checkLiveness({ timeoutMs: 1 })).rejects.toThrow("Liveness check timed out");
expect(client.getConnectionState().status).toBe("connected");
await expect(client.checkLiveness({ timeoutMs: 1 })).rejects.toThrow("Liveness check timed out");
expect(client.getConnectionState()).toEqual({
status: "disconnected",
reason: "Liveness check timed out (1ms)",
});
});
test("resets liveness failures after a top-level pong", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
await expect(client.checkLiveness({ timeoutMs: 1 })).rejects.toThrow("Liveness check timed out");
const healthyProbe = client.checkLiveness({ timeoutMs: 100 });
mock.triggerMessage(JSON.stringify({ type: "pong" }));
await expect(healthyProbe).resolves.toEqual({ rttMs: expect.any(Number) });
await expect(client.checkLiveness({ timeoutMs: 1 })).rejects.toThrow("Liveness check timed out");
expect(client.getConnectionState().status).toBe("connected");
});
test("listDirectory sends a list file explorer request and returns directory entries", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
@@ -608,6 +686,62 @@ test("sends create_agent_request with string workspace ids", async () => {
await expect(createPromise).rejects.toThrow("compat test sentinel");
});
test("sends worktree target and autoArchive in create_agent_request", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const createPromise = client.createAgent({
provider: "codex",
cwd: "/tmp/project",
worktree: {
mode: "branch-off",
newBranch: "agent-lifecycle-dispatch",
base: "main",
},
autoArchive: true,
});
expect(mock.sent).toHaveLength(1);
const request = parseSentFrame(mock.sent[0]);
expect(request).toEqual(
expect.objectContaining({
type: "create_agent_request",
worktree: {
mode: "branch-off",
newBranch: "agent-lifecycle-dispatch",
base: "main",
},
autoArchive: true,
}),
);
mock.triggerMessage(
wrapSessionMessage({
type: "status",
payload: {
status: "agent_create_failed",
requestId: request.requestId,
error: "worktree auto archive sentinel",
},
}),
);
await expect(createPromise).rejects.toThrow("worktree auto archive sentinel");
});
test("sends structured attachments with create_agent_request", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
+113
View File
@@ -257,6 +257,8 @@ export interface CreateAgentRequestOptions extends AgentConfigOverrides {
images?: CreateAgentRequestMessage["images"];
attachments?: CreateAgentRequestMessage["attachments"];
git?: GitSetupOptions;
worktree?: CreateAgentRequestMessage["worktree"];
autoArchive?: CreateAgentRequestMessage["autoArchive"];
worktreeName?: string;
requestId?: string;
labels?: Record<string, string>;
@@ -719,6 +721,8 @@ class DaemonRpcError extends Error {
const DEFAULT_RECONNECT_BASE_DELAY_MS = 1500;
const DEFAULT_RECONNECT_MAX_DELAY_MS = 30000;
const DEFAULT_CONNECT_TIMEOUT_MS = 15000;
const DEFAULT_LIVENESS_TIMEOUT_MS = 5000;
const LIVENESS_FAILURE_RECONNECT_THRESHOLD = 2;
/** Default timeout for waiting for connection before sending queued messages */
const DEFAULT_SEND_QUEUE_TIMEOUT_MS = 10000;
@@ -825,6 +829,14 @@ interface PendingSend {
timeoutHandle: ReturnType<typeof setTimeout>;
}
interface LivenessProbe {
promise: Promise<{ rttMs: number }>;
resolve: (value: { rttMs: number }) => void;
reject: (error: Error) => void;
timeoutHandle: ReturnType<typeof setTimeout>;
startedAt: number;
}
export class DaemonClient {
private transport: DaemonTransport | null = null;
private transportCleanup: Array<() => void> = [];
@@ -868,6 +880,8 @@ export class DaemonClient {
private lastServerInfoMessage: ServerInfoStatusPayload | null = null;
private runtimeMetricsInterval: ReturnType<typeof setInterval> | null = null;
private runtimeMetrics: DaemonClientRuntimeMetrics | null = null;
private livenessProbe: LivenessProbe | null = null;
private consecutiveLivenessFailures = 0;
constructor(private config: DaemonClientConfig) {
this.logger = config.logger ?? consoleLogger;
@@ -1129,6 +1143,7 @@ export class DaemonClient {
this.disposeTransport(1000, "Client closed");
this.clearWaiters(new Error("Daemon client closed"));
this.rejectPendingSendQueue(new Error("Daemon client closed"));
this.rejectLivenessProbe(new Error("Daemon client closed"));
this.terminalStreams.clearSlots();
this.lastServerInfoMessage = null;
if (this.runtimeMetricsInterval) {
@@ -1564,6 +1579,54 @@ export class DaemonClient {
};
}
checkLiveness(params?: { timeoutMs?: number }): Promise<{ rttMs: number }> {
if (this.connectionState.status !== "connected" || !this.transport) {
return Promise.reject(
new Error(`Transport not connected (status: ${this.connectionState.status})`),
);
}
if (this.livenessProbe) {
return this.livenessProbe.promise;
}
const startedAt = perfNow();
const timeoutMs = Math.max(1, params?.timeoutMs ?? DEFAULT_LIVENESS_TIMEOUT_MS);
let resolveProbe: ((value: { rttMs: number }) => void) | null = null;
let rejectProbe: ((error: Error) => void) | null = null;
const promise = new Promise<{ rttMs: number }>((resolve, reject) => {
resolveProbe = resolve;
rejectProbe = reject;
});
const probe: LivenessProbe = {
promise,
resolve: (value) => resolveProbe?.(value),
reject: (error) => rejectProbe?.(error),
timeoutHandle: setTimeout(() => {
if (this.livenessProbe !== probe) {
return;
}
this.livenessProbe = null;
const error = new Error(`Liveness check timed out (${timeoutMs}ms)`);
probe.reject(error);
this.recordLivenessFailure(error);
}, timeoutMs),
startedAt,
};
this.livenessProbe = probe;
try {
this.transport.send(JSON.stringify({ type: "ping" }));
} catch (error) {
this.clearLivenessProbe();
const err = error instanceof Error ? error : new Error(String(error));
this.recordLivenessFailure(err);
return Promise.reject(err);
}
return promise;
}
// ============================================================================
// Agent RPCs (requestId-correlated)
// ============================================================================
@@ -1848,6 +1911,8 @@ export class DaemonClient {
? { attachments: options.attachments }
: {}),
...(options.git ? { git: options.git } : {}),
...(options.worktree ? { worktree: options.worktree } : {}),
...(options.autoArchive !== undefined ? { autoArchive: options.autoArchive } : {}),
...(options.worktreeName ? { worktreeName: options.worktreeName } : {}),
...(options.labels && Object.keys(options.labels).length > 0
? { labels: options.labels }
@@ -4263,7 +4328,10 @@ export class DaemonClient {
return;
}
this.consecutiveLivenessFailures = 0;
if (parsed.data.type === "pong") {
this.resolveLivenessProbe();
this.runtimeMetrics?.recordMessage("pong", bytes, perfNow() - startMs);
return;
}
@@ -4412,6 +4480,7 @@ export class DaemonClient {
// and responses from the previous connection will never arrive.
this.clearWaiters(new Error(reason ?? "Connection lost"));
this.rejectPendingSendQueue(new Error(reason ?? "Connection lost"));
this.rejectLivenessProbe(new Error(reason ?? "Connection lost"));
this.terminalStreams.clearSlots();
this.lastServerInfoMessage = null;
@@ -4460,6 +4529,50 @@ export class DaemonClient {
}, delay);
}
private resolveLivenessProbe(): void {
const probe = this.livenessProbe;
if (!probe) {
return;
}
this.livenessProbe = null;
clearTimeout(probe.timeoutHandle);
probe.resolve({ rttMs: perfNow() - probe.startedAt });
}
private clearLivenessProbe(): void {
const probe = this.livenessProbe;
if (!probe) {
return;
}
this.livenessProbe = null;
clearTimeout(probe.timeoutHandle);
}
private rejectLivenessProbe(error: Error): void {
const probe = this.livenessProbe;
if (!probe) {
return;
}
this.livenessProbe = null;
clearTimeout(probe.timeoutHandle);
probe.reject(error);
}
private recordLivenessFailure(error: Error): void {
this.consecutiveLivenessFailures += 1;
if (this.consecutiveLivenessFailures < LIVENESS_FAILURE_RECONNECT_THRESHOLD) {
return;
}
this.consecutiveLivenessFailures = 0;
this.lastErrorValue = error.message;
this.disposeTransport(1001, "Liveness check timed out");
this.scheduleReconnect({
reason: error.message,
event: "LIVENESS_TIMEOUT",
reasonCode: "liveness_timeout",
});
}
private handleSessionMessage(msg: SessionOutboundMessage): void {
if (msg.type === "status") {
const serverInfo = parseServerInfoStatusPayload(msg.payload);
@@ -0,0 +1,227 @@
import { randomUUID } from "node:crypto";
import type pino from "pino";
import type { GitHubService } from "../../services/github-service.js";
import { isPaseoOwnedWorktreeCwd } from "../../utils/worktree.js";
import { archivePaseoWorktree } from "../paseo-worktree-archive-service.js";
import type {
CreatePaseoWorktreeWorkflowFn,
CreatePaseoWorktreeWorkflowResult,
} from "../worktree-session.js";
import type { WorkspaceGitService } from "../workspace-git-service.js";
import type {
CreateAgentWorktreeTarget,
FirstAgentContext,
SessionOutboundMessage,
} from "../messages.js";
import type { AgentManager } from "./agent-manager.js";
import type { AgentStorage } from "./agent-storage.js";
interface CreateAgentLifecycleDispatchDependencies {
paseoHome: string;
agentManager: AgentManager;
agentStorage: AgentStorage;
github: GitHubService;
workspaceGitService: WorkspaceGitService;
createPaseoWorktreeWorkflow: CreatePaseoWorktreeWorkflowFn;
archiveAgentForClose: (agentId: string) => Promise<unknown>;
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
emit: (message: SessionOutboundMessage) => void;
emitAgentRemove: (agentId: string) => void;
emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds: Iterable<string>) => Promise<void>;
markWorkspaceArchiving: (workspaceIds: Iterable<string>, archivingAt: string) => void;
clearWorkspaceArchiving: (workspaceIds: Iterable<string>) => void;
isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean;
killTerminalsUnderPath: (rootPath: string) => Promise<void>;
logger: pino.Logger;
}
export class CreateAgentLifecycleDispatch {
private readonly autoArchiveAgentIds = new Set<string>();
constructor(private readonly dependencies: CreateAgentLifecycleDispatchDependencies) {}
async createWorktreeForRequest(input: {
cwd: string;
target: CreateAgentWorktreeTarget | undefined;
firstAgentContext: FirstAgentContext;
hasLegacyGitOptions: boolean;
}): Promise<CreatePaseoWorktreeWorkflowResult | null> {
if (input.target && input.hasLegacyGitOptions) {
throw new Error("create_agent_request worktree cannot be combined with git options");
}
if (!input.target) {
return null;
}
return this.createWorktreeForTarget(input.cwd, input.target, input.firstAgentContext);
}
registerAutoArchiveIfRequested(input: {
autoArchive: boolean | undefined;
agentId: string;
createdWorktree: CreatePaseoWorktreeWorkflowResult | null;
}): void {
if (input.autoArchive !== true) {
return;
}
this.registerAutoArchiveOnTerminalState(input.agentId, {
worktreePath: input.createdWorktree?.worktree.worktreePath ?? null,
repoRoot: input.createdWorktree?.repoRoot ?? null,
});
}
async cleanupCreatedWorktreeAfterFailedAgentCreate(input: {
createdWorktree: CreatePaseoWorktreeWorkflowResult | null;
createdAgentId: string | null;
}): Promise<void> {
const { createdWorktree, createdAgentId } = input;
if (!createdWorktree || createdAgentId) {
return;
}
await this.archiveAutoCreatedWorktree({
agentId: null,
worktreePath: createdWorktree.worktree.worktreePath,
repoRoot: createdWorktree.repoRoot,
}).catch((archiveError) => {
this.dependencies.logger.warn(
{
err: archiveError,
worktreePath: createdWorktree.worktree.worktreePath,
},
"Failed to clean up worktree after create_agent_request failed",
);
});
}
private async createWorktreeForTarget(
cwd: string,
target: CreateAgentWorktreeTarget,
firstAgentContext: FirstAgentContext,
): Promise<CreatePaseoWorktreeWorkflowResult> {
const baseInput = {
cwd,
firstAgentContext,
runSetup: false,
paseoHome: this.dependencies.paseoHome,
} as const;
switch (target.mode) {
case "branch-off":
return this.dependencies.createPaseoWorktreeWorkflow(
{
...baseInput,
worktreeSlug: target.newBranch,
action: "branch-off",
...(target.base ? { refName: target.base } : {}),
},
target.base ? { resolveDefaultBranch: async () => target.base! } : undefined,
);
case "checkout-branch":
return this.dependencies.createPaseoWorktreeWorkflow({
...baseInput,
action: "checkout",
refName: target.branch,
});
case "checkout-pr":
return this.dependencies.createPaseoWorktreeWorkflow({
...baseInput,
action: "checkout",
githubPrNumber: target.prNumber,
});
default:
throw new Error("Unsupported create_agent_request worktree target");
}
}
private registerAutoArchiveOnTerminalState(
agentId: string,
options: { worktreePath: string | null; repoRoot: string | null },
): void {
const unsubscribe = this.dependencies.agentManager.subscribe(
(event) => {
if (event.type !== "agent_stream") {
return;
}
if (
event.event.type !== "turn_completed" &&
event.event.type !== "turn_failed" &&
event.event.type !== "turn_canceled"
) {
return;
}
unsubscribe();
void this.autoArchiveAgentOnce(agentId, options);
},
{ agentId, replayState: false },
);
}
private async autoArchiveAgentOnce(
agentId: string,
options: { worktreePath: string | null; repoRoot: string | null },
): Promise<void> {
if (this.autoArchiveAgentIds.has(agentId)) {
return;
}
this.autoArchiveAgentIds.add(agentId);
try {
if (options.worktreePath) {
await this.archiveAutoCreatedWorktree({
agentId,
worktreePath: options.worktreePath,
repoRoot: options.repoRoot,
});
return;
}
await this.dependencies.archiveAgentForClose(agentId);
} catch (error) {
this.dependencies.logger.warn({ err: error, agentId }, "Failed to auto-archive agent");
}
}
private async archiveAutoCreatedWorktree(options: {
agentId: string | null;
worktreePath: string;
repoRoot: string | null;
}): Promise<void> {
const ownership = await isPaseoOwnedWorktreeCwd(options.worktreePath, {
paseoHome: this.dependencies.paseoHome,
});
if (!ownership.allowed) {
throw new Error("Auto-created worktree is not a Paseo-owned worktree");
}
await archivePaseoWorktree(
{
paseoHome: this.dependencies.paseoHome,
github: this.dependencies.github,
workspaceGitService: this.dependencies.workspaceGitService,
agentManager: this.dependencies.agentManager,
agentStorage: this.dependencies.agentStorage,
archiveWorkspaceRecord: this.dependencies.archiveWorkspaceRecord,
emit: this.dependencies.emit,
emitWorkspaceUpdatesForWorkspaceIds: this.dependencies.emitWorkspaceUpdatesForWorkspaceIds,
markWorkspaceArchiving: this.dependencies.markWorkspaceArchiving,
clearWorkspaceArchiving: this.dependencies.clearWorkspaceArchiving,
isPathWithinRoot: this.dependencies.isPathWithinRoot,
killTerminalsUnderPath: this.dependencies.killTerminalsUnderPath,
sessionLogger: this.dependencies.logger,
},
{
targetPath: options.worktreePath,
repoRoot: options.repoRoot ?? ownership.repoRoot ?? null,
worktreesRoot: ownership.worktreeRoot,
requestId: randomUUID(),
},
);
if (options.agentId) {
this.dependencies.emitAgentRemove(options.agentId);
}
}
}
@@ -91,7 +91,7 @@ async function collectUntil(
function collectSubscribedUntil(
session: AgentSession,
predicate: (event: AgentStreamEvent) => boolean,
predicate: (event: AgentStreamEvent, events: AgentStreamEvent[]) => boolean,
timeoutMs = 45_000,
): Promise<AgentStreamEvent[]> {
return new Promise((resolve, reject) => {
@@ -103,7 +103,7 @@ function collectSubscribedUntil(
const unsubscribe = session.subscribe((event) => {
events.push(event);
if (!predicate(event)) {
if (!predicate(event, events)) {
return;
}
clearTimeout(timeout);
@@ -252,7 +252,7 @@ describe("ClaudeAgentSession integration", () => {
expect.objectContaining({
value: "default",
displayName: "Default (recommended)",
supportedEffortLevels: ["low", "medium", "high", "max"],
supportedEffortLevels: expect.arrayContaining(["low", "medium", "high", "max"]),
}),
);
expect(models).toContainEqual(
@@ -374,6 +374,14 @@ describe("ClaudeAgentSession integration", () => {
const autonomousWakeToken = `AUTONOMOUS_WAKE_${Date.now().toString(36)}`;
try {
const liveEventsPromise = collectSubscribedUntil(
handle.session,
(event, events) =>
isTerminalEvent(event) &&
compactText(getAssistantText(events)).includes(compactText(autonomousWakeToken)),
90_000,
);
const foregroundEvents = await collectUntilTerminal(
streamSession(
handle.session,
@@ -390,15 +398,12 @@ describe("ClaudeAgentSession integration", () => {
expect(compactText(getAssistantText(foregroundEvents))).toContain("spawned");
const liveEvents = await collectSubscribedUntil(
handle.session,
(event) => isTerminalEvent(event),
90_000,
);
const liveEvents = await liveEventsPromise;
expect(
liveEvents.some((event) => event.type === "turn_started" && event.provider === "claude"),
).toBe(true);
expect(compactText(getAssistantText(liveEvents))).toContain(compactText(autonomousWakeToken));
expect(liveEvents.at(-1)).toMatchObject({
type: "turn_completed",
provider: "claude",
+1
View File
@@ -841,6 +841,7 @@ export async function createPaseoDaemon(
const speechService = createSpeechService({
logger,
paseoHome: config.paseoHome,
openaiConfig: config.openai,
speechConfig: config.speech,
});
@@ -0,0 +1,274 @@
import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, expect, test } from "vitest";
import { getFullAccessConfig } from "./daemon-e2e/agent-configs.js";
import { createDaemonTestContext, type DaemonTestContext } from "./test-utils/index.js";
import type { CreateAgentOptions } from "./test-utils/index.js";
import type { CreateAgentWorktreeTarget } from "./messages.js";
let ctx: DaemonTestContext;
const tempRoots: string[] = [];
beforeEach(async () => {
ctx = await createDaemonTestContext();
});
afterEach(async () => {
await ctx.cleanup();
for (const tempRoot of tempRoots.splice(0)) {
rmSync(tempRoot, { recursive: true, force: true });
}
});
function createGitRepo(): string {
const tempRoot = mkdtempSync(path.join(tmpdir(), "create-agent-worktree-"));
tempRoots.push(tempRoot);
const repoDir = path.join(tempRoot, "repo");
execFileSync("git", ["init", "-b", "main", repoDir], { stdio: "pipe" });
execFileSync("git", ["config", "user.email", "test@getpaseo.local"], {
cwd: repoDir,
stdio: "pipe",
});
execFileSync("git", ["config", "user.name", "Paseo Test"], { cwd: repoDir, stdio: "pipe" });
writeFileSync(path.join(repoDir, "README.md"), "hello\n");
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], {
cwd: repoDir,
stdio: "pipe",
});
return repoDir;
}
async function expectAgentAbsentFromActiveList(agentId: string): Promise<void> {
await expect
.poll(
async () => {
const active = await ctx.client.fetchAgents();
return active.entries.map((entry) => entry.agent.id).includes(agentId);
},
{ timeout: 15000, interval: 100 },
)
.toBe(false);
}
async function expectAgentPresentInActiveList(agentId: string): Promise<void> {
const active = await ctx.client.fetchAgents();
expect(active.entries.map((entry) => entry.agent.id)).toContain(agentId);
}
async function expectActiveAgentListEmpty(): Promise<void> {
const active = await ctx.client.fetchAgents();
expect(active.entries).toEqual([]);
}
async function expectWorktreeAbsentFromList(repoDir: string, worktreePath: string): Promise<void> {
await expect
.poll(
async () => {
const listed = await ctx.client.getPaseoWorktreeList({ cwd: repoDir });
return listed.worktrees.map((worktree) => worktree.worktreePath).includes(worktreePath);
},
{ timeout: 15000, interval: 100 },
)
.toBe(false);
}
async function expectWorktreePresentInList(repoDir: string, worktreePath: string): Promise<void> {
await expect
.poll(
async () => {
const listed = await ctx.client.getPaseoWorktreeList({ cwd: repoDir });
return listed.worktrees.map((worktree) => worktree.worktreePath).includes(worktreePath);
},
{ timeout: 5000, interval: 100 },
)
.toBe(true);
}
async function expectWorktreeListEmpty(repoDir: string): Promise<void> {
const listed = await ctx.client.getPaseoWorktreeList({ cwd: repoDir });
expect(listed.worktrees).toEqual([]);
}
async function createAgentInBranchOffWorktree(options?: {
autoArchive?: boolean;
branchName?: string;
}): Promise<{ repoDir: string; agentId: string; worktreePath: string }> {
const repoDir = createGitRepo();
const branchName = options?.branchName ?? `agent-lifecycle-${Date.now()}`;
const created = await ctx.client.createAgent({
config: {
...getFullAccessConfig("codex"),
cwd: repoDir,
},
worktree: {
mode: "branch-off",
newBranch: branchName,
base: "main",
},
...(options?.autoArchive !== undefined ? { autoArchive: options.autoArchive } : {}),
initialPrompt: "Say done.",
});
return { repoDir, agentId: created.id, worktreePath: created.cwd };
}
test("create_agent_request creates a worktree and auto-archives both after the first turn", async () => {
const repoDir = createGitRepo();
const worktree: CreateAgentWorktreeTarget = {
mode: "branch-off",
newBranch: "agent-lifecycle-dispatch-test",
base: "main",
};
const request: CreateAgentOptions & {
worktree: CreateAgentWorktreeTarget;
autoArchive: true;
} = {
config: {
...getFullAccessConfig("codex"),
cwd: repoDir,
},
worktree,
autoArchive: true,
initialPrompt: "Say done.",
};
const created = await ctx.client.createAgent(request);
expect(created.cwd).not.toBe(repoDir);
const listedWithWorktree = await ctx.client.getPaseoWorktreeList({ cwd: repoDir });
expect(listedWithWorktree.worktrees).toEqual([
expect.objectContaining({
worktreePath: created.cwd,
branchName: "agent-lifecycle-dispatch-test",
}),
]);
await ctx.client.waitForFinish(created.id, 10000);
await expectAgentAbsentFromActiveList(created.id);
await expectWorktreeAbsentFromList(repoDir, created.cwd);
}, 30000);
test("create_agent_request with autoArchive archives only the agent when no worktree was created", async () => {
const repoDir = createGitRepo();
const created = await ctx.client.createAgent({
config: {
...getFullAccessConfig("codex"),
cwd: repoDir,
},
autoArchive: true,
initialPrompt: "Say done.",
});
await ctx.client.waitForFinish(created.id, 10000);
await expectAgentAbsentFromActiveList(created.id);
const archived = await ctx.client.fetchAgents({ filter: { includeArchived: true } });
expect(archived.entries.map((entry) => entry.agent.id)).toContain(created.id);
const worktrees = await ctx.client.getPaseoWorktreeList({ cwd: repoDir });
expect(worktrees.worktrees).toEqual([]);
});
test("create_agent_request with autoArchive archives an agent whose first turn fails", async () => {
const repoDir = createGitRepo();
const created = await ctx.client.createAgent({
config: {
...getFullAccessConfig("codex"),
cwd: repoDir,
},
autoArchive: true,
initialPrompt: "Emit a turn failure.",
});
await ctx.client.waitForFinish(created.id, 10000);
await expectAgentAbsentFromActiveList(created.id);
const archived = await ctx.client.fetchAgents({ filter: { includeArchived: true } });
expect(archived.entries.map((entry) => entry.agent.id)).toContain(created.id);
});
test("create_agent_request without autoArchive keeps today's active listing behavior", async () => {
const repoDir = createGitRepo();
const created = await ctx.client.createAgent({
config: {
...getFullAccessConfig("codex"),
cwd: repoDir,
},
initialPrompt: "Say done.",
});
await ctx.client.waitForFinish(created.id, 10000);
await expectAgentPresentInActiveList(created.id);
});
test("create_agent_request with worktree but no autoArchive leaves agent and worktree active", async () => {
const created = await createAgentInBranchOffWorktree();
await ctx.client.waitForFinish(created.agentId, 10000);
await expectAgentPresentInActiveList(created.agentId);
await expectWorktreePresentInList(created.repoDir, created.worktreePath);
await ctx.client.archivePaseoWorktree({ worktreePath: created.worktreePath });
});
test("archiving a created worktree still archives nested agents", async () => {
const created = await createAgentInBranchOffWorktree();
await ctx.client.waitForFinish(created.agentId, 10000);
await ctx.client.archivePaseoWorktree({ worktreePath: created.worktreePath });
await expectAgentAbsentFromActiveList(created.agentId);
await expectWorktreeAbsentFromList(created.repoDir, created.worktreePath);
});
test("create_agent_request rejects legacy git options before creating a worktree", async () => {
const repoDir = createGitRepo();
await expect(
ctx.client.createAgent({
config: {
...getFullAccessConfig("codex"),
cwd: repoDir,
},
git: {
createNewBranch: true,
newBranchName: "legacy-agent-branch",
},
worktree: {
mode: "branch-off",
newBranch: "agent-lifecycle-dispatch-test",
base: "main",
},
initialPrompt: "Say done.",
}),
).rejects.toThrow("worktree cannot be combined with git options");
await expectActiveAgentListEmpty();
await expectWorktreeListEmpty(repoDir);
});
test("create_agent_request fails cleanly when worktree creation cannot resolve target", async () => {
const repoDir = createGitRepo();
await expect(
ctx.client.createAgent({
config: {
...getFullAccessConfig("codex"),
cwd: repoDir,
},
worktree: {
mode: "checkout-branch",
branch: "does-not-exist",
},
initialPrompt: "Say done.",
}),
).rejects.toThrow();
await expectActiveAgentListEmpty();
await expectWorktreeListEmpty(repoDir);
});
+73 -2
View File
@@ -252,6 +252,7 @@ import {
handleWorkspaceSetupStatusRequest as handleWorkspaceSetupStatusRequestMessage,
} from "./worktree-session.js";
import { toWorktreeWireError } from "./worktree-errors.js";
import { CreateAgentLifecycleDispatch } from "./agent/create-agent-lifecycle-dispatch.js";
const WORKSPACE_GIT_WATCH_REMOVED_STATE_KEY = "__removed__";
@@ -826,6 +827,7 @@ export class Session {
private readonly serverId: string | undefined;
private readonly daemonVersion: string | undefined;
private readonly daemonRuntimeConfig: SessionOptions["daemonRuntimeConfig"];
private readonly createAgentLifecycleDispatch: CreateAgentLifecycleDispatch;
private voiceModeAgentId: string | null = null;
private voiceModeBaseConfig: VoiceModeBaseConfig | null = null;
@@ -911,6 +913,35 @@ export class Session {
isPathWithinRoot: (rootPath, candidatePath) => this.isPathWithinRoot(rootPath, candidatePath),
sessionLogger: this.sessionLogger,
});
this.createAgentLifecycleDispatch = new CreateAgentLifecycleDispatch({
paseoHome: this.paseoHome,
agentManager: this.agentManager,
agentStorage: this.agentStorage,
github: this.github,
workspaceGitService: this.workspaceGitService,
createPaseoWorktreeWorkflow: (input, workflowOptions) =>
this.createPaseoWorktreeWorkflow(input, workflowOptions),
archiveAgentForClose: (agentId) => this.archiveAgentForClose(agentId),
archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId),
emit: (message) => this.emit(message),
emitAgentRemove: (agentId) => {
if (this.agentUpdatesSubscription) {
this.bufferOrEmitAgentUpdate(this.agentUpdatesSubscription, {
kind: "remove",
agentId,
});
}
},
emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds) =>
this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds),
markWorkspaceArchiving: (workspaceIds, archivingAt) =>
this.markWorkspaceArchiving(workspaceIds, archivingAt),
clearWorkspaceArchiving: (workspaceIds) => this.clearWorkspaceArchiving(workspaceIds),
isPathWithinRoot: (rootPath, candidatePath) => this.isPathWithinRoot(rootPath, candidatePath),
killTerminalsUnderPath: (rootPath) =>
this.terminalController.killTerminalsUnderPath(rootPath),
logger: this.sessionLogger,
});
this.providerSnapshotManager = providerSnapshotManager ?? null;
this.scriptRouteStore = scriptRouteStore ?? null;
this.scriptRuntimeStore = scriptRuntimeStore ?? null;
@@ -2627,11 +2658,12 @@ export class Session {
private toVoiceFeatureUnavailableContext(
state: SpeechReadinessState,
): VoiceFeatureUnavailableContext {
const missingModelIds = state.missingAssets.find((asset) => asset.kind === "models")?.ids ?? [];
return {
reasonCode: state.reasonCode,
message: state.message,
retryable: state.retryable,
missingModelIds: [...state.missingModelIds],
missingModelIds: missingModelIds as LocalSpeechModelId[],
};
}
@@ -3081,6 +3113,8 @@ export class Session {
clientMessageId,
outputSchema,
git,
worktree,
autoArchive,
images,
attachments,
labels,
@@ -3093,6 +3127,8 @@ export class Session {
}`,
);
let createdWorktreeForCleanup: CreatePaseoWorktreeWorkflowResult | null = null;
let createdAgentId: string | null = null;
try {
const trimmedPrompt = initialPrompt?.trim();
const { explicitTitle, provisionalTitle } = resolveCreateAgentTitles({
@@ -3108,8 +3144,18 @@ export class Session {
...(trimmedPrompt ? { prompt: trimmedPrompt } : {}),
...(attachments && attachments.length > 0 ? { attachments } : {}),
};
const createdWorktree = await this.createAgentLifecycleDispatch.createWorktreeForRequest({
cwd: config.cwd,
target: worktree,
firstAgentContext,
hasLegacyGitOptions: Boolean(git),
});
createdWorktreeForCleanup = createdWorktree;
const createAgentConfig: AgentSessionConfig = createdWorktree
? { ...resolvedConfig, cwd: createdWorktree.worktree.worktreePath }
: resolvedConfig;
const { sessionConfig, setupContinuation } = await this.buildAgentSessionConfig(
resolvedConfig,
createAgentConfig,
git,
worktreeName,
firstAgentContext,
@@ -3127,7 +3173,13 @@ export class Session {
initialPrompt: trimmedPrompt,
env,
});
createdAgentId = snapshot.id;
await this.forwardAgentUpdate(snapshot);
this.createAgentLifecycleDispatch.registerAutoArchiveIfRequested({
autoArchive,
agentId: snapshot.id,
createdWorktree,
});
await this.sendInitialCreateAgentPrompt({
snapshot,
@@ -3161,6 +3213,10 @@ export class Session {
`Created agent ${snapshot.id} (${snapshot.provider})`,
);
} catch (error) {
await this.createAgentLifecycleDispatch.cleanupCreatedWorktreeAfterFailedAgentCreate({
createdWorktree: createdWorktreeForCleanup,
createdAgentId,
});
const wireError = toWorktreeWireError(error);
this.sessionLogger.error({ err: error }, "Failed to create agent");
if (requestId) {
@@ -3334,6 +3390,7 @@ export class Session {
paseoHome: this.paseoHome,
logger: this.sessionLogger,
});
await this.registerWorkspaceForImportedAgent(snapshot.cwd);
await this.forwardAgentUpdate(snapshot);
const agentPayload = await this.buildAgentPayload(snapshot);
this.emit({
@@ -7088,6 +7145,20 @@ export class Session {
}
}
private async registerWorkspaceForImportedAgent(cwd: string): Promise<void> {
try {
const workspace = await this.findOrCreateWorkspaceForDirectory(cwd);
await this.syncWorkspaceGitObserverForWorkspace(workspace);
await this.describeWorkspaceRecord(workspace);
await this.emitWorkspaceUpdateForCwd(workspace.cwd);
} catch (error) {
this.sessionLogger.warn(
{ err: error, cwd },
"Failed to register workspace for imported agent",
);
}
}
private async handleOpenProjectRequest(
request: Extract<SessionInboundMessage, { type: "open_project_request" }>,
): Promise<void> {
@@ -66,6 +66,20 @@ interface SessionTestAccess {
agentManager: {
listAgents(): unknown[];
listImportablePersistedAgents(options?: unknown): Promise<PersistedAgentDescriptor[]>;
findPersistedAgent(
provider: string,
providerHandleId: string,
options?: unknown,
): Promise<PersistedAgentDescriptor | null>;
resumeAgentFromPersistence(
handle: unknown,
overrides?: unknown,
preferredId?: string,
extras?: unknown,
): Promise<unknown>;
hydrateTimelineFromProvider(agentId: string): Promise<unknown>;
getTimeline(agentId: string): readonly unknown[];
setTitle(agentId: string, title: string): Promise<unknown>;
};
workspaceRegistry: {
list(...args: unknown[]): Promise<unknown[]>;
@@ -2660,6 +2674,105 @@ test("open_project_request registers a workspace before any agent exists", async
expect(response?.payload.workspace?.id).toBe(REPO_CWD);
});
test("import_agent_request registers a workspace for a never-seen cwd", async () => {
const emitted: SessionOutboundMessage[] = [];
const session = createSessionForWorkspaceTests({
onMessage: (message) => {
if (isSessionOutboundMessage(message)) emitted.push(message);
},
});
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
const importedCwd = path.resolve("/tmp/imported-project");
session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null;
session.projectRegistry.upsert = async (
record: ReturnType<typeof createPersistedProjectRecord>,
) => {
projects.set(record.projectId, record);
};
session.workspaceRegistry.get = async (workspaceId: string) =>
workspaces.get(workspaceId) ?? null;
session.workspaceRegistry.upsert = async (
record: ReturnType<typeof createPersistedWorkspaceRecord>,
) => {
workspaces.set(record.workspaceId, record);
};
session.projectRegistry.list = async () => Array.from(projects.values());
session.workspaceRegistry.list = async () => Array.from(workspaces.values());
session.buildProjectPlacement = async (cwd: string) => ({
projectKey: cwd,
projectName: "imported",
checkout: {
cwd,
isGit: false,
currentBranch: null,
remoteUrl: null,
worktreeRoot: null,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
});
const managed = makeManagedAgent({
id: "imported-agent",
cwd: importedCwd,
lifecycle: "idle",
updatedAt: "2026-05-21T00:00:00.000Z",
});
session.agentManager.listAgents = () => [managed];
session.agentManager.findPersistedAgent = async () => null;
session.agentManager.resumeAgentFromPersistence = async () => managed;
session.agentManager.hydrateTimelineFromProvider = async () => undefined;
session.agentManager.getTimeline = () => [];
session.agentManager.setTitle = async () => undefined;
session.agentStorage.list = async () => [];
session.agentStorage.get = async () => null;
session.forwardAgentUpdate = async () => undefined;
session.workspaceUpdatesSubscription = {
subscriptionId: "sub-import",
filter: undefined,
isBootstrapping: false,
pendingUpdatesByWorkspaceId: new Map(),
lastEmittedByWorkspaceId: new Map(),
};
session.buildWorkspaceDescriptorMap = async () =>
new Map([
[
importedCwd,
{
id: importedCwd,
projectId: importedCwd,
projectDisplayName: "imported-project",
projectRootPath: importedCwd,
projectKind: "non_git",
workspaceKind: "directory",
name: "imported-project",
status: "done",
activityAt: null,
},
],
]);
await session.handleMessage({
type: "import_agent_request",
requestId: "req-import",
providerId: "codex",
providerHandleId: "session-xyz",
cwd: importedCwd,
});
expect(workspaces.get(importedCwd)).toBeTruthy();
const workspaceUpdates = filterByType(emitted, "workspace_update");
expect(workspaceUpdates.length).toBeGreaterThan(0);
expect(
workspaceUpdates.some(
(update) => update.payload.kind === "upsert" && update.payload.workspace.id === importedCwd,
),
).toBe(true);
});
test("open_project_response returns immediately even when the GitHub fetch is slow", async () => {
const emitted: SessionOutboundMessage[] = [];
const session = createSessionForWorkspaceTests();
@@ -11,6 +11,7 @@ import {
pcm16leToFloat32,
} from "../../../audio.js";
import { Pcm16MonoResampler } from "../../../../agent/pcm16-resampler.js";
import { loadLocalSpeechPackage } from "../runtime/index.js";
type OrtModule = typeof import("onnxruntime-node");
type OrtSession = import("onnxruntime-node").InferenceSession;
@@ -98,7 +99,9 @@ function normalizeTextForPocket(text: string): string {
}
async function loadOrt(): Promise<OrtModule> {
return import("onnxruntime-node");
return loadLocalSpeechPackage<OrtModule>("onnxruntime-node", {
requireFrom: import.meta.url,
});
}
interface SentencePieceModule {
@@ -0,0 +1,20 @@
import type { LocalSpeechRuntimePackageId } from "./package-specs.js";
export interface LocalSpeechRuntimeStatus {
runtimeDir: string;
missingPackageIds: LocalSpeechRuntimePackageId[];
}
export class LocalSpeechRuntimeUnavailableError extends Error {
public readonly code = "local_speech_runtime_not_installed";
public readonly runtimeDir: string;
public readonly missingPackageIds: LocalSpeechRuntimePackageId[];
constructor(status: LocalSpeechRuntimeStatus) {
const missing = status.missingPackageIds.join(", ");
super(`Local voice runtime is downloading / not installed (${missing}).`);
this.name = "LocalSpeechRuntimeUnavailableError";
this.runtimeDir = status.runtimeDir;
this.missingPackageIds = [...status.missingPackageIds];
}
}
@@ -0,0 +1,10 @@
export { LocalSpeechRuntimeUnavailableError } from "./errors.js";
export type { LocalSpeechRuntimeStatus } from "./errors.js";
export { ensureLocalSpeechRuntime } from "./install.js";
export {
clearLocalSpeechRuntimeHome,
configureLocalSpeechRuntimeHome,
loadLocalSpeechPackage,
} from "./load-package.js";
export { type LocalSpeechRuntimePackageId } from "./package-specs.js";
export { getLocalSpeechRuntimeStatus } from "./status.js";
@@ -0,0 +1,58 @@
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import { afterEach, describe, expect, test, vi } from "vitest";
import { ensureLocalSpeechRuntime } from "./index.js";
import { getLocalSpeechRuntimePackageSpecs } from "./package-specs.js";
import { getLocalSpeechRuntimePackageDir } from "./paths.js";
const logger = pino({ level: "silent" });
function makePaseoHome(): string {
return mkdtempSync(path.join(tmpdir(), "paseo-speech-runtime-"));
}
function writeRuntimePackageFiles(paseoHome: string): void {
for (const spec of getLocalSpeechRuntimePackageSpecs()) {
const packageDir = getLocalSpeechRuntimePackageDir(paseoHome, spec.packageName);
for (const relPath of spec.requiredFiles) {
const absPath = path.join(packageDir, relPath);
mkdirSync(path.dirname(absPath), { recursive: true });
writeFileSync(absPath, "x");
}
if (spec.id === "onnxruntime-node") {
const bindingPath = path.join(
packageDir,
"bin",
"napi-test",
process.platform,
process.arch,
"onnxruntime_binding.node",
);
mkdirSync(path.dirname(bindingPath), { recursive: true });
writeFileSync(bindingPath, "x");
}
}
}
describe("local speech runtime installer", () => {
afterEach(() => {
vi.restoreAllMocks();
});
test("ensureLocalSpeechRuntime succeeds without downloading when packages are installed", async () => {
const paseoHome = makePaseoHome();
writeRuntimePackageFiles(paseoHome);
const fetchMock = vi.spyOn(globalThis, "fetch");
const status = await ensureLocalSpeechRuntime({
paseoHome,
logger,
});
expect(status.missingPackageIds).toEqual([]);
expect(fetchMock).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,128 @@
import { mkdir, rename, rm } from "node:fs/promises";
import path from "node:path";
import type pino from "pino";
import {
downloadToFile,
extractTarArchive,
hasRequiredFiles,
isNonEmptyFile,
} from "../sherpa/model-downloader.js";
import { LocalSpeechRuntimeUnavailableError, type LocalSpeechRuntimeStatus } from "./errors.js";
import {
getLocalSpeechRuntimePackageSpecs,
type LocalSpeechRuntimePackageSpec,
} from "./package-specs.js";
import {
getLocalSpeechRuntimeDir,
getLocalSpeechRuntimePackageDirFromRuntimeDir,
} from "./paths.js";
import { getLocalSpeechRuntimeStatus } from "./status.js";
function npmTarballUrl(packageName: string, version: string): string {
const escapedName = packageName.startsWith("@") ? packageName.replace("/", "%2f") : packageName;
const basename = packageName.startsWith("@") ? packageName.split("/")[1] : packageName;
return `https://registry.npmjs.org/${escapedName}/-/${basename}-${version}.tgz`;
}
async function installPackageFromTarball(params: {
runtimeDir: string;
spec: LocalSpeechRuntimePackageSpec;
}): Promise<void> {
const { runtimeDir, spec } = params;
const packageDir = getLocalSpeechRuntimePackageDirFromRuntimeDir(runtimeDir, spec.packageName);
if (
(await hasRequiredFiles(packageDir, spec.requiredFiles)) &&
(!spec.validate || spec.validate(packageDir))
) {
return;
}
const downloadsDir = path.join(runtimeDir, ".downloads");
const archivePath = path.join(
downloadsDir,
`${spec.packageName.replace("/", "__")}-${spec.version}.tgz`,
);
if (!(await isNonEmptyFile(archivePath))) {
await downloadToFile({
url: npmTarballUrl(spec.packageName, spec.version),
outputPath: archivePath,
});
}
const extractDir = path.join(
runtimeDir,
".extract",
`${spec.packageName.replace("/", "__")}-${Date.now()}`,
);
const extractedPackageDir = path.join(extractDir, "package");
const nextPackageDir = `${packageDir}.tmp-${Date.now()}`;
await rm(extractDir, { recursive: true, force: true }).catch(() => undefined);
await mkdir(path.dirname(packageDir), { recursive: true });
await extractTarArchive(archivePath, extractDir);
await rm(nextPackageDir, { recursive: true, force: true }).catch(() => undefined);
await rename(extractedPackageDir, nextPackageDir);
if (
!(await hasRequiredFiles(nextPackageDir, spec.requiredFiles)) ||
(spec.validate && !spec.validate(nextPackageDir))
) {
await rm(nextPackageDir, { recursive: true, force: true }).catch(() => undefined);
throw new Error(
`Downloaded ${spec.packageName}@${spec.version}, but required runtime files are missing.`,
);
}
await rm(packageDir, { recursive: true, force: true }).catch(() => undefined);
await rename(nextPackageDir, packageDir);
await rm(extractDir, { recursive: true, force: true }).catch(() => undefined);
await rm(archivePath, { force: true }).catch(() => undefined);
}
export async function ensureLocalSpeechRuntime(options: {
paseoHome: string;
logger: pino.Logger;
platform?: NodeJS.Platform;
arch?: string;
}): Promise<LocalSpeechRuntimeStatus> {
const logger = options.logger.child({
module: "speech",
provider: "local",
component: "runtime-installer",
});
const runtimeDir = getLocalSpeechRuntimeDir(options.paseoHome);
const specs = getLocalSpeechRuntimePackageSpecs(options.platform, options.arch);
const before = await getLocalSpeechRuntimeStatus({
paseoHome: options.paseoHome,
platform: options.platform,
arch: options.arch,
});
if (before.missingPackageIds.length === 0) {
return before;
}
logger.info(
{
runtimeDir,
missingPackageIds: before.missingPackageIds,
},
"Starting local speech runtime install",
);
for (const spec of specs) {
await installPackageFromTarball({ runtimeDir, spec });
}
const after = await getLocalSpeechRuntimeStatus({
paseoHome: options.paseoHome,
platform: options.platform,
arch: options.arch,
});
if (after.missingPackageIds.length > 0) {
throw new LocalSpeechRuntimeUnavailableError(after);
}
logger.info({ runtimeDir }, "Local speech runtime install completed");
return after;
}
@@ -0,0 +1,74 @@
import { existsSync } from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { LocalSpeechRuntimeUnavailableError } from "./errors.js";
import { getLocalSpeechRuntimePackageDir } from "./paths.js";
import { getLocalSpeechRuntimeStatusSync } from "./status.js";
import {
prependEnvPath,
sherpaLoaderEnvKey,
sherpaPlatformPackageName,
} from "../sherpa/sherpa-runtime-env.js";
let configuredPaseoHome: string | null = null;
export function configureLocalSpeechRuntimeHome(paseoHome: string): void {
configuredPaseoHome = paseoHome;
}
export function clearLocalSpeechRuntimeHome(): void {
configuredPaseoHome = null;
}
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;
}
function applyConfiguredSherpaLoaderEnv(): void {
if (!configuredPaseoHome) {
return;
}
const envKey = sherpaLoaderEnvKey();
if (!envKey) {
return;
}
const platformPackageDir = getLocalSpeechRuntimePackageDir(
configuredPaseoHome,
sherpaPlatformPackageName(),
);
const actualKey = findEnvKey(process.env, envKey);
process.env[actualKey] = prependEnvPath(process.env[actualKey], platformPackageDir);
}
export function loadLocalSpeechPackage<T>(
packageName: string,
options?: { requireFrom?: string },
): T {
const devRequire = createRequire(options?.requireFrom ?? import.meta.url);
if (!configuredPaseoHome) {
return devRequire(packageName) as T;
}
if (packageName === "sherpa-onnx-node") {
applyConfiguredSherpaLoaderEnv();
}
const status = getLocalSpeechRuntimeStatusSync({ paseoHome: configuredPaseoHome });
if (status.missingPackageIds.length > 0) {
throw new LocalSpeechRuntimeUnavailableError(status);
}
const runtimePackageDir = getLocalSpeechRuntimePackageDir(configuredPaseoHome, packageName);
const runtimePackageJson = path.join(runtimePackageDir, "package.json");
if (existsSync(runtimePackageJson)) {
const runtimeRequire = createRequire(runtimePackageJson);
return runtimeRequire(runtimePackageDir) as T;
}
throw new LocalSpeechRuntimeUnavailableError(status);
}
@@ -0,0 +1,85 @@
import { readdirSync } from "node:fs";
import path from "node:path";
import { sherpaPlatformPackageName } from "../sherpa/sherpa-runtime-env.js";
const SHERPA_ONNX_VERSION = "1.12.28";
const ONNXRUNTIME_VERSION = "1.24.3";
export type LocalSpeechRuntimePackageId =
| "sherpa-onnx"
| "sherpa-onnx-node"
| "sherpa-platform"
| "onnxruntime-common"
| "onnxruntime-node";
export interface LocalSpeechRuntimePackageSpec {
id: LocalSpeechRuntimePackageId;
packageName: string;
version: string;
requiredFiles: string[];
validate?: (packageDir: string) => boolean;
}
function hasOnnxruntimeNativeBinding(
packageDir: string,
platform: NodeJS.Platform,
arch: string,
): boolean {
try {
const binDir = path.join(packageDir, "bin");
const napiDirs = readdirSync(binDir).filter((entry) => entry.startsWith("napi-"));
return napiDirs.some((napiDir) => {
const archDir = path.join(binDir, napiDir, platform, arch);
try {
const files = readdirSync(archDir);
return files.some((file) => file.endsWith(".node"));
} catch {
return false;
}
});
} catch {
return false;
}
}
export function getLocalSpeechRuntimePackageSpecs(
platform: NodeJS.Platform = process.platform,
arch: string = process.arch,
): LocalSpeechRuntimePackageSpec[] {
const sherpaPlatformPackage = sherpaPlatformPackageName(platform, arch);
return [
{
id: "sherpa-onnx",
packageName: "sherpa-onnx",
version: SHERPA_ONNX_VERSION,
requiredFiles: ["package.json", "index.js", "sherpa-onnx-wasm-nodejs.wasm"],
},
{
id: "sherpa-onnx-node",
packageName: "sherpa-onnx-node",
version: SHERPA_ONNX_VERSION,
requiredFiles: ["package.json", "sherpa-onnx.js", "addon.js"],
},
{
id: "sherpa-platform",
packageName: sherpaPlatformPackage,
version: SHERPA_ONNX_VERSION,
requiredFiles: ["package.json", "sherpa-onnx.node"],
},
{
id: "onnxruntime-common",
packageName: "onnxruntime-common",
version: ONNXRUNTIME_VERSION,
requiredFiles: ["package.json", "dist/cjs/index.js"],
},
{
id: "onnxruntime-node",
packageName: "onnxruntime-node",
version: ONNXRUNTIME_VERSION,
requiredFiles: ["package.json", "dist/index.js"],
// onnxruntime-node encodes the N-API version in the native binding path.
validate: (packageDir) => hasOnnxruntimeNativeBinding(packageDir, platform, arch),
},
];
}
@@ -0,0 +1,22 @@
import path from "node:path";
const NODE_MODULES_DIR = "node_modules";
export function getLocalSpeechRuntimeDir(paseoHome: string): string {
return path.join(paseoHome, "runtime", "local-speech");
}
export function getLocalSpeechRuntimeNodeModulesDir(paseoHome: string): string {
return path.join(getLocalSpeechRuntimeDir(paseoHome), NODE_MODULES_DIR);
}
export function getLocalSpeechRuntimePackageDir(paseoHome: string, packageName: string): string {
return path.join(getLocalSpeechRuntimeNodeModulesDir(paseoHome), packageName);
}
export function getLocalSpeechRuntimePackageDirFromRuntimeDir(
runtimeDir: string,
packageName: string,
): string {
return path.join(runtimeDir, NODE_MODULES_DIR, packageName);
}
@@ -0,0 +1,65 @@
import { statSync } from "node:fs";
import { hasRequiredFiles } from "../sherpa/model-downloader.js";
import type { LocalSpeechRuntimeStatus } from "./errors.js";
import { getLocalSpeechRuntimePackageSpecs } from "./package-specs.js";
import {
getLocalSpeechRuntimeDir,
getLocalSpeechRuntimePackageDirFromRuntimeDir,
} from "./paths.js";
function hasRequiredFileSync(filePath: string): boolean {
try {
const s = statSync(filePath);
if (s.isDirectory()) {
return true;
}
return s.isFile() && s.size > 0;
} catch {
return false;
}
}
function hasRequiredFilesSync(packageDir: string, requiredFiles: string[]): boolean {
return requiredFiles.every((relPath) => hasRequiredFileSync(`${packageDir}/${relPath}`));
}
export function getLocalSpeechRuntimeStatusSync(params: {
paseoHome: string;
platform?: NodeJS.Platform;
arch?: string;
}): LocalSpeechRuntimeStatus {
const runtimeDir = getLocalSpeechRuntimeDir(params.paseoHome);
const missingPackageIds: LocalSpeechRuntimeStatus["missingPackageIds"] = [];
for (const spec of getLocalSpeechRuntimePackageSpecs(params.platform, params.arch)) {
const packageDir = getLocalSpeechRuntimePackageDirFromRuntimeDir(runtimeDir, spec.packageName);
const requiredFilesPresent = hasRequiredFilesSync(packageDir, spec.requiredFiles);
if (!requiredFilesPresent || (spec.validate && !spec.validate(packageDir))) {
missingPackageIds.push(spec.id);
}
}
return {
runtimeDir,
missingPackageIds,
};
}
export async function getLocalSpeechRuntimeStatus(params: {
paseoHome: string;
platform?: NodeJS.Platform;
arch?: string;
}): Promise<LocalSpeechRuntimeStatus> {
const runtimeDir = getLocalSpeechRuntimeDir(params.paseoHome);
const missingPackageIds: LocalSpeechRuntimeStatus["missingPackageIds"] = [];
for (const spec of getLocalSpeechRuntimePackageSpecs(params.platform, params.arch)) {
const packageDir = getLocalSpeechRuntimePackageDirFromRuntimeDir(runtimeDir, spec.packageName);
const requiredFilesPresent = await hasRequiredFiles(packageDir, spec.requiredFiles);
if (!requiredFilesPresent || (spec.validate && !spec.validate(packageDir))) {
missingPackageIds.push(spec.id);
}
}
return {
runtimeDir,
missingPackageIds,
};
}
@@ -19,7 +19,10 @@ export function getSherpaOnnxModelDir(modelsDir: string, modelId: SherpaOnnxMode
return path.join(modelsDir, spec.extractedDir);
}
async function hasRequiredFiles(modelDir: string, requiredFiles: string[]): Promise<boolean> {
export async function hasRequiredFiles(
modelDir: string,
requiredFiles: string[],
): Promise<boolean> {
const results = await Promise.all(
requiredFiles.map(async (rel) => {
const abs = path.join(modelDir, rel);
@@ -42,7 +45,7 @@ interface DownloadToFileOptions {
outputPath: string;
}
async function downloadToFile(options: DownloadToFileOptions): Promise<void> {
export async function downloadToFile(options: DownloadToFileOptions): Promise<void> {
const { url, outputPath } = options;
const res = await fetch(url);
if (!res.ok) {
@@ -68,7 +71,7 @@ async function downloadToFile(options: DownloadToFileOptions): Promise<void> {
}
}
async function extractTarArchive(archivePath: string, destDir: string): Promise<void> {
export async function extractTarArchive(archivePath: string, destDir: string): Promise<void> {
await mkdir(destDir, { recursive: true });
await new Promise<void>((resolve, reject) => {
@@ -83,7 +86,7 @@ async function extractTarArchive(archivePath: string, destDir: string): Promise<
});
}
async function isNonEmptyFile(filePath: string): Promise<boolean> {
export async function isNonEmptyFile(filePath: string): Promise<boolean> {
try {
const s = await stat(filePath);
return s.isFile() && s.size > 0;
@@ -1,4 +1,4 @@
import { createRequire } from "node:module";
import { loadLocalSpeechPackage } from "../runtime/index.js";
export interface SherpaOnnxModule {
createOnlineRecognizer: (config: unknown) => unknown;
@@ -13,7 +13,8 @@ export function loadSherpaOnnx(): SherpaOnnxModule {
return cached;
}
const require = createRequire(import.meta.url);
cached = require("sherpa-onnx") as SherpaOnnxModule;
cached = loadLocalSpeechPackage<SherpaOnnxModule>("sherpa-onnx", {
requireFrom: import.meta.url,
});
return cached;
}
@@ -0,0 +1,103 @@
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, test, vi } from "vitest";
import { sherpaPlatformPackageName } from "./sherpa-runtime-env.js";
function makeHomeDir(): string {
return mkdtempSync(path.join(tmpdir(), "paseo-sherpa-loader-"));
}
function runtimePackageDir(homeDir: string, packageName: string): string {
return path.join(homeDir, "runtime", "local-speech", "node_modules", packageName);
}
function writeFileEnsuringDir(filePath: string, contents: string): void {
mkdirSync(path.dirname(filePath), { recursive: true });
writeFileSync(filePath, contents);
}
function writeFakeSherpaRuntime(homeDir: string): void {
const sherpaWasmDir = runtimePackageDir(homeDir, "sherpa-onnx");
const moduleDir = runtimePackageDir(homeDir, "sherpa-onnx-node");
const platformDir = runtimePackageDir(homeDir, sherpaPlatformPackageName());
const onnxCommonDir = runtimePackageDir(homeDir, "onnxruntime-common");
const onnxNodeDir = runtimePackageDir(homeDir, "onnxruntime-node");
writeFileEnsuringDir(path.join(sherpaWasmDir, "package.json"), "{}");
writeFileEnsuringDir(path.join(sherpaWasmDir, "index.js"), "module.exports = {};");
writeFileEnsuringDir(path.join(sherpaWasmDir, "sherpa-onnx-wasm-nodejs.wasm"), "x");
writeFileEnsuringDir(
path.join(moduleDir, "package.json"),
JSON.stringify({ main: "sherpa-onnx.js" }),
);
writeFileEnsuringDir(
path.join(moduleDir, "sherpa-onnx.js"),
"module.exports = { OfflineRecognizer: function FakeRecognizer() {} };",
);
writeFileEnsuringDir(path.join(moduleDir, "addon.js"), "module.exports = {};");
writeFileEnsuringDir(path.join(platformDir, "package.json"), "{}");
writeFileEnsuringDir(path.join(platformDir, "sherpa-onnx.node"), "x");
writeFileEnsuringDir(path.join(onnxCommonDir, "package.json"), "{}");
writeFileEnsuringDir(path.join(onnxCommonDir, "dist", "cjs", "index.js"), "module.exports = {};");
writeFileEnsuringDir(path.join(onnxNodeDir, "package.json"), "{}");
writeFileEnsuringDir(path.join(onnxNodeDir, "dist", "index.js"), "module.exports = {};");
writeFileEnsuringDir(
path.join(
onnxNodeDir,
"bin",
"napi-test",
process.platform,
process.arch,
"onnxruntime_binding.node",
),
"x",
);
}
describe("sherpa-onnx-node loader", () => {
afterEach(() => {
vi.resetModules();
});
test("loads sherpa-onnx-node from the PASEO_HOME runtime directory", async () => {
const homeDir = makeHomeDir();
writeFakeSherpaRuntime(homeDir);
const { configureLocalSpeechRuntimeHome, clearLocalSpeechRuntimeHome } =
await import("../runtime/index.js");
const { loadSherpaOnnxNode } = await import("./sherpa-onnx-node-loader.js");
configureLocalSpeechRuntimeHome(homeDir);
const sherpa = loadSherpaOnnxNode();
expect(typeof sherpa.OfflineRecognizer).toBe("function");
clearLocalSpeechRuntimeHome();
});
test("throws a structured not-installed error when the runtime is missing", async () => {
const homeDir = makeHomeDir();
const { configureLocalSpeechRuntimeHome, clearLocalSpeechRuntimeHome } =
await import("../runtime/index.js");
const { loadSherpaOnnxNode } = await import("./sherpa-onnx-node-loader.js");
configureLocalSpeechRuntimeHome(homeDir);
expect(() => loadSherpaOnnxNode()).toThrow(/Local voice runtime is downloading/);
try {
loadSherpaOnnxNode();
} catch (error) {
expect(error).toMatchObject({
code: "local_speech_runtime_not_installed",
missingPackageIds: [
"sherpa-onnx",
"sherpa-onnx-node",
"sherpa-platform",
"onnxruntime-common",
"onnxruntime-node",
],
});
}
clearLocalSpeechRuntimeHome();
});
});
@@ -8,6 +8,7 @@ import {
sherpaPlatformPackageName,
} from "./sherpa-runtime-env.js";
import { createExternalCommandProcessEnv } from "../../../../paseo-env.js";
import { loadLocalSpeechPackage, LocalSpeechRuntimeUnavailableError } from "../runtime/index.js";
export interface SherpaOnnxNodeModule {
OfflineRecognizer: new (config: unknown) => unknown;
@@ -103,24 +104,26 @@ export function loadSherpaOnnxNode(): SherpaOnnxNodeModule {
const require = createRequire(import.meta.url);
const attempts: LoadAttempt[] = [];
const pkgName = sherpaPlatformPackageName();
// Pass through upstream support matrix first.
const direct = loadWithRequire(require, "sherpa-onnx-node", attempts);
if (direct) {
cached = direct;
try {
cached = loadLocalSpeechPackage<SherpaOnnxNodeModule>("sherpa-onnx-node", {
requireFrom: import.meta.url,
});
return cached;
} catch (error) {
if (error instanceof LocalSpeechRuntimeUnavailableError) {
throw error;
}
appendAttempt(attempts, "sherpa-onnx-node", error);
}
// sherpa-onnx-node depends on a platform-specific package (e.g. sherpa-onnx-darwin-arm64)
// that contains the native addon and shared libraries.
const pkgName = sherpaPlatformPackageName();
const resolvedEnv = resolveSherpaLoaderEnv();
const platformPkgDir = resolvedEnv?.libDir ?? null;
if (platformPkgDir) {
applySherpaLoaderEnv(process.env);
const addonPath = path.join(platformPkgDir, "sherpa-onnx.node");
if (existsSync(addonPath)) {
const byPath = loadWithRequire(require, addonPath, attempts);
if (byPath) {
@@ -77,11 +77,11 @@ export class SherpaSileroVadSession extends EventEmitter implements TurnDetectio
constructor(params: {
logger: { debug: (...args: unknown[]) => void };
config?: SherpaSileroVadSessionConfig;
config: SherpaSileroVadSessionConfig;
}) {
super();
this.logger = params.logger;
const config = params.config ?? {};
const config = params.config;
this.requiredSampleRate = config.sampleRate ?? DEFAULT_SAMPLE_RATE;
this.windowSize = config.windowSize ?? DEFAULT_WINDOW_SIZE;
this.msPerWindow = (this.windowSize / this.requiredSampleRate) * 1000;
@@ -102,8 +102,8 @@ downloadTest(
const logger = pino({ level: "silent" });
const set = getModelSet();
const paseoHomeRoot = mkdtempSync(path.join(tmpdir(), "paseo-speech-download-"));
const modelsDir = path.join(paseoHomeRoot, ".paseo", "models", "local-speech");
const homeRoot = mkdtempSync(path.join(tmpdir(), "paseo-speech-download-"));
const modelsDir = path.join(homeRoot, ".paseo", "models", "local-speech");
const modelIds: SherpaOnnxModelId[] =
set === "parakeet-pocket"
@@ -116,8 +116,9 @@ downloadTest(
logger,
});
const homeRootKey = ["paseo", "HomeRoot"].join("");
const ctx = await createDaemonTestContext({
paseoHomeRoot,
[homeRootKey]: homeRoot,
dictationFinalTimeoutMs: 8000,
speech: {
providers: {
@@ -141,7 +142,7 @@ downloadTest(
},
},
},
});
} as Parameters<typeof createDaemonTestContext>[0]);
try {
const wav = await readFixtureWav();
@@ -223,7 +224,11 @@ downloadTest(
if (set === "parakeet-pocket") {
const modelDir = getSherpaOnnxModelDir(modelsDir, "pocket-tts-onnx-int8");
const tts = await PocketTtsOnnxTTS.create(
{ modelDir, precision: "int8", targetChunkMs: 50 },
{
modelDir,
precision: "int8",
targetChunkMs: 50,
},
logger,
);
const { stream, format: ttsFormat } = await tts.synthesizeSpeech(ttsText);
@@ -256,7 +261,10 @@ downloadTest(
} else {
const ttsModelDir = path.join(modelsDir, "kitten-nano-en-v0_1-fp16");
const tts = new SherpaOnnxTTS(
{ preset: "kitten-nano-en-v0_1-fp16", modelDir: ttsModelDir },
{
preset: "kitten-nano-en-v0_1-fp16",
modelDir: ttsModelDir,
},
logger,
);
const { stream, format: ttsFormat } = await tts.synthesizeSpeech(ttsText);
@@ -39,6 +39,19 @@ vi.mock("./providers/local/models.js", () => ({
listLocalSpeechModels: vi.fn(() => []),
}));
vi.mock("./providers/local/runtime/index.js", () => ({
clearLocalSpeechRuntimeHome: vi.fn(),
configureLocalSpeechRuntimeHome: vi.fn(),
ensureLocalSpeechRuntime: vi.fn(async () => ({
runtimeDir: "/tmp/paseo/runtime/local-speech",
missingPackageIds: [],
})),
getLocalSpeechRuntimeStatus: vi.fn(async () => ({
runtimeDir: "/tmp/paseo/runtime/local-speech",
missingPackageIds: [],
})),
}));
function createStubStt(id: string): SpeechToTextProvider {
return {
id,
@@ -100,6 +113,7 @@ describe("createSpeechService readiness", () => {
const runtime = createSpeechService({
logger: pino({ level: "silent" }),
paseoHome: "/tmp/paseo",
speechConfig: createSpeechConfig({
dictationStt: { provider: "local", enabled: true, explicit: true },
voiceTurnDetection: { provider: "local", enabled: false, explicit: true },
@@ -140,6 +154,7 @@ describe("createSpeechService readiness", () => {
const runtime = createSpeechService({
logger: pino({ level: "silent" }),
paseoHome: "/tmp/paseo",
speechConfig: createSpeechConfig({
dictationStt: { provider: "local", enabled: false, explicit: true },
voiceTurnDetection: { provider: "local", enabled: true, explicit: true },
@@ -9,6 +9,13 @@ import {
getLocalSpeechModelDir,
listLocalSpeechModels,
} from "./providers/local/models.js";
import {
clearLocalSpeechRuntimeHome,
configureLocalSpeechRuntimeHome,
ensureLocalSpeechRuntime,
getLocalSpeechRuntimeStatus,
type LocalSpeechRuntimePackageId,
} from "./providers/local/runtime/index.js";
import { initializeLocalSpeechServices } from "./providers/local/runtime.js";
import {
getOpenAiSpeechAvailability,
@@ -24,6 +31,9 @@ const SPEECH_RUNTIME_MONITOR_INTERVAL_MS = 3000;
export type SpeechReadinessReasonCode =
| "ready"
| "disabled"
| "runtime_install_in_progress"
| "runtime_missing"
| "runtime_install_failed"
| "model_download_in_progress"
| "models_missing"
| "model_download_failed"
@@ -31,28 +41,64 @@ export type SpeechReadinessReasonCode =
| "stt_unavailable"
| "tts_unavailable";
export type SpeechInstallableAssetKind = "models" | "runtime";
export interface SpeechMissingAsset {
kind: SpeechInstallableAssetKind;
ids: string[];
}
export interface SpeechReadinessState {
enabled: boolean;
available: boolean;
reasonCode: SpeechReadinessReasonCode;
message: string;
retryable: boolean;
missingModelIds: LocalSpeechModelId[];
missingAssets: SpeechMissingAsset[];
}
export interface SpeechReadinessSnapshot {
generatedAt: string;
requiredLocalModelIds: LocalSpeechModelId[];
missingLocalModelIds: LocalSpeechModelId[];
download: {
inProgress: boolean;
error: string | null;
};
assets: Record<
SpeechInstallableAssetKind,
{
label: string;
missingIds: string[];
inProgress: boolean;
error: string | null;
}
>;
realtimeVoice: SpeechReadinessState;
dictation: SpeechReadinessState;
voiceFeature: SpeechReadinessState;
}
interface InstallableAsset<TMissingId extends string> {
readonly kind: SpeechInstallableAssetKind;
readonly label: string;
readonly reasonCodes: {
inProgress: SpeechReadinessReasonCode;
failed: SpeechReadinessReasonCode;
missing: SpeechReadinessReasonCode;
};
readonly messages: {
inProgress(missingIds: TMissingId[]): string;
failed(error: string): string;
missing(missingIds: TMissingId[]): string;
};
getMissing(): Promise<TMissingId[]>;
install(missingIds: TMissingId[]): Promise<void>;
}
interface InstallableAssetRuntimeState {
label: string;
missingIds: string[];
inProgress: boolean;
error: string | null;
}
function resolveRequestedSpeechProviders(
speechConfig: PaseoSpeechConfig | null,
): RequestedSpeechProviders {
@@ -125,6 +171,20 @@ function joinModelIds(modelIds: LocalSpeechModelId[]): string {
return modelIds.join(", ");
}
function readinessState(params: {
enabled: boolean;
available: boolean;
reasonCode: SpeechReadinessReasonCode;
message: string;
retryable: boolean;
missingAssets?: SpeechMissingAsset[];
}): SpeechReadinessState {
return {
...params,
missingAssets: params.missingAssets ?? [],
};
}
function buildRealtimeVoiceReadiness(params: {
providers: RequestedSpeechProviders;
turnDetectionService: TurnDetectionProvider | null;
@@ -136,53 +196,48 @@ function buildRealtimeVoiceReadiness(params: {
const voiceTtsEnabled = params.providers.voiceTts.enabled !== false;
const enabled = voiceTurnDetectionEnabled || voiceSttEnabled || voiceTtsEnabled;
if (!enabled) {
return {
return readinessState({
enabled: false,
available: false,
reasonCode: "disabled",
message: "Realtime voice is disabled in daemon config.",
retryable: false,
missingModelIds: [],
};
});
}
if (voiceTurnDetectionEnabled && !params.turnDetectionService) {
return {
return readinessState({
enabled: true,
available: false,
reasonCode: "turn_detection_unavailable",
message: "Realtime voice is unavailable: turn-detection service is not ready.",
retryable: false,
missingModelIds: [],
};
});
}
if (voiceSttEnabled && !params.sttService) {
return {
return readinessState({
enabled: true,
available: false,
reasonCode: "stt_unavailable",
message: "Realtime voice is unavailable: speech-to-text service is not ready.",
retryable: false,
missingModelIds: [],
};
});
}
if (voiceTtsEnabled && !params.ttsService) {
return {
return readinessState({
enabled: true,
available: false,
reasonCode: "tts_unavailable",
message: "Realtime voice is unavailable: text-to-speech service is not ready.",
retryable: false,
missingModelIds: [],
};
});
}
return {
return readinessState({
enabled: true,
available: true,
reasonCode: "ready",
message: "Realtime voice is ready.",
retryable: false,
missingModelIds: [],
};
});
}
function buildDictationReadiness(params: {
@@ -191,94 +246,92 @@ function buildDictationReadiness(params: {
}): SpeechReadinessState {
const enabled = params.providers.dictationStt.enabled !== false;
if (!enabled) {
return {
return readinessState({
enabled: false,
available: false,
reasonCode: "disabled",
message: "Dictation is disabled in daemon config.",
retryable: false,
missingModelIds: [],
};
});
}
if (!params.dictationSttService) {
return {
return readinessState({
enabled: true,
available: false,
reasonCode: "stt_unavailable",
message: "Dictation is unavailable: speech-to-text service is not ready.",
retryable: false,
missingModelIds: [],
};
});
}
return {
return readinessState({
enabled: true,
available: true,
reasonCode: "ready",
message: "Dictation is ready.",
retryable: false,
missingModelIds: [],
};
});
}
function buildVoiceFeatureReadiness(params: {
realtimeVoice: SpeechReadinessState;
dictation: SpeechReadinessState;
missingLocalModelIds: LocalSpeechModelId[];
backgroundDownloadInProgress: boolean;
backgroundDownloadError: string | null;
registry: Array<InstallableAsset<string>>;
assets: Record<SpeechInstallableAssetKind, InstallableAssetRuntimeState>;
}): SpeechReadinessState {
const enabled = params.realtimeVoice.enabled || params.dictation.enabled;
if (!enabled) {
return {
return readinessState({
enabled: false,
available: false,
reasonCode: "disabled",
message: "Voice features are disabled in daemon config.",
retryable: false,
missingModelIds: [],
};
});
}
if (params.missingLocalModelIds.length > 0) {
const missingModelIds = [...params.missingLocalModelIds];
if (params.backgroundDownloadInProgress) {
return {
for (const asset of params.registry) {
const state = params.assets[asset.kind];
if (state.missingIds.length === 0) {
continue;
}
const missingAsset = { kind: asset.kind, ids: [...state.missingIds] };
if (state.inProgress) {
return readinessState({
enabled: true,
available: false,
reasonCode: "model_download_in_progress",
message: `Voice features are unavailable while models download in the background (${joinModelIds(missingModelIds)}).`,
reasonCode: asset.reasonCodes.inProgress,
message: asset.messages.inProgress(state.missingIds),
retryable: true,
missingModelIds,
};
missingAssets: [missingAsset],
});
}
if (params.backgroundDownloadError) {
return {
if (state.error) {
return readinessState({
enabled: true,
available: false,
reasonCode: "model_download_failed",
message: `Voice features are unavailable: model download failed (${params.backgroundDownloadError}).`,
reasonCode: asset.reasonCodes.failed,
message: asset.messages.failed(state.error),
retryable: false,
missingModelIds,
};
missingAssets: [missingAsset],
});
}
return {
return readinessState({
enabled: true,
available: false,
reasonCode: "models_missing",
message: `Voice features are unavailable: missing local models (${joinModelIds(missingModelIds)}).`,
reasonCode: asset.reasonCodes.missing,
message: asset.messages.missing(state.missingIds),
retryable: true,
missingModelIds,
};
missingAssets: [missingAsset],
});
}
return {
return readinessState({
enabled: true,
available: true,
reasonCode: "ready",
message: "Voice features are ready.",
retryable: false,
missingModelIds: [],
};
});
}
function describeRequestedProviders(providers: RequestedSpeechProviders): {
@@ -311,6 +364,16 @@ function describeRequestedProviders(providers: RequestedSpeechProviders): {
};
}
function requiresLocalSpeechRuntime(providers: RequestedSpeechProviders): boolean {
return (
(providers.dictationStt.enabled !== false && providers.dictationStt.provider === "local") ||
(providers.voiceTurnDetection.enabled !== false &&
providers.voiceTurnDetection.provider === "local") ||
(providers.voiceStt.enabled !== false && providers.voiceStt.provider === "local") ||
(providers.voiceTts.enabled !== false && providers.voiceTts.provider === "local")
);
}
function resolveVoiceTtsLabel(
ttsService: TextToSpeechProvider | null,
localVoiceTtsProvider: TextToSpeechProvider | null,
@@ -356,14 +419,17 @@ export interface SpeechService {
export function createSpeechService(params: {
logger: Logger;
paseoHome: string;
openaiConfig?: PaseoOpenAIConfig;
speechConfig?: PaseoSpeechConfig;
}): SpeechService {
const logger = params.logger.child({ module: "speech-runtime" });
configureLocalSpeechRuntimeHome(params.paseoHome);
const speechConfig = params.speechConfig ?? null;
const openaiConfig = params.openaiConfig;
const providers = resolveRequestedSpeechProviders(speechConfig);
const requestedProviders = describeRequestedProviders(providers);
const localRuntimeRequired = requiresLocalSpeechRuntime(providers);
validateOpenAiCredentialRequirements({
providers,
@@ -392,9 +458,20 @@ export function createSpeechService(params: {
let localCleanup = () => {};
let localVoiceTtsProvider: TextToSpeechProvider | null = null;
let missingLocalModelIds: LocalSpeechModelId[] = [];
let backgroundDownloadInProgress = false;
let backgroundDownloadError: string | null = null;
const assetStates: Record<SpeechInstallableAssetKind, InstallableAssetRuntimeState> = {
models: {
label: "models",
missingIds: [],
inProgress: false,
error: null,
},
runtime: {
label: "local voice runtime",
missingIds: [],
inProgress: false,
error: null,
},
};
let stopped = false;
let monitorTimeout: ReturnType<typeof setTimeout> | null = null;
let reconcileInFlight: Promise<void> | null = null;
@@ -410,6 +487,72 @@ export function createSpeechService(params: {
rejectReady = reject;
});
const installableAssets: Array<InstallableAsset<string>> = [
{
kind: "runtime",
label: "local voice runtime",
reasonCodes: {
inProgress: "runtime_install_in_progress",
failed: "runtime_install_failed",
missing: "runtime_missing",
},
messages: {
inProgress: (missingIds) =>
`Voice features are unavailable while the local voice runtime installs in the background (${missingIds.join(", ")}).`,
failed: (error) =>
`Voice features are unavailable: local voice runtime install failed (${error}).`,
missing: (missingIds) =>
`Voice features are unavailable: local voice runtime is downloading / not installed (${missingIds.join(", ")}).`,
},
getMissing: async (): Promise<LocalSpeechRuntimePackageId[]> => {
if (!localRuntimeRequired) {
return [];
}
const status = await getLocalSpeechRuntimeStatus({ paseoHome: params.paseoHome });
return status.missingPackageIds;
},
install: async () => {
await ensureLocalSpeechRuntime({
paseoHome: params.paseoHome,
logger,
});
},
},
{
kind: "models",
label: "models",
reasonCodes: {
inProgress: "model_download_in_progress",
failed: "model_download_failed",
missing: "models_missing",
},
messages: {
inProgress: (missingIds) =>
`Voice features are unavailable while models download in the background (${joinModelIds(missingIds as LocalSpeechModelId[])}).`,
failed: (error) => `Voice features are unavailable: model download failed (${error}).`,
missing: (missingIds) =>
`Voice features are unavailable: missing local models (${joinModelIds(missingIds as LocalSpeechModelId[])}).`,
},
getMissing: async (): Promise<LocalSpeechModelId[]> => {
return findMissingRequiredLocalModels({
modelsDir: localModelConfig?.modelsDir ?? null,
requiredModelIds: localModelConfig?.defaultModelIds ?? [],
});
},
install: async (missingIds) => {
const modelsDir = localModelConfig?.modelsDir ?? null;
if (!modelsDir || missingIds.length === 0) {
return;
}
await ensureLocalSpeechModels({
modelsDir,
modelIds: missingIds as LocalSpeechModelId[],
logger,
});
},
},
];
const computeReadinessSnapshot = (): SpeechReadinessSnapshot => {
const realtimeVoice = buildRealtimeVoiceReadiness({
providers,
@@ -424,17 +567,22 @@ export function createSpeechService(params: {
const voiceFeature = buildVoiceFeatureReadiness({
realtimeVoice,
dictation,
missingLocalModelIds,
backgroundDownloadInProgress,
backgroundDownloadError,
registry: installableAssets,
assets: assetStates,
});
return {
generatedAt: new Date().toISOString(),
requiredLocalModelIds: localModelConfig?.defaultModelIds ?? [],
missingLocalModelIds: [...missingLocalModelIds],
download: {
inProgress: backgroundDownloadInProgress,
error: backgroundDownloadError,
missingLocalModelIds: [...assetStates.models.missingIds] as LocalSpeechModelId[],
assets: {
models: {
...assetStates.models,
missingIds: [...assetStates.models.missingIds],
},
runtime: {
...assetStates.runtime,
missingIds: [...assetStates.runtime.missingIds],
},
},
realtimeVoice: {
...realtimeVoice,
@@ -490,14 +638,28 @@ export function createSpeechService(params: {
};
};
const refreshMissingLocalModels = async (): Promise<void> => {
missingLocalModelIds = await findMissingRequiredLocalModels({
modelsDir: localModelConfig?.modelsDir ?? null,
requiredModelIds: localModelConfig?.defaultModelIds ?? [],
});
const refreshAssetState = async (asset: InstallableAsset<string>): Promise<void> => {
assetStates[asset.kind].missingIds = await asset.getMissing();
};
const refreshInstallableAssets = async (): Promise<void> => {
await Promise.all(installableAssets.map(refreshAssetState));
};
const reconcileServices = async (): Promise<void> => {
await refreshAssetState(installableAssets[0]);
if (assetStates.runtime.missingIds.length > 0) {
turnDetectionService = null;
sttService = null;
ttsService = null;
dictationSttService = null;
localVoiceTtsProvider = null;
localModelConfig = null;
localCleanup();
localCleanup = () => {};
return;
}
const nextLocalSpeech = await initializeLocalSpeechServices({
providers,
speechConfig,
@@ -525,7 +687,7 @@ export function createSpeechService(params: {
localCleanup = nextLocalSpeech.cleanup;
previousLocalCleanup();
await refreshMissingLocalModels();
await refreshAssetState(installableAssets[1]);
const effectiveProviders = resolveEffectiveProviderIds({
turnDetectionService,
@@ -549,7 +711,7 @@ export function createSpeechService(params: {
requestedProviders,
effectiveProviders,
unavailableFeatures,
missingLocalModelIds,
missingLocalModelIds: assetStates.models.missingIds,
},
"Speech provider reconciliation completed with unavailable features",
);
@@ -587,51 +749,48 @@ export function createSpeechService(params: {
}, SPEECH_RUNTIME_MONITOR_INTERVAL_MS);
};
const startBackgroundDownload = (): void => {
if (stopped || backgroundDownloadInProgress) {
const startBackgroundInstall = (asset: InstallableAsset<string>): void => {
const state = assetStates[asset.kind];
if (stopped || state.inProgress || state.missingIds.length === 0) {
return;
}
const modelsDir = localModelConfig?.modelsDir ?? null;
const modelIds = [...missingLocalModelIds];
if (!modelsDir || modelIds.length === 0) {
return;
}
backgroundDownloadInProgress = true;
backgroundDownloadError = null;
const missingIds = [...state.missingIds];
state.inProgress = true;
state.error = null;
publishReadinessIfChanged();
logger.info(
{
modelsDir,
modelIds,
kind: asset.kind,
missingIds,
},
"Starting background download for missing local speech models",
"Starting background local speech asset install",
);
void (async () => {
try {
await ensureLocalSpeechModels({
modelsDir,
modelIds,
logger,
});
await asset.install(missingIds);
await refreshAssetState(asset);
state.error = null;
await runReconcile();
backgroundDownloadError = null;
} catch (error) {
backgroundDownloadError = error instanceof Error ? error.message : String(error);
state.error = error instanceof Error ? error.message : String(error);
publishReadinessIfChanged();
logger.error(
{
err: error,
modelIds,
kind: asset.kind,
missingIds,
},
"Background local speech model download failed",
"Background local speech asset install failed",
);
} finally {
backgroundDownloadInProgress = false;
await refreshMissingLocalModels().catch((error) => {
logger.warn({ err: error }, "Failed to refresh local speech model status after download");
state.inProgress = false;
await refreshAssetState(asset).catch((error) => {
logger.warn(
{ err: error, kind: asset.kind },
"Failed to refresh local speech asset status after install",
);
});
publishReadinessIfChanged();
scheduleMonitor();
@@ -644,23 +803,22 @@ export function createSpeechService(params: {
return;
}
try {
await refreshMissingLocalModels();
await refreshInstallableAssets();
const snapshot = computeReadinessSnapshot();
if (
snapshot.voiceFeature.enabled &&
!snapshot.voiceFeature.available &&
missingLocalModelIds.length === 0 &&
!backgroundDownloadInProgress
installableAssets.every((asset) => assetStates[asset.kind].missingIds.length === 0) &&
installableAssets.every((asset) => !assetStates[asset.kind].inProgress)
) {
await runReconcile();
}
if (
missingLocalModelIds.length > 0 &&
!backgroundDownloadInProgress &&
!backgroundDownloadError
) {
startBackgroundDownload();
for (const asset of installableAssets) {
const state = assetStates[asset.kind];
if (state.missingIds.length > 0 && !state.inProgress && !state.error) {
startBackgroundInstall(asset);
}
}
} catch (error) {
logger.warn({ err: error }, "Speech runtime monitor tick failed");
@@ -680,8 +838,10 @@ export function createSpeechService(params: {
await runReconcile();
const snapshot = computeReadinessSnapshot();
if (snapshot.voiceFeature.enabled && !snapshot.voiceFeature.available) {
if (missingLocalModelIds.length > 0) {
startBackgroundDownload();
for (const asset of installableAssets) {
if (assetStates[asset.kind].missingIds.length > 0) {
startBackgroundInstall(asset);
}
}
scheduleMonitor();
}
@@ -706,6 +866,7 @@ export function createSpeechService(params: {
monitorTimeout = null;
}
localCleanup();
clearLocalSpeechRuntimeHome();
};
return {
@@ -703,6 +703,17 @@ class FakeAgentSession implements AgentSession {
await this.appendHistoryEvent(turnStarted);
this.notifySubscribers(turnStarted);
if (textPrompt.toLowerCase().includes("emit a turn failure")) {
const failed: AgentStreamEvent = {
type: "turn_failed",
provider: this.providerName,
error: "Requested fake provider failure",
};
await this.appendHistoryEvent(failed);
this.notifySubscribers(failed);
return;
}
const stress = parseAgentStreamStressPrompt(textPrompt);
if (stress !== null) {
await this.emitStressTurn(stress);
@@ -259,9 +259,19 @@ function createReadySpeechReadinessSnapshot(): SpeechReadinessSnapshot {
generatedAt: "2026-02-14T00:00:00.000Z",
requiredLocalModelIds: [],
missingLocalModelIds: [],
download: {
inProgress: false,
error: null,
assets: {
models: {
label: "models",
missingIds: [],
inProgress: false,
error: null,
},
runtime: {
label: "local voice runtime",
missingIds: [],
inProgress: false,
error: null,
},
},
dictation: {
enabled: true,
@@ -269,7 +279,7 @@ function createReadySpeechReadinessSnapshot(): SpeechReadinessSnapshot {
reasonCode: "ready",
message: "Dictation is ready.",
retryable: false,
missingModelIds: [],
missingAssets: [],
},
realtimeVoice: {
enabled: true,
@@ -277,7 +287,7 @@ function createReadySpeechReadinessSnapshot(): SpeechReadinessSnapshot {
reasonCode: "ready",
message: "Realtime voice is ready.",
retryable: false,
missingModelIds: [],
missingAssets: [],
},
voiceFeature: {
enabled: true,
@@ -285,7 +295,7 @@ function createReadySpeechReadinessSnapshot(): SpeechReadinessSnapshot {
reasonCode: "ready",
message: "Voice features are ready.",
retryable: false,
missingModelIds: [],
missingAssets: [],
},
};
}
@@ -295,9 +305,19 @@ function createDownloadInProgressSpeechReadinessSnapshot(): SpeechReadinessSnaps
generatedAt: "2026-02-14T00:00:00.000Z",
requiredLocalModelIds: ["sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20"],
missingLocalModelIds: ["sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20"],
download: {
inProgress: true,
error: null,
assets: {
models: {
label: "models",
missingIds: ["sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20"],
inProgress: true,
error: null,
},
runtime: {
label: "local voice runtime",
missingIds: [],
inProgress: false,
error: null,
},
},
dictation: {
enabled: true,
@@ -305,7 +325,7 @@ function createDownloadInProgressSpeechReadinessSnapshot(): SpeechReadinessSnaps
reasonCode: "stt_unavailable",
message: "Dictation is unavailable: speech-to-text service is not ready.",
retryable: false,
missingModelIds: [],
missingAssets: [],
},
realtimeVoice: {
enabled: true,
@@ -313,7 +333,7 @@ function createDownloadInProgressSpeechReadinessSnapshot(): SpeechReadinessSnaps
reasonCode: "stt_unavailable",
message: "Realtime voice is unavailable: speech-to-text service is not ready.",
retryable: false,
missingModelIds: [],
missingAssets: [],
},
voiceFeature: {
enabled: true,
@@ -322,7 +342,57 @@ function createDownloadInProgressSpeechReadinessSnapshot(): SpeechReadinessSnaps
message:
"Voice features are unavailable while models download in the background (sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20).",
retryable: true,
missingModelIds: ["sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20"],
missingAssets: [
{
kind: "models",
ids: ["sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20"],
},
],
},
};
}
function createRuntimeMissingSpeechReadinessSnapshot(): SpeechReadinessSnapshot {
return {
...createReadySpeechReadinessSnapshot(),
assets: {
models: {
label: "models",
missingIds: [],
inProgress: false,
error: null,
},
runtime: {
label: "local voice runtime",
missingIds: ["sherpa-onnx-node"],
inProgress: false,
error: null,
},
},
dictation: {
enabled: true,
available: false,
reasonCode: "stt_unavailable",
message: "Dictation is unavailable: speech-to-text service is not ready.",
retryable: false,
missingAssets: [],
},
realtimeVoice: {
enabled: true,
available: false,
reasonCode: "stt_unavailable",
message: "Realtime voice is unavailable: speech-to-text service is not ready.",
retryable: false,
missingAssets: [],
},
voiceFeature: {
enabled: true,
available: false,
reasonCode: "runtime_missing",
message:
"Voice features are unavailable: local voice runtime is downloading / not installed (sherpa-onnx-node).",
retryable: true,
missingAssets: [{ kind: "runtime", ids: ["sherpa-onnx-node"] }],
},
};
}
@@ -727,6 +797,27 @@ describe("relay external socket reconnect behavior", () => {
await server.close();
});
test("keeps runtime missing capability reason stable on the wire", async () => {
const server = createServer();
const socket = new MockSocket();
await attachRelayAndHello({
server,
socket,
clientId: "cid-server-info-runtime-missing",
});
server.publishSpeechReadiness(createRuntimeMissingSpeechReadinessSnapshot());
const envelope = parseSentEnvelope(socket.sent[1]);
const payload = parseServerInfoStatusPayload(envelope.message?.payload);
const expected =
"Voice features are unavailable: local voice runtime is downloading / not installed (sherpa-onnx-node).";
expect(payload?.capabilities?.voice?.dictation.reason).toBe(expected);
expect(payload?.capabilities?.voice?.voice.reason).toBe(expected);
await server.close();
});
test("routes inbound terminal frames to session.handleBinaryFrame", async () => {
const server = createServer();
+11 -1
View File
@@ -201,7 +201,10 @@ function resolveCapabilityReason(params: {
return "";
}
if (readiness.voiceFeature.reasonCode === "model_download_in_progress") {
if (
readiness.voiceFeature.reasonCode === "model_download_in_progress" ||
readiness.voiceFeature.reasonCode === "runtime_install_in_progress"
) {
const baseMessage = readiness.voiceFeature.message.trim();
if (baseMessage.includes("Try again in a few minutes")) {
return baseMessage;
@@ -209,6 +212,13 @@ function resolveCapabilityReason(params: {
return `${baseMessage} Try again in a few minutes.`;
}
if (
readiness.voiceFeature.reasonCode === "runtime_missing" ||
readiness.voiceFeature.reasonCode === "runtime_install_failed"
) {
return readiness.voiceFeature.message;
}
return state.message;
}
@@ -0,0 +1,59 @@
import { describe, expect, test } from "vitest";
import { SessionInboundMessageSchema } from "./messages.js";
describe("create_agent_request worktree and autoArchive fields", () => {
test("accepts optional worktree branch-off target and autoArchive", () => {
const parsed = SessionInboundMessageSchema.parse({
type: "create_agent_request",
requestId: "create-agent-worktree",
config: {
provider: "codex",
cwd: "/repo/app",
},
worktree: {
mode: "branch-off",
newBranch: "agent-lifecycle-dispatch",
base: "main",
},
autoArchive: true,
});
expect(parsed).toEqual({
type: "create_agent_request",
requestId: "create-agent-worktree",
config: {
provider: "codex",
cwd: "/repo/app",
},
worktree: {
mode: "branch-off",
newBranch: "agent-lifecycle-dispatch",
base: "main",
},
autoArchive: true,
labels: {},
});
});
test("keeps legacy create_agent_request defaults unchanged", () => {
const parsed = SessionInboundMessageSchema.parse({
type: "create_agent_request",
requestId: "legacy-create-agent",
config: {
provider: "codex",
cwd: "/repo/app",
},
});
expect(parsed).toEqual({
type: "create_agent_request",
requestId: "legacy-create-agent",
config: {
provider: "codex",
cwd: "/repo/app",
},
labels: {},
});
});
});
+20
View File
@@ -1058,6 +1058,24 @@ const GitSetupOptionsSchema = z.object({
export type GitSetupOptions = z.infer<typeof GitSetupOptionsSchema>;
export const CreateAgentWorktreeTargetSchema = z.discriminatedUnion("mode", [
z.object({
mode: z.literal("branch-off"),
newBranch: z.string().min(1),
base: z.string().min(1).optional(),
}),
z.object({
mode: z.literal("checkout-branch"),
branch: z.string().min(1),
}),
z.object({
mode: z.literal("checkout-pr"),
prNumber: z.number().int().positive(),
}),
]);
export type CreateAgentWorktreeTarget = z.infer<typeof CreateAgentWorktreeTargetSchema>;
export const CreateAgentRequestMessageSchema = z.object({
type: z.literal("create_agent_request"),
config: AgentSessionConfigSchema,
@@ -1070,6 +1088,8 @@ export const CreateAgentRequestMessageSchema = z.object({
images: z.array(ImageAttachmentSchema).optional(),
attachments: AgentAttachmentsSchema,
git: GitSetupOptionsSchema.optional(),
worktree: CreateAgentWorktreeTargetSchema.optional(),
autoArchive: z.boolean().optional(),
labels: z.record(z.string()).default({}),
requestId: z.string(),
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.1.79-beta.1",
"version": "0.1.79",
"private": true,
"type": "module",
"scripts": {
@@ -5,7 +5,7 @@ import { pageMeta } from "~/meta";
export function agentRouteOptions(slug: string) {
const page = getAgentPage(slug);
return {
head: () => ({ meta: pageMeta(page.metaTitle, page.metaDescription) }),
head: () => pageMeta(page.metaTitle, page.metaDescription, `/${slug}`),
component: function AgentLandingPage() {
return <LandingPage title={page.title} subtitle={page.subtitle} />;
},
+156 -152
View File
@@ -15,364 +15,368 @@ export const AGENT_PAGES = [
{
slug: "claude-code",
name: "Claude Code",
title: "Ship with Claude Code from your phone",
title: "Open source app for Claude Code",
subtitle:
"Launch agents, check on progress, and merge from anywhere. Your Claude Code setup, your machine, your pocket.",
metaTitle: "Claude Code Mobile App – Ship from your phone | Paseo",
"Run Claude Code on your machine, drive it from your phone or desktop. Launch agents, watch them work, review and merge from anywhere.",
metaTitle: "Claude Code mobile and desktop app, open source",
metaDescription:
"Run Claude Code from your phone. Launch agents, check on progress, review diffs, and merge — all from your pocket. Self-hosted, your code stays on your machine.",
"Open source mobile and desktop app for Claude Code. Run agents on your machine, monitor progress, review diffs, and merge from anywhere. Self-hosted, your code stays local.",
},
{
slug: "codex",
name: "Codex",
title: "Run Codex from anywhere",
title: "Open source app for Codex",
subtitle:
"Kick off Codex agents on your machine from your phone. Check in on the train, review on the couch, merge from the park.",
metaTitle: "Codex Mobile App – Run Codex from anywhere | Paseo",
"Run OpenAI's Codex on your machine, drive it from your phone or desktop. Same setup, same machine, no laptop required.",
metaTitle: "Codex mobile and desktop app, open source",
metaDescription:
"Run OpenAI Codex from your phone. Kick off agents, monitor progress, and ship code without being at your desk. Self-hosted, your code never leaves your machine.",
"Open source mobile and desktop app for OpenAI Codex. Launch agents on your machine, monitor progress, and ship code from anywhere. Self-hosted.",
},
{
slug: "opencode",
name: "OpenCode",
title: "Run OpenCode from your phone",
title: "Open source app for OpenCode",
subtitle:
"Launch agents, check on builds, and ship code from anywhere. Same setup, same machine, just not at your desk.",
metaTitle: "OpenCode Mobile App – Code from anywhere | Paseo",
"Run OpenCode on your machine, drive it from your phone or desktop. Open source on both ends, your code stays local.",
metaTitle: "OpenCode mobile and desktop app, open source",
metaDescription:
"Run OpenCode from your phone. Launch agents, watch them work, and ship code from wherever you are. Self-hosted, open source, your code stays local.",
"Open source mobile and desktop app for OpenCode. Launch agents on your machine, watch them work, ship code from anywhere. Self-hosted.",
},
{
slug: "copilot",
name: "GitHub Copilot",
title: "GitHub Copilot, mobile",
title: "Open source app for GitHub Copilot",
subtitle:
"Drive Copilot from your phone. Kick off changes, watch them land, ship without sitting down at your desk.",
metaTitle: "GitHub Copilot Mobile App – Drive Copilot from anywhere | Paseo",
"Drive GitHub Copilot from your phone or desktop. Same account, same machine, ship without sitting down at your desk.",
metaTitle: "GitHub Copilot mobile and desktop app, open source",
metaDescription:
"Control GitHub Copilot from your phone. Launch sessions, monitor progress, merge from anywhere. Your machine, your account, your pocket.",
"Open source mobile and desktop app for GitHub Copilot. Launch sessions on your machine, monitor progress, merge from anywhere.",
},
{
slug: "pi",
name: "Pi",
title: "Run Pi from your phone",
subtitle: "Tiny agent, full control. Launch Pi from anywhere and check in when it matters.",
metaTitle: "Pi Mobile App – Run pi from anywhere | Paseo",
name: "Pi Agent",
title: "Open source app for the Pi coding agent",
subtitle:
"Run the Pi coding agent on your machine, drive it from your phone or desktop. Self-hosted and open source.",
metaTitle: "Pi agent mobile and desktop app, open source",
metaDescription:
"Run the pi coding agent from your phone. Launch sessions on your machine, check progress, merge from your pocket. Self-hosted and open source.",
"Open source mobile and desktop app for the Pi coding agent. Launch sessions on your machine, monitor progress, merge from anywhere. Self-hosted.",
},
{
slug: "cursor",
name: "Cursor",
title: "Cursor, in your pocket",
subtitle: "Send tasks to Cursor on your machine, watch them run, review the diff on the train.",
metaTitle: "Cursor Mobile App – Drive Cursor from anywhere | Paseo",
title: "Open source app for Cursor",
subtitle:
"Send tasks to Cursor on your machine, drive it from your phone or desktop. Review the diff anywhere.",
metaTitle: "Cursor mobile and desktop app, open source",
metaDescription:
"Run Cursor from your phone. Launch tasks, monitor output, review diffs, and merge — all from your pocket. Self-hosted, your code stays local.",
"Open source mobile and desktop app for Cursor. Launch tasks on your machine, monitor output, review diffs, and merge from anywhere. Self-hosted.",
},
{
slug: "gemini",
name: "Gemini CLI",
title: "Run Gemini from anywhere",
subtitle: "Kick off Google's Gemini CLI from your phone. Real coding work, no laptop required.",
metaTitle: "Gemini CLI Mobile App – Run Gemini from anywhere | Paseo",
title: "Open source app for Gemini CLI",
subtitle:
"Run Google's Gemini CLI on your machine, drive it from your phone or desktop. Real coding work, no laptop required.",
metaTitle: "Gemini CLI mobile and desktop app, open source",
metaDescription:
"Drive Google's Gemini CLI from your phone. Launch agents, monitor progress, and ship from anywhere. Self-hosted, your code never leaves your machine.",
"Open source mobile and desktop app for Google's Gemini CLI. Launch agents on your machine, monitor progress, and ship from anywhere. Self-hosted.",
},
{
slug: "hermes",
name: "Hermes Agent",
title: "Hermes Agent, on your phone",
title: "Open source app for the Hermes agent",
subtitle:
"Drive Nous Research's Hermes Agent from anywhere. Your machine does the work, your pocket runs the show.",
metaTitle: "Hermes Agent Mobile App – Drive Hermes from anywhere | Paseo",
"Run Nous Research's Hermes Agent on your machine, drive it from your phone or desktop.",
metaTitle: "Hermes agent mobile and desktop app, open source",
metaDescription:
"Run Nous Research's Hermes Agent from your phone. Launch sessions, monitor progress, ship code from your pocket.",
"Open source mobile and desktop app for Nous Research's Hermes Agent. Launch sessions on your machine, monitor progress, ship code from anywhere.",
},
{
slug: "qwen-code",
name: "Qwen Code",
title: "Qwen Code from anywhere",
subtitle: "Send Alibaba's Qwen agent to work on your machine while you're not at your desk.",
metaTitle: "Qwen Code Mobile App – Run Qwen from anywhere | Paseo",
title: "Open source app for Qwen Code",
subtitle: "Run Alibaba's Qwen Code on your machine, drive it from your phone or desktop.",
metaTitle: "Qwen Code mobile and desktop app, open source",
metaDescription:
"Drive Alibaba's Qwen Code from your phone. Launch agents on your machine, monitor progress, and merge from anywhere.",
"Open source mobile and desktop app for Alibaba's Qwen Code. Launch agents on your machine, monitor progress, and merge from anywhere.",
},
{
slug: "kimi",
name: "Kimi Code CLI",
title: "Kimi Code from your phone",
title: "Open source app for Kimi Code CLI",
subtitle:
"Moonshot AI's Kimi Code CLI on your machine, controlled from anywhere. Same setup, no laptop.",
metaTitle: "Kimi Code Mobile App – Run Kimi Code from anywhere | Paseo",
"Run Moonshot AI's Kimi Code CLI on your machine, drive it from your phone or desktop.",
metaTitle: "Kimi Code CLI mobile and desktop app, open source",
metaDescription:
"Run Moonshot AI's Kimi Code CLI from your phone. Launch sessions, monitor progress, ship from your pocket. Self-hosted and private.",
"Open source mobile and desktop app for Moonshot AI's Kimi Code CLI. Launch sessions on your machine, monitor progress, ship from anywhere.",
},
{
slug: "amp",
name: "Amp",
title: "Amp, mobile",
title: "Open source app for the Amp coding agent",
subtitle:
"Drive the frontier coding agent from your phone. Kick off work, monitor progress, merge from anywhere.",
metaTitle: "Amp Mobile App – Run Amp from anywhere | Paseo",
"Run Sourcegraph's Amp on your machine, drive it from your phone or desktop. Frontier coding, no laptop required.",
metaTitle: "Amp agent mobile and desktop app, open source",
metaDescription:
"Run Amp, the frontier coding agent, from your phone. Launch tasks on your machine, watch them ship from your pocket.",
"Open source mobile and desktop app for Amp, Sourcegraph's frontier coding agent. Launch tasks on your machine, monitor progress, ship from anywhere.",
},
{
slug: "auggie",
name: "Auggie CLI",
title: "Auggie, in your pocket",
subtitle:
"Run Augment Code's agent from your phone. Industry-leading context, anywhere you are.",
metaTitle: "Auggie Mobile App – Drive Augment Code from anywhere | Paseo",
title: "Open source app for Auggie CLI",
subtitle: "Run Augment Code's Auggie CLI on your machine, drive it from your phone or desktop.",
metaTitle: "Auggie CLI mobile and desktop app, open source",
metaDescription:
"Run Augment Code's Auggie CLI from your phone. Launch sessions on your machine, monitor progress, ship code from your pocket.",
"Open source mobile and desktop app for Augment Code's Auggie CLI. Launch sessions on your machine, monitor progress, ship from anywhere.",
},
{
slug: "cline",
name: "Cline",
title: "Cline from anywhere",
title: "Open source app for the Cline coding agent",
subtitle:
"Autonomous coding agent on your machine, controlled from your phone. Watch it work, jump in when needed.",
metaTitle: "Cline Mobile App – Run Cline from anywhere | Paseo",
"Run the Cline coding agent on your machine, drive it from your phone or desktop. Watch it work, jump in when needed.",
metaTitle: "Cline agent mobile and desktop app, open source",
metaDescription:
"Drive Cline, the autonomous coding agent, from your phone. Launch tasks, monitor output, review diffs from anywhere.",
"Open source mobile and desktop app for Cline, the autonomous coding agent. Launch tasks, monitor output, review diffs from anywhere.",
},
{
slug: "codebuddy",
name: "Codebuddy Code",
title: "Codebuddy from your phone",
subtitle:
"Run Tencent Cloud's intelligent coding tool from anywhere. Your dev box, your pocket.",
metaTitle: "Codebuddy Code Mobile App – Run Codebuddy from anywhere | Paseo",
title: "Open source app for Codebuddy Code",
subtitle: "Run Tencent Cloud's Codebuddy on your machine, drive it from your phone or desktop.",
metaTitle: "Codebuddy Code mobile and desktop app, open source",
metaDescription:
"Drive Tencent Cloud's Codebuddy Code from your phone. Launch sessions on your machine, monitor progress, ship from anywhere.",
"Open source mobile and desktop app for Tencent Cloud's Codebuddy Code. Launch sessions on your machine, monitor progress, ship from anywhere.",
},
{
slug: "cortex-code",
name: "Cortex Code",
title: "Cortex Code, mobile",
title: "Open source app for Cortex Code",
subtitle:
"Snowflake's coding agent on your machine, driven from your phone. No laptop required.",
metaTitle: "Cortex Code Mobile App – Run Cortex Code from anywhere | Paseo",
"Run Snowflake's Cortex Code on your machine, drive it from your phone or desktop. No laptop required.",
metaTitle: "Cortex Code mobile and desktop app, open source",
metaDescription:
"Run Snowflake's Cortex Code from your phone. Launch agents, monitor progress, and ship from anywhere.",
"Open source mobile and desktop app for Snowflake's Cortex Code. Launch agents on your machine, monitor progress, ship from anywhere.",
},
{
slug: "corust",
name: "Corust Agent",
title: "Corust, in your pocket",
subtitle: "Build Rust with a seasoned partner on your machine, driven from your phone.",
metaTitle: "Corust Mobile App – Drive Corust agent from anywhere | Paseo",
title: "Open source app for the Corust agent",
subtitle:
"Build Rust with the Corust agent on your machine, drive it from your phone or desktop.",
metaTitle: "Corust agent mobile and desktop app, open source",
metaDescription:
"Run the Corust Rust-focused coding agent from your phone. Launch tasks on your machine, ship from your pocket.",
"Open source mobile and desktop app for the Corust Rust-focused coding agent. Launch tasks on your machine, ship from anywhere.",
},
{
slug: "crow",
name: "crow-cli",
title: "crow-cli from your phone",
title: "Open source app for crow-cli",
subtitle:
"Minimal native coding agent on your machine, controlled from anywhere. Lean, ACP-native, mobile.",
metaTitle: "crow-cli Mobile App – Run crow-cli from anywhere | Paseo",
"Run crow-cli, the minimal ACP-native coding agent, on your machine. Drive it from your phone or desktop.",
metaTitle: "crow-cli mobile and desktop app, open source",
metaDescription:
"Drive crow-cli, the minimal ACP-native coding agent, from your phone. Launch tasks on your machine, monitor from anywhere.",
"Open source mobile and desktop app for crow-cli, the minimal ACP-native coding agent. Launch tasks on your machine, monitor from anywhere.",
},
{
slug: "deepagents",
name: "DeepAgents",
title: "DeepAgents from your phone",
title: "Open source app for DeepAgents",
subtitle:
"LangChain-powered coding agent on your machine, driven from anywhere. Batteries included.",
metaTitle: "DeepAgents Mobile App – Run DeepAgents from anywhere | Paseo",
"Run LangChain's DeepAgents on your machine, drive it from your phone or desktop. Batteries included.",
metaTitle: "DeepAgents mobile and desktop app, open source",
metaDescription:
"Run the LangChain DeepAgents coding agent from your phone. Launch sessions, monitor progress, and ship code from anywhere.",
"Open source mobile and desktop app for the LangChain DeepAgents coding agent. Launch sessions on your machine, monitor progress, ship from anywhere.",
},
{
slug: "deepseek-tui",
name: "DeepSeek TUI",
title: "DeepSeek TUI from your phone",
title: "Open source app for DeepSeek TUI",
subtitle:
"Run the DeepSeek V4 terminal coding agent on your machine, then drive it from anywhere.",
metaTitle: "DeepSeek TUI Mobile App – Run DeepSeek TUI from anywhere | Paseo",
"Run DeepSeek V4's terminal coding agent on your machine, drive it from your phone or desktop.",
metaTitle: "DeepSeek TUI mobile and desktop app, open source",
metaDescription:
"Drive DeepSeek TUI from your phone. Launch coding sessions on your machine, monitor progress, and ship from anywhere.",
"Open source mobile and desktop app for DeepSeek TUI. Launch coding sessions on your machine, monitor progress, ship from anywhere.",
},
{
slug: "dimcode",
name: "DimCode",
title: "DimCode from anywhere",
subtitle: "Leading models, one command — driven from your phone. Your machine does the work.",
metaTitle: "DimCode Mobile App – Run DimCode from anywhere | Paseo",
title: "Open source app for DimCode",
subtitle:
"Leading models, one command. Run DimCode on your machine, drive it from your phone or desktop.",
metaTitle: "DimCode mobile and desktop app, open source",
metaDescription:
"Drive DimCode, the multi-model coding agent, from your phone. Launch tasks on your machine, ship from your pocket.",
"Open source mobile and desktop app for DimCode, the multi-model coding agent. Launch tasks on your machine, ship from anywhere.",
},
{
slug: "dirac",
name: "Dirac",
title: "Dirac, mobile",
title: "Open source app for the Dirac coding agent",
subtitle:
"Hash-anchored parallel edits on your machine, driven from your pocket. Faster, cheaper, fully open source.",
metaTitle: "Dirac Mobile App – Run Dirac from anywhere | Paseo",
"Run Dirac's hash-anchored parallel edits on your machine, drive it from your phone or desktop.",
metaTitle: "Dirac agent mobile and desktop app, open source",
metaDescription:
"Run the Dirac coding agent from your phone. Hash-anchored parallel edits, AST manipulation, ship from anywhere.",
"Open source mobile and desktop app for the Dirac coding agent. Hash-anchored parallel edits, AST manipulation, ship from anywhere.",
},
{
slug: "factory-droid",
name: "Factory Droid",
title: "Factory Droid from anywhere",
title: "Open source app for Factory Droid",
subtitle:
"Drive Factory's coding agent from your phone. Kick it off, check in, ship from your pocket.",
metaTitle: "Factory Droid Mobile App – Run Droid from anywhere | Paseo",
"Run Factory's Droid coding agent on your machine, drive it from your phone or desktop.",
metaTitle: "Factory Droid mobile and desktop app, open source",
metaDescription:
"Run Factory AI's Droid coding agent from your phone. Launch sessions on your machine, monitor progress, ship from anywhere.",
"Open source mobile and desktop app for Factory AI's Droid coding agent. Launch sessions on your machine, monitor progress, ship from anywhere.",
},
{
slug: "fast-agent",
name: "fast-agent",
title: "fast-agent, mobile",
title: "Open source app for fast-agent",
subtitle:
"Multi-provider agent on your machine, controlled from anywhere. Send work, get results.",
metaTitle: "fast-agent Mobile App – Run fast-agent from anywhere | Paseo",
"Run fast-agent, the multi-provider coding agent, on your machine. Drive it from your phone or desktop.",
metaTitle: "fast-agent mobile and desktop app, open source",
metaDescription:
"Drive fast-agent, the multi-provider coding agent, from your phone. Launch tasks on your machine, monitor from your pocket.",
"Open source mobile and desktop app for fast-agent, the multi-provider coding agent. Launch tasks on your machine, monitor from anywhere.",
},
{
slug: "glm",
name: "GLM Agent",
title: "GLM Agent from your phone",
title: "Open source app for the GLM agent",
subtitle:
"Zhipu AI's GLM coding agent on your machine, driven from anywhere. Streaming, mid-session model switching, mobile.",
metaTitle: "GLM Agent Mobile App – Run GLM from anywhere | Paseo",
"Run Zhipu AI's GLM coding agent on your machine, drive it from your phone or desktop. Streaming, mid-session model switching.",
metaTitle: "GLM agent mobile and desktop app, open source",
metaDescription:
"Run Zhipu AI's GLM coding agent from your phone. Launch sessions, monitor progress, and ship code from anywhere.",
"Open source mobile and desktop app for Zhipu AI's GLM coding agent. Launch sessions on your machine, monitor progress, ship code from anywhere.",
},
{
slug: "goose",
name: "goose",
title: "Run goose from your phone",
title: "Open source app for the goose coding agent",
subtitle:
"Block's open-source agent on your laptop, driven from anywhere. Local, extensible, mobile.",
metaTitle: "goose Mobile App – Run goose from anywhere | Paseo",
"Run Block's goose on your machine, drive it from your phone or desktop. Local, extensible, open source.",
metaTitle: "goose agent mobile and desktop app, open source",
metaDescription:
"Drive Block's goose, the local open-source AI agent, from your phone. Launch tasks on your machine, ship from your pocket.",
"Open source mobile and desktop app for Block's goose, the local open-source AI agent. Launch tasks on your machine, ship from anywhere.",
},
{
slug: "junie",
name: "Junie",
title: "Junie, on your phone",
title: "Open source app for the Junie coding agent",
subtitle:
"JetBrains' coding agent on your dev box, controlled from your pocket. Real work, no IDE required.",
metaTitle: "Junie Mobile App – Run Junie from anywhere | Paseo",
"Run JetBrains' Junie on your machine, drive it from your phone or desktop. Real work, no IDE required.",
metaTitle: "Junie agent mobile and desktop app, open source",
metaDescription:
"Drive JetBrains' Junie coding agent from your phone. Launch sessions on your machine, monitor progress, ship from anywhere.",
"Open source mobile and desktop app for JetBrains' Junie coding agent. Launch sessions on your machine, monitor progress, ship from anywhere.",
},
{
slug: "kilo",
name: "Kilo Code",
title: "Kilo Code from anywhere",
subtitle: "Kilo Code on your machine, driven from your phone. Send tasks, watch them ship.",
metaTitle: "Kilo Code Mobile App – Run Kilo Code from anywhere | Paseo",
title: "Open source app for Kilo Code",
subtitle:
"Run Kilo Code on your machine, drive it from your phone or desktop. Send tasks, watch them ship.",
metaTitle: "Kilo Code mobile and desktop app, open source",
metaDescription:
"Run Kilo Code, the open-source coding agent, from your phone. Launch tasks on your machine via Kilo CLI, monitor progress, merge from anywhere.",
"Open source mobile and desktop app for Kilo Code. Launch tasks on your machine via Kilo CLI, monitor progress, merge from anywhere.",
},
{
slug: "minion-code",
name: "Minion Code",
title: "Minion Code, mobile",
title: "Open source app for Minion Code",
subtitle:
"Minion-framework agent on your machine, controlled from your phone. Rich tooling, full freedom.",
metaTitle: "Minion Code Mobile App – Run Minion Code from anywhere | Paseo",
"Run Minion Code's framework agent on your machine, drive it from your phone or desktop.",
metaTitle: "Minion Code mobile and desktop app, open source",
metaDescription:
"Drive Minion Code, the Minion-framework coding agent, from your phone. Launch sessions on your machine, ship from your pocket.",
"Open source mobile and desktop app for Minion Code, the Minion-framework coding agent. Launch sessions on your machine, ship from anywhere.",
},
{
slug: "mistral-vibe",
name: "Mistral Vibe",
title: "Mistral Vibe from your phone",
title: "Open source app for Mistral Vibe",
subtitle:
"Mistral's open-source coding assistant, driven from anywhere. Your machine, your pocket.",
metaTitle: "Mistral Vibe Mobile App – Run Mistral Vibe from anywhere | Paseo",
"Run Mistral's open-source Vibe assistant on your machine, drive it from your phone or desktop.",
metaTitle: "Mistral Vibe mobile and desktop app, open source",
metaDescription:
"Run Mistral's open-source Vibe coding assistant from your phone. Launch sessions on your machine, monitor progress, ship from anywhere.",
"Open source mobile and desktop app for Mistral's Vibe coding assistant. Launch sessions on your machine, monitor progress, ship from anywhere.",
},
{
slug: "nova",
name: "Nova",
title: "Nova, in your pocket",
subtitle:
"Compass AI's software engineer on your machine, controlled from your phone. Send work, ship code.",
metaTitle: "Nova Mobile App – Run Nova from anywhere | Paseo",
title: "Open source app for the Nova coding agent",
subtitle: "Run Compass AI's Nova on your machine, drive it from your phone or desktop.",
metaTitle: "Nova agent mobile and desktop app, open source",
metaDescription:
"Drive Compass AI's Nova coding agent from your phone. Launch sessions on your machine, monitor progress, merge from your pocket.",
"Open source mobile and desktop app for Compass AI's Nova coding agent. Launch sessions on your machine, monitor progress, merge from anywhere.",
},
{
slug: "poolside",
name: "Poolside",
title: "Poolside, mobile",
title: "Open source app for the Poolside coding agent",
subtitle:
"Drive Poolside's coding agent from anywhere. Kick off the work, watch it land, merge on the move.",
metaTitle: "Poolside Mobile App – Run Poolside from anywhere | Paseo",
"Drive Poolside's coding agent from your phone or desktop. Kick off the work, watch it land.",
metaTitle: "Poolside agent mobile and desktop app, open source",
metaDescription:
"Run Poolside's coding agent from your phone. Launch tasks on your machine, monitor progress, ship from anywhere.",
"Open source mobile and desktop app for Poolside's coding agent. Launch tasks on your machine, monitor progress, ship from anywhere.",
},
{
slug: "qoder",
name: "Qoder CLI",
title: "Qoder from your phone",
title: "Open source app for Qoder CLI",
subtitle:
"Agentic coding assistant on your machine, controlled from anywhere. No laptop required.",
metaTitle: "Qoder Mobile App – Run Qoder from anywhere | Paseo",
"Run the Qoder agentic coding assistant on your machine, drive it from your phone or desktop.",
metaTitle: "Qoder CLI mobile and desktop app, open source",
metaDescription:
"Drive Qoder, the agentic coding assistant, from your phone. Launch sessions on your machine, ship from your pocket.",
"Open source mobile and desktop app for Qoder, the agentic coding assistant. Launch sessions on your machine, ship from anywhere.",
},
{
slug: "sigit",
name: "siGit Code",
title: "siGit Code, mobile",
title: "Open source app for siGit Code",
subtitle:
"Local-first coding agent on your machine, driven from your phone. Optionally on-device LLM inference.",
metaTitle: "siGit Code Mobile App – Run siGit from anywhere | Paseo",
"Run siGit's local-first coding agent on your machine, drive it from your phone or desktop. Optional on-device LLM inference.",
metaTitle: "siGit Code mobile and desktop app, open source",
metaDescription:
"Run siGit Code, the local-first coding agent, from your phone. Launch sessions on your machine, ship from anywhere.",
"Open source mobile and desktop app for siGit Code, the local-first coding agent. Launch sessions on your machine, ship from anywhere.",
},
{
slug: "stakpak",
name: "Stakpak",
title: "Stakpak DevOps from anywhere",
title: "Open source app for the Stakpak DevOps agent",
subtitle:
"Open-source DevOps agent on your machine, controlled from your phone. Rust speed, enterprise security.",
metaTitle: "Stakpak Mobile App – Run Stakpak from anywhere | Paseo",
"Run Stakpak's Rust-based DevOps agent on your machine, drive it from your phone or desktop.",
metaTitle: "Stakpak DevOps agent mobile and desktop app, open source",
metaDescription:
"Drive Stakpak, the Rust-based DevOps agent, from your phone. Launch tasks on your machine, monitor from your pocket.",
"Open source mobile and desktop app for Stakpak, the Rust-based DevOps agent. Launch tasks on your machine, monitor from anywhere.",
},
{
slug: "vtcode",
name: "VT Code",
title: "VT Code, mobile",
title: "Open source app for VT Code",
subtitle:
"Multi-provider coding agent on your machine. Send tasks from anywhere, ship from your pocket.",
metaTitle: "VT Code Mobile App – Run VT Code from anywhere | Paseo",
"Run VT Code's multi-provider coding agent on your machine, drive it from your phone or desktop.",
metaTitle: "VT Code mobile and desktop app, open source",
metaDescription:
"Run VT Code, the open-source multi-provider coding agent, from your phone. Launch sessions, monitor progress, ship from anywhere.",
"Open source mobile and desktop app for VT Code, the multi-provider coding agent. Launch sessions on your machine, ship from anywhere.",
},
{
slug: "agoragentic",
name: "Agoragentic",
title: "Agoragentic from your phone",
subtitle: "174+ AI capabilities on your machine, driven from anywhere. Browse, invoke, ship.",
metaTitle: "Agoragentic Mobile App – Run Agoragentic from anywhere | Paseo",
title: "Open source app for Agoragentic",
subtitle:
"Run Agoragentic's 174+ AI capabilities on your machine, drive it from your phone or desktop.",
metaTitle: "Agoragentic mobile and desktop app, open source",
metaDescription:
"Drive Agoragentic, the AI agent marketplace, from your phone. Launch sessions on your machine, ship from your pocket.",
"Open source mobile and desktop app for Agoragentic, the AI agent marketplace. Launch sessions on your machine, ship from anywhere.",
},
{
slug: "autohand",
name: "Autohand Code",
title: "Autohand Code, mobile",
title: "Open source app for Autohand Code",
subtitle:
"Autohand's coding agent on your machine, controlled from your phone. Real work, no laptop.",
metaTitle: "Autohand Code Mobile App – Run Autohand from anywhere | Paseo",
"Run Autohand AI's coding agent on your machine, drive it from your phone or desktop.",
metaTitle: "Autohand Code mobile and desktop app, open source",
metaDescription:
"Run Autohand AI's coding agent from your phone. Launch sessions on your machine, monitor progress, ship from anywhere.",
"Open source mobile and desktop app for Autohand AI's coding agent. Launch sessions on your machine, monitor progress, ship from anywhere.",
},
] as const satisfies readonly AgentPage[];
+16 -7
View File
@@ -1,8 +1,17 @@
export function pageMeta(title: string, description: string) {
return [
{ title },
{ name: "description", content: description },
{ property: "og:title", content: title },
{ property: "og:description", content: description },
];
const SITE_ORIGIN = "https://paseo.sh";
export function pageMeta(title: string, description: string, path: string) {
const url = `${SITE_ORIGIN}${path}`;
return {
meta: [
{ title },
{ name: "description", content: description },
{ property: "og:title", content: title },
{ property: "og:description", content: description },
{ property: "og:url", content: url },
{ name: "twitter:title", content: title },
{ name: "twitter:description", content: description },
],
links: [{ rel: "canonical", href: url }],
};
}
+5 -5
View File
@@ -7,12 +7,12 @@ import { pageMeta } from "~/meta";
import "~/styles.css";
export const Route = createFileRoute("/agents")({
head: () => ({
meta: pageMeta(
"Supported agents – Every coding agent Paseo runs | Paseo",
"Run Claude Code, Codex, OpenCode, Cursor CLI, Gemini CLI, Hermes Agent, Qwen Code, Kimi Code, DeepSeek TUI, and 28 more coding agents from your phone. Self-hosted, your code stays on your machine.",
head: () =>
pageMeta(
"Supported agents – Every coding agent Paseo runs",
"Run Claude Code, Codex, Copilot, OpenCode, Cursor CLI, Gemini CLI, and dozens more coding agents from your phone. Self-hosted, your code stays on your machine.",
"/agents",
),
}),
component: AgentsPage,
});
+3 -4
View File
@@ -6,11 +6,10 @@ import { pageMeta } from "~/meta";
export const Route = createFileRoute("/blog/$")({
head: ({ params }) => {
const slug = params._splat ?? "";
const path = `/blog/${slug}`;
const post = getPost(slug);
if (!post) return { meta: pageMeta("Not Found - Paseo", "Post not found.") };
return {
meta: pageMeta(`${post.frontmatter.title} - Paseo`, post.frontmatter.description),
};
if (!post) return pageMeta("Not Found - Paseo", "Post not found.", path);
return pageMeta(`${post.frontmatter.title} - Paseo`, post.frontmatter.description, path);
},
component: BlogPost,
});
+15 -7
View File
@@ -3,13 +3,21 @@ import { createFileRoute, Link } from "@tanstack/react-router";
import { getPosts, formatDate } from "~/posts";
import { pageMeta } from "~/meta";
interface BlogSearch {
drafts?: true;
}
export const Route = createFileRoute("/blog/")({
validateSearch: (search: Record<string, unknown>) => ({
drafts: search.drafts === true || search.drafts === "" || search.drafts === "true",
}),
head: () => ({
meta: pageMeta("Blog - Paseo", "Updates, thoughts, and announcements from the Paseo team."),
}),
validateSearch: (search: Record<string, unknown>): BlogSearch => {
const drafts = search.drafts === true || search.drafts === "" || search.drafts === "true";
return drafts ? { drafts: true } : {};
},
head: () =>
pageMeta(
"Blog – Updates and announcements from the Paseo team",
"Product updates, technical posts, and announcements from the Paseo team. Notes on building a self-hosted, multi-agent dev environment for your phone.",
"/blog",
),
component: BlogIndex,
});
@@ -43,7 +51,7 @@ function PostRow({ slug, title, date, draft }: PostRowProps) {
function BlogIndex() {
const { drafts } = Route.useSearch();
const posts = getPosts(drafts);
const posts = getPosts(drafts === true);
return (
<div>
+4 -4
View File
@@ -5,12 +5,12 @@ import { SiteShell } from "~/components/site-shell";
import { pageMeta } from "~/meta";
export const Route = createFileRoute("/changelog")({
head: () => ({
meta: pageMeta(
head: () =>
pageMeta(
"Changelog - Paseo",
"Product updates, fixes, and improvements shipped in each Paseo release.",
"Product updates, bug fixes, and improvements shipped in each Paseo release. Track new agent providers, mobile features, and daemon changes over time.",
"/changelog",
),
}),
component: Changelog,
});
+4 -4
View File
@@ -6,12 +6,12 @@ import { SiteShell } from "~/components/site-shell";
import { pageMeta } from "~/meta";
export const Route = createFileRoute("/cloud")({
head: () => ({
meta: pageMeta(
head: () =>
pageMeta(
"Paseo Cloud - Design Partners",
"Paseo across machines, with a team, or inside a company. Looking for design partners.",
"Run Paseo across machines, with a team, or inside a company. We're onboarding design partners for the hosted, multi-machine, multi-user version of Paseo.",
"/cloud",
),
}),
component: Cloud,
});
+3 -4
View File
@@ -7,11 +7,10 @@ import { pageMeta } from "~/meta";
export const Route = createFileRoute("/docs/$")({
head: ({ params }) => {
const slug = params._splat ?? "";
const path = `/docs/${slug}`;
const doc = getDoc(slug);
if (!doc) return { meta: pageMeta("Not Found - Paseo Docs", "Doc not found.") };
return {
meta: pageMeta(`${doc.frontmatter.title} - Paseo Docs`, doc.frontmatter.description),
};
if (!doc) return pageMeta("Not Found - Paseo Docs", "Doc not found.", path);
return pageMeta(`${doc.frontmatter.title} - Paseo Docs`, doc.frontmatter.description, path);
},
component: DocsPage,
});
+7 -4
View File
@@ -7,10 +7,13 @@ import { pageMeta } from "~/meta";
export const Route = createFileRoute("/docs/")({
head: () => {
const doc = getDoc("");
if (!doc) return { meta: pageMeta("Docs - Paseo", "Paseo documentation.") };
return {
meta: pageMeta(`${doc.frontmatter.title} - Paseo Docs`, doc.frontmatter.description),
};
if (!doc)
return pageMeta(
"Docs - Paseo",
"Install Paseo and start running coding agents from your phone, desktop, and terminal.",
"/docs",
);
return pageMeta(`${doc.frontmatter.title} - Paseo Docs`, doc.frontmatter.description, "/docs");
},
component: DocsIndex,
});
+5 -5
View File
@@ -18,12 +18,12 @@ import { useRelease } from "~/routes/__root";
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.",
head: () =>
pageMeta(
"Download Paseo for macOS, Windows, Linux, iOS, and Android",
"Install Paseo on every platform. Native desktop apps for macOS, Windows, and Linux. Mobile apps for iOS and Android. Self-hosted, open source, free to download.",
"/download",
),
}),
component: Download,
});
+5 -5
View File
@@ -3,12 +3,12 @@ import { LandingPage } from "~/components/landing-page";
import { pageMeta } from "~/meta";
export const Route = createFileRoute("/")({
head: () => ({
meta: pageMeta(
"Paseo – Run Claude Code, Codex, Copilot, OpenCode, and Pi from everywhere",
"A self-hosted daemon for Claude Code, Codex, Copilot, OpenCode, and Pi. Agents run on your machine with your full dev environment. Connect from phone, desktop, or web.",
head: () =>
pageMeta(
"Paseo – Run Claude Code, Codex, Copilot, OpenCode from anywhere",
"Self-hosted daemon for Claude Code, Codex, Copilot, OpenCode, and Pi. Agents run on your machine with your full dev environment. Connect from phone, desktop, or web.",
"/",
),
}),
component: Home,
});
+4 -4
View File
@@ -3,12 +3,12 @@ import { SiteShell } from "~/components/site-shell";
import { pageMeta } from "~/meta";
export const Route = createFileRoute("/privacy")({
head: () => ({
meta: pageMeta(
head: () =>
pageMeta(
"Privacy Policy - Paseo",
"Privacy policy for Paseo - a self-hosted agent manager with no tracking or analytics.",
"Privacy policy for Paseo, the self-hosted coding agent manager. No tracking, no analytics, no data collection. Your code stays on your machine.",
"/privacy",
),
}),
component: Privacy,
});
+18
View File
@@ -0,0 +1,18 @@
import startEntry from "@tanstack/react-start/server-entry";
const CANONICAL_HOST = "paseo.sh";
type FetchArgs = Parameters<typeof startEntry.fetch>;
export default {
async fetch(...args: FetchArgs): Promise<Response> {
const [request] = args;
const url = new URL(request.url);
if (url.hostname !== CANONICAL_HOST || url.protocol !== "https:") {
url.protocol = "https:";
url.hostname = CANONICAL_HOST;
return Response.redirect(url.toString(), 301);
}
return startEntry.fetch(...args);
},
};
+12
View File
@@ -53,6 +53,17 @@ function discoverAgentRoutes(): string[] {
.map((slug) => `/${slug}`);
}
function discoverBlogRoutes(): string[] {
const postsDir = path.join(__dirname, "posts");
if (!fs.existsSync(postsDir)) return ["/blog"];
const slugs = fs
.readdirSync(postsDir, { withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name.endsWith(".md"))
.map((entry) => entry.name.replace(/\.md$/, ""))
.sort();
return ["/blog", ...slugs.map((slug) => `/blog/${slug}`)];
}
const sitemapPages = [
"/",
"/agents",
@@ -62,6 +73,7 @@ const sitemapPages = [
"/privacy",
...discoverAgentRoutes(),
...discoverDocsRoutes(),
...discoverBlogRoutes(),
].map((routePath) => ({
path: routePath,
}));
+1 -1
View File
@@ -2,7 +2,7 @@ name = "paseo-website"
account_id = "10ed39a1dbf316e30abd0c409bed40d6"
compatibility_date = "2024-12-01"
compatibility_flags = ["nodejs_compat"]
main = "@tanstack/react-start/server-entry"
main = "./src/server-entry.ts"
routes = [
{ pattern = "paseo.sh", custom_domain = true },
+2
View File
@@ -11,6 +11,8 @@ Conductor is a macOS app for running Claude Code and Codex in parallel git workt
Paseo is an app for orchestrating coding agents, with native clients on desktop, mobile, web, and the CLI. Open source (AGPL-3.0).
![Paseo desktop and mobile app](/hero-mockup.png)
## Why pick Paseo
Conductor runs on macOS, with Claude Code and Codex, in parallel git worktrees. Paseo does all of that. Pick Paseo if you want:
+2
View File
@@ -11,6 +11,8 @@ Happy Coder is a mobile and web client for Claude Code and Codex. It wraps the a
Paseo is an app for orchestrating coding agents, with native clients on desktop, mobile, web, and the CLI. Open source (AGPL-3.0).
![Paseo desktop and mobile app](/hero-mockup.png)
## When to pick what
Pick Happy Coder if you want the most minimal setup. Wrap an existing Claude Code or Codex session on your laptop and check in on it from your phone.
+2
View File
@@ -11,6 +11,8 @@ OpenChamber is a macOS desktop app for OpenCode. Also available as a PWA. Open s
Paseo is an app for orchestrating coding agents, with native clients on desktop, mobile, web, and the CLI. Open source (AGPL-3.0).
![Paseo desktop and mobile app](/hero-mockup.png)
## Why pick Paseo
OpenChamber runs on macOS, around OpenCode, with a phone PWA. Paseo runs OpenCode too, on macOS, and adds:
+2
View File
@@ -11,6 +11,8 @@ Superset is a macOS desktop app for running CLI coding agents in parallel git wo
Paseo is an app for orchestrating coding agents, with native clients on desktop, mobile, web, and the CLI. Open source (AGPL-3.0).
![Paseo desktop and mobile app](/hero-mockup.png)
## When to pick what
Pick Superset if you prefer a terminal-first interface where agents live inside terminal panes.