Compare commits

...

14 Commits

Author SHA1 Message Date
Mohamed Boudra
7c02b9ec7c fix(search): address workspace search review feedback 2026-07-01 19:07:53 +02:00
Mohamed Boudra
9905b0da31 fix(search): include dot-prefixed workspace entries
Workspace suggestions now use the indexed search backend directly, so dotfiles and dot directories are included without a second ignore layer. Packaged desktop smoke covers the native search path.
2026-06-30 23:46:32 +02:00
Mohamed Boudra
db03b1f3fd docs: link paseo-vscode extension 2026-06-30 16:18:03 +02:00
Mohamed Boudra
cb486c3a5a Speed up app Playwright CI (#1830)
* ci(playwright): shard app e2e in CI

Run the app Playwright suite across isolated CI shards and keep restarted E2E daemons on the same speech-disabled setup path as global setup.

* test(app): share disabled speech e2e env
2026-06-30 12:37:47 +02:00
paseo-ai[bot]
7dad7a377c fix: update lockfile signatures and Nix hash [skip ci] 2026-06-30 10:05:14 +00:00
Mohamed Boudra
e32c50f2d7 chore(release): cut 0.1.102 2026-06-30 12:00:31 +02:00
Mohamed Boudra
e3eb633582 docs: update changelog for 0.1.102 2026-06-30 11:23:41 +02:00
Mohamed Boudra
beac4544fd chore: update ACP provider catalog 2026-06-30 11:22:12 +02:00
Mohamed Boudra
c2271ff0ca Place turn footers after trailing tools (#1827)
* fix(app): place turn footers after trailing tools

Completed turn footers now render on the last visible item before the next user message while still using the latest assistant message for footer content and timing.

* test(app): cover split turn footer placement
2026-06-30 10:51:53 +02:00
Mohamed Boudra
f91806defe Use separate OpenAI endpoints for speech-to-text and text-to-speech (#1823)
* feat(voice): configure OpenAI STT and TTS endpoints separately

Replace providers.openai.voice (a single apiKey/baseUrl shared by
speech-to-text and text-to-speech) with independent providers.openai.stt
and providers.openai.tts, each carrying its own apiKey/baseUrl. STT and
TTS now resolve fully independently, so they can point at different
OpenAI-compatible endpoints. The env equivalents OPENAI_VOICE_API_KEY /
OPENAI_VOICE_BASE_URL split into OPENAI_STT_* and OPENAI_TTS_*.

No backcompat: the voice key is removed and no longer read. Each feature
still falls back to providers.openai.apiKey/baseUrl, then OPENAI_API_KEY/
OPENAI_BASE_URL. Composer dictation resolves from the STT endpoint.

* fix(voice): keep daemon bootable and respect global OpenAI key

Two issues from review of the STT/TTS endpoint split:

- A config from an older release that still sets providers.openai.voice
  crashed daemon startup, because the strict schema rejects the now-unknown
  key. Strip it before parsing (alongside the existing local.autoDownload
  strip) so the daemon boots; the value is discarded, not migrated.
- An empty endpoint env var (e.g. a copied .env.example leaving
  OPENAI_STT_API_KEY= blank) shadowed the OPENAI_API_KEY fallback, so
  speech was reported as missing credentials despite a configured global
  key. firstDefined now skips empty/whitespace strings.

* fix(voice): isolate STT and TTS option parsing per endpoint

An STT-only OpenAI setup could be broken by a stale or invalid TTS env
var (e.g. a leftover TTS_VOICE/TTS_MODEL), because the single resolution
schema validated both endpoints' option groups before the per-endpoint
gate. Split into endpoint-key, STT-option, and TTS-option schemas and
parse each option group only when that endpoint has credentials, so an
unused endpoint's bad env can no longer take down the configured one.

* fix(voice): tag voice-config shim and update direct-daemon test callers

- Mark the providers.openai.voice strip with a COMPAT(openaiVoiceConfig)
  comment + removal date so it shows up in the back-compat cleanup
  inventory, per repo convention.
- Update the tests that build the daemon directly with a resolved OpenAI
  config (bootstrap smoke + the real-API voice/daemon e2e suites) to the
  new { stt, tts } shape; the old top-level { apiKey } is no longer read,
  and these files are excluded from typecheck so the break was silent.

* fix(voice): update voice-roundtrip debug script to new OpenAI config shape

Last direct daemon caller still passing the removed top-level
openai: { apiKey }; the debug script lives outside tsconfig.scripts.json
so the stale shape wasn't caught by typecheck. Use { stt, tts }.
2026-06-30 10:00:27 +02:00
Mohamed Boudra
e9431517a0 Show host in search results and filter sidebar by multiple hosts (#1825)
* feat(app): show host in command center and multi-select host filter

Command-center agent results now show which host the workspace lives on,
shown only when more than one host is connected (matching the sidebar).

The sidebar display-preferences filter becomes multi-select — pick any
number of hosts instead of one-or-all — and its host rows now carry a
status dot on the left like the other host pickers. Persisted filter
state migrates from the old single host to a host list.

* test(app): extract host-filter and command-center e2e DSL helpers

Move the mechanical menu/search interactions out of the new spec bodies
into focused helpers so the tests read as user intent.

* fix(app): drop the icon from the host filter "All hosts" item

* fix(app): make multi-host e2e seeding order-independent

The offline-host seed relied on Playwright running the test's init script
after the fixture's registry reset — an ordering Playwright does not
guarantee. Write the full registry and set the fixture's disable-once flag,
then reload, so the multi-host seed survives regardless of script order.

Also tag the pre-v2 hostFilter migration reader with COMPAT so the cleanup
sweep can find it.
2026-06-30 09:40:24 +02:00
Xisheng Parker Zhao
4e28b0941b fix(server): surface Kiro CLI slash commands and skills over ACP (#1792)
* fix(server): surface Kiro CLI slash commands and skills over ACP

Kiro CLI advertises its slash commands and skills through the
`_kiro.dev/commands/available` ACP extension notification rather than the
standard `available_commands_update` session update. ACPAgentSession only
trace-logged extension notifications and dropped the payload, so the
composer never showed any Kiro commands or skills.

- Map the extension payload onto the shared slash-command cache:
  `commands` (built-in, names arrive with a leading "/") and `prompts`
  (skills/prompts, tagged with a `skill:` serverName) are normalized to
  Paseo's AgentSlashCommand shape, stripping the leading slash and pulling
  the argument hint from `meta.hint`.
- Add KiroACPAgentClient (mirrors CursorACPAgentClient) so the provider
  waits for the first async command batch before listCommands() resolves,
  since Kiro emits the notification shortly after session/new.

* fix(server): settle on empty Kiro batch; inject Kiro commands via constructor option

Addresses review feedback on #1792.

Keep vendor-specific providers out of the generic acp-agent.ts, following
the same pattern as Cursor/Copilot: provider behavior is injected through
ACPAgentClient constructor options rather than special-cased in the base
class. Adds an optional extensionCommandsParser option on the generic ACP
client/session; the base extNotification() invokes it and routes any parsed
commands through a generic applyResolvedCommands() helper. Kiro supplies
parseKiroExtensionCommands (recognizes _kiro.dev/commands/available); the
base class no longer references any Kiro method string or payload shape.

Also fixes an empty-batch hang: applyResolvedCommands() always settles the
initial-commands gate, so a Kiro user with no slash commands no longer
blocks listCommands() for the full 10s timeout. Adds a regression test.
2026-06-30 09:36:48 +02:00
Mohamed Boudra
7c6152663e Show found desktop updates after manual checks (#1815)
* fix(desktop): report found app updates during manual checks

Manual checks could reuse cached update state while the renderer discarded pending update details. Keep found update versions visible while downloads prepare, and reserve install affordances for ready updates.

* test(desktop): cover manual update retry after errors

* fix(desktop): keep ready updates ready on recheck
2026-06-30 08:12:15 +02:00
paseo-ai[bot]
5fc53c576e fix: update lockfile signatures and Nix hash [skip ci] 2026-06-29 22:07:09 +00:00
89 changed files with 3381 additions and 1664 deletions

View File

@@ -241,6 +241,11 @@ jobs:
run: npm run typecheck:examples --workspace=@getpaseo/client
playwright:
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
name: playwright (shard ${{ matrix.shard }}/4)
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
@@ -280,7 +285,7 @@ jobs:
run: npm install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
- name: Run Playwright E2E tests
run: npm run test:e2e --workspace=@getpaseo/app
run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ matrix.shard }}/4
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
@@ -288,7 +293,7 @@ jobs:
uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-results
name: playwright-results-${{ matrix.shard }}
path: |
packages/app/test-results/
packages/app/playwright-report/

View File

@@ -329,6 +329,18 @@ jobs:
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install Windows arm64 native search packages
shell: bash
run: |
set -euo pipefail
fff_version="$(node -p "require('./node_modules/@ff-labs/fff-node/package.json').optionalDependencies['@ff-labs/fff-bin-win32-arm64']")"
ffi_version="$(node -p "require('./node_modules/ffi-rs/package.json').optionalDependencies['@yuuang/ffi-rs-win32-arm64-msvc']")"
npm install --no-save --package-lock=false --ignore-scripts --no-audit --fund=false --os=win32 --cpu=arm64 \
"@ff-labs/fff-bin-win32-arm64@${fff_version}" \
"@yuuang/ffi-rs-win32-arm64-msvc@${ffi_version}"
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Set desktop package version from tag
shell: bash
run: |

View File

@@ -1,51 +1,49 @@
# Changelog
## 0.1.102-beta.2 - 2026-06-29
## 0.1.102 - 2026-06-30
### Added
- Fork a chat from any assistant answer ([#1788](https://github.com/getpaseo/paseo/pull/1788))
- Use Paseo from a browser served directly by your daemon ([#1635](https://github.com/getpaseo/paseo/pull/1635))
- Fork chats into a new tab or new worktree ([#1788](https://github.com/getpaseo/paseo/pull/1788))
- See workspaces from all connected hosts ([#1538](https://github.com/getpaseo/paseo/pull/1538), [#1775](https://github.com/getpaseo/paseo/pull/1775), [#1825](https://github.com/getpaseo/paseo/pull/1825))
- Daemon can now serve the web UI ([#1635](https://github.com/getpaseo/paseo/pull/1635), [#1739](https://github.com/getpaseo/paseo/pull/1739))
- Run Paseo from an official Docker image ([#1740](https://github.com/getpaseo/paseo/pull/1740) by [@Herbrant](https://github.com/Herbrant))
- Update a remote host from the app when a newer daemon is available ([#1513](https://github.com/getpaseo/paseo/pull/1513) by [@thedavidweng](https://github.com/thedavidweng))
- Dropped files can be attached from every composer ([#1750](https://github.com/getpaseo/paseo/pull/1750))
- MiniMax usage is available in provider quota views ([#1662](https://github.com/getpaseo/paseo/pull/1662) by [@ilteoood](https://github.com/ilteoood))
- C# code blocks now have syntax highlighting ([#1651](https://github.com/getpaseo/paseo/pull/1651) by [@dev693](https://github.com/dev693))
- Update a daemon remotely from the app ([#1513](https://github.com/getpaseo/paseo/pull/1513) by [@thedavidweng](https://github.com/thedavidweng))
- Configure separate OpenAI endpoints for speech-to-text and text-to-speech ([#1823](https://github.com/getpaseo/paseo/pull/1823))
- Drop files into any composer ([#1750](https://github.com/getpaseo/paseo/pull/1750), [#1801](https://github.com/getpaseo/paseo/pull/1801))
- Show MiniMax usage in quota views ([#1662](https://github.com/getpaseo/paseo/pull/1662) by [@ilteoood](https://github.com/ilteoood))
- Highlight C# code blocks ([#1651](https://github.com/getpaseo/paseo/pull/1651) by [@dev693](https://github.com/dev693))
### Improved
- The sidebar can show workspaces across all connected hosts ([#1538](https://github.com/getpaseo/paseo/pull/1538))
- Multi-host workspace rows show which host they belong to ([#1775](https://github.com/getpaseo/paseo/pull/1775))
- Host switcher rows show the connection currently in use
- Creating a new workspace is available from an app-wide screen ([#1746](https://github.com/getpaseo/paseo/pull/1746))
- New Workspace starts from the current project when opened inside a project ([#1806](https://github.com/getpaseo/paseo/pull/1806))
- Project search shows loading progress while results are fetched ([#1762](https://github.com/getpaseo/paseo/pull/1762))
- Desktop update checks show feedback while checking for updates ([#1808](https://github.com/getpaseo/paseo/pull/1808))
- Slow host requests time out less aggressively ([#1789](https://github.com/getpaseo/paseo/pull/1789))
- Pi waits longer for extension results before timing out ([#1732](https://github.com/getpaseo/paseo/pull/1732) by [@theslava](https://github.com/theslava))
- New Workspace opens from anywhere ([#1746](https://github.com/getpaseo/paseo/pull/1746), [#1806](https://github.com/getpaseo/paseo/pull/1806))
- Project search shows loading progress ([#1762](https://github.com/getpaseo/paseo/pull/1762))
- Desktop update checks show clearer status ([#1808](https://github.com/getpaseo/paseo/pull/1808), [#1815](https://github.com/getpaseo/paseo/pull/1815))
- Slow remote hosts time out less aggressively ([#1789](https://github.com/getpaseo/paseo/pull/1789))
- Pi waits longer for extension results ([#1732](https://github.com/getpaseo/paseo/pull/1732) by [@theslava](https://github.com/theslava))
- Open file tabs refresh when you revisit them ([#1699](https://github.com/getpaseo/paseo/pull/1699) by [@cleiter](https://github.com/cleiter))
- Web terminals stay smoother while scrolling ([#1622](https://github.com/getpaseo/paseo/pull/1622) by [@TommyLike](https://github.com/TommyLike))
- ACP provider catalog updated to the latest registry versions
- Web terminals scroll more smoothly ([#1622](https://github.com/getpaseo/paseo/pull/1622) by [@TommyLike](https://github.com/TommyLike))
### Fixed
- Freshly added projects can be edited without restarting ([#1761](https://github.com/getpaseo/paseo/pull/1761) by [@huiliaoning](https://github.com/huiliaoning))
- Opening projects and directory suggestions work better in large repos ([#1620](https://github.com/getpaseo/paseo/pull/1620) by [@jms830](https://github.com/jms830))
- Mobile opens the saved workspace reliably on launch ([#1777](https://github.com/getpaseo/paseo/pull/1777))
- Agent prompts no longer rename existing workspaces ([#1779](https://github.com/getpaseo/paseo/pull/1779))
- Chat stays at your scroll position when delayed history arrives ([#1776](https://github.com/getpaseo/paseo/pull/1776))
- Large repos open more reliably ([#1620](https://github.com/getpaseo/paseo/pull/1620) by [@jms830](https://github.com/jms830))
- Mobile restores the saved workspace on launch ([#1777](https://github.com/getpaseo/paseo/pull/1777))
- Agent prompts no longer rename workspaces ([#1779](https://github.com/getpaseo/paseo/pull/1779))
- Chat stays put when delayed history arrives ([#1776](https://github.com/getpaseo/paseo/pull/1776))
- Streamed chat images stay in order ([#1805](https://github.com/getpaseo/paseo/pull/1805))
- Chat actions wait until running tool calls finish
- Claude subagent narration no longer leaks into chat ([#1807](https://github.com/getpaseo/paseo/pull/1807))
- Agent lists keep working when project records go stale ([#1812](https://github.com/getpaseo/paseo/pull/1812))
- Windows image previews work with drive-letter paths ([#1811](https://github.com/getpaseo/paseo/pull/1811))
- Chat actions stay below tool output ([#1827](https://github.com/getpaseo/paseo/pull/1827))
- Claude subagent narration stays out of chat ([#1807](https://github.com/getpaseo/paseo/pull/1807))
- Kiro slash commands and skills appear in Paseo ([#1792](https://github.com/getpaseo/paseo/pull/1792) by [@park0er](https://github.com/park0er))
- Agent lists survive stale project records ([#1812](https://github.com/getpaseo/paseo/pull/1812))
- Windows image previews handle drive-letter paths ([#1811](https://github.com/getpaseo/paseo/pull/1811))
- OpenCode closes cleanly on Windows ([#1771](https://github.com/getpaseo/paseo/pull/1771) by [@agamotto](https://github.com/agamotto))
- Desktop file uploads keep their file extensions ([#1741](https://github.com/getpaseo/paseo/pull/1741))
- Closing Claude Code sessions no longer leaves child processes running ([#1540](https://github.com/getpaseo/paseo/pull/1540) by [@TommyLike](https://github.com/TommyLike))
- OpenCode no longer indexes your home directory by mistake ([#1704](https://github.com/getpaseo/paseo/pull/1704) by [@rex-chang](https://github.com/rex-chang))
- Desktop file uploads keep their extensions ([#1741](https://github.com/getpaseo/paseo/pull/1741))
- Claude Code cleanup kills child processes ([#1540](https://github.com/getpaseo/paseo/pull/1540) by [@TommyLike](https://github.com/TommyLike))
- OpenCode no longer indexes your home directory ([#1704](https://github.com/getpaseo/paseo/pull/1704) by [@rex-chang](https://github.com/rex-chang))
- Packaged macOS CLI daemon no longer shows extra Dock icons ([#1759](https://github.com/getpaseo/paseo/pull/1759) by [@yzim](https://github.com/yzim))
- `paseo daemon status` reports daemon health even when agent details are unavailable ([#1810](https://github.com/getpaseo/paseo/pull/1810))
- PR worktrees show pushed state correctly after checkout ([#1804](https://github.com/getpaseo/paseo/pull/1804))
- `paseo daemon status` works without loading agents ([#1810](https://github.com/getpaseo/paseo/pull/1810))
- PR worktrees show pushed state correctly ([#1804](https://github.com/getpaseo/paseo/pull/1804))
## 0.1.101 - 2026-06-26

View File

@@ -154,6 +154,7 @@ npm run typecheck
## コミュニティ
- [paseo-relay](https://github.com/zenghongtu/paseo-relay) — Go 実装のセルフホスト型リレー
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code 拡張機能
---

View File

@@ -169,6 +169,7 @@ npm run typecheck
## Community
- [paseo-relay](https://github.com/zenghongtu/paseo-relay) — self-hosted relay in Go
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code extension
---

View File

@@ -154,6 +154,7 @@ npm run typecheck
## 社区
- [paseo-relay](https://github.com/zenghongtu/paseo-relay) — Go 实现的自托管 relay
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code 扩展
### 自托管 relay TLS

View File

@@ -164,7 +164,12 @@ Single file, validated with `PersistedConfigSchema`.
root?: string // optional root for new worktrees; defaults to $PASEO_HOME/worktrees
},
providers: {
openai: { voice: { apiKey: string, baseUrl: string } },
openai: {
apiKey?: string,
baseUrl?: string,
stt?: { apiKey?: string, baseUrl?: string },
tts?: { apiKey?: string, baseUrl?: string }
},
local: { modelsDir: string }
},
agents: {
@@ -202,13 +207,17 @@ Set these to select OpenAI instead of local speech:
| `PASEO_DICTATION_STT_PROVIDER` | Composer dictation STT provider |
| `PASEO_VOICE_TTS_PROVIDER` | Voice mode TTS provider |
OpenAI voice can be configured under `providers.openai`:
OpenAI speech can be configured under `providers.openai`. STT and TTS resolve independently, so they can point at different endpoints:
```json
{
"providers": {
"openai": {
"voice": {
"stt": {
"apiKey": "sk-...",
"baseUrl": "https://stt.example.com/v1"
},
"tts": {
"apiKey": "sk-...",
"baseUrl": "https://api.openai.com/v1"
}
@@ -217,7 +226,7 @@ OpenAI voice can be configured under `providers.openai`:
}
```
`providers.openai.voice.apiKey` and `providers.openai.voice.baseUrl` apply only to Paseo OpenAI voice features.
`providers.openai.stt` is used for both composer dictation and voice mode speech-to-text; `providers.openai.tts` is used for voice mode text-to-speech. The equivalent env vars are `OPENAI_STT_API_KEY`/`OPENAI_STT_BASE_URL` and `OPENAI_TTS_API_KEY`/`OPENAI_TTS_BASE_URL`. Each feature falls back to `providers.openai.apiKey`/`providers.openai.baseUrl`, then `OPENAI_API_KEY`/`OPENAI_BASE_URL`, when its own fields are unset. These settings apply only to Paseo OpenAI speech features, not to Codex or other OpenAI-backed tools.
Paseo uses these paths under the configured OpenAI base URL:

View File

@@ -169,6 +169,14 @@ The supervisor rotates `daemon.log`. Persisted `log.file.rotate` settings in
`PASEO_LOG_ROTATE_SIZE` and `PASEO_LOG_ROTATE_COUNT` env vars override the
defaults. The default rotation is `10m` x `3` files everywhere.
### Workspace search
Workspace file and directory suggestions are backed by `@ff-labs/fff-node` in
`packages/server/src/server/search/workspace-entries.ts`. Treat that backend as
the search and ignore boundary: do not add a separate `.gitignore`/`.rgignore`
matcher in Paseo. If ignore semantics need to change, change the backend
contract and keep the packaged desktop smoke covering dot-prefixed suggestions.
## paseo.json service scripts
`worktree.setup` and `worktree.teardown` accept either a multiline shell script or an array

View File

@@ -131,6 +131,7 @@ Test suites in this repo are heavy. Running them in bulk freezes the machine, es
- For full-suite confidence, push to CI and check GitHub Actions.
- Never run the full Playwright E2E suite locally — defer whole-suite verification to CI. Targeted Playwright specs are allowed when you changed or need to prove that specific flow.
- App Playwright specs share one isolated daemon per run. Helpers that create projects or workspaces must remove the daemon project record during cleanup, not only delete the temp directory. Agent helpers must pass the intended `workspaceId` through to agent creation; never infer ownership from `cwd`.
- CI can shard app Playwright across multiple jobs; each shard still owns a full isolated daemon/relay/Metro stack from global setup. Helpers that restart the daemon must preserve the global setup environment, including disabled speech/local-model settings, so a restart does not change the tested surface or start background downloads.
## Agent authentication in tests

View File

@@ -1 +1 @@
sha256-4fS/faM5nUjfRnc0UznCisNAby2fiNg0ROklW0FyptY=
sha256-qdRCpAtiqTk5mqz+lE6xTO18PtmD1ROQuDRtqDz6reI=

374
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.102-beta.2",
"version": "0.1.102",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.102-beta.2",
"version": "0.1.102",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -6154,6 +6154,141 @@
"excpretty": "build/cli.js"
}
},
"node_modules/@ff-labs/fff-bin-darwin-arm64": {
"version": "0.9.6",
"resolved": "https://registry.npmjs.org/@ff-labs/fff-bin-darwin-arm64/-/fff-bin-darwin-arm64-0.9.6.tgz",
"integrity": "sha512-gJzvYuAbudAIvUVJAAMXjManTPxmFqqqqbJSZ0RuZTNWLK2nFMKMo0XUVCXcRVaTENY/VRoHjTTFCHjVebmolw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@ff-labs/fff-bin-darwin-x64": {
"version": "0.9.6",
"resolved": "https://registry.npmjs.org/@ff-labs/fff-bin-darwin-x64/-/fff-bin-darwin-x64-0.9.6.tgz",
"integrity": "sha512-m+p7te06G785VS6fLCFEEeO0twZqmWxy3zzCxcICdwebFqDY3cQGGqDQ6Lmr264Q36bLQjLE7t2iqWIwRoI6dg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@ff-labs/fff-bin-linux-arm64-gnu": {
"version": "0.9.6",
"resolved": "https://registry.npmjs.org/@ff-labs/fff-bin-linux-arm64-gnu/-/fff-bin-linux-arm64-gnu-0.9.6.tgz",
"integrity": "sha512-UjqXuvJc2z2yeQ/6RqkgL1skdbzoZyexOvsBAt1VDIjUnm5AqoUoqh/guRQ2mke0dBWktZYRtvCrdS4CHB7dfA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@ff-labs/fff-bin-linux-arm64-musl": {
"version": "0.9.6",
"resolved": "https://registry.npmjs.org/@ff-labs/fff-bin-linux-arm64-musl/-/fff-bin-linux-arm64-musl-0.9.6.tgz",
"integrity": "sha512-OdERLjrCwx3Ykd0szEe2WxDZ44653ZHftIl5XV4QFnsyn6jLaxu227JV3Ap9BIG3VBrKIBtr5pUqk9Tumbrolg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@ff-labs/fff-bin-linux-x64-gnu": {
"version": "0.9.6",
"resolved": "https://registry.npmjs.org/@ff-labs/fff-bin-linux-x64-gnu/-/fff-bin-linux-x64-gnu-0.9.6.tgz",
"integrity": "sha512-OdROw237Ne7qy3HbTB78eovQMusccJ0PhRNAB6AmFuo+MiAzi1AdYs9iTAPYHqEH3uwEu0fdVFo9GTZcdnm03g==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@ff-labs/fff-bin-linux-x64-musl": {
"version": "0.9.6",
"resolved": "https://registry.npmjs.org/@ff-labs/fff-bin-linux-x64-musl/-/fff-bin-linux-x64-musl-0.9.6.tgz",
"integrity": "sha512-/7GJq5sw2WaNU79OKQqymYAF7lceMUm0OBw61M6tMDGi40LG1wNSF7uf8uLkD/p6GNkx8r/knmMDUuAKp208CQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@ff-labs/fff-bin-win32-arm64": {
"version": "0.9.6",
"resolved": "https://registry.npmjs.org/@ff-labs/fff-bin-win32-arm64/-/fff-bin-win32-arm64-0.9.6.tgz",
"integrity": "sha512-o0e8qu0kwqWEoV8rn/0wEmlFYBY09xZwaNdmBoGU6zXWZAviP/V92fzEED/yx0iIshq+qAYsuTuQSOj7gVDbVg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@ff-labs/fff-bin-win32-x64": {
"version": "0.9.6",
"resolved": "https://registry.npmjs.org/@ff-labs/fff-bin-win32-x64/-/fff-bin-win32-x64-0.9.6.tgz",
"integrity": "sha512-fkD3e6FmfR3i5Ymv14gfPT1FeJpnAXwe9Fu0r4sSDhvSE0x0ADQKEvwjMUBosb02O8XB9wWURx9cov1rCHVTnA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@ff-labs/fff-node": {
"version": "0.9.6",
"resolved": "https://registry.npmjs.org/@ff-labs/fff-node/-/fff-node-0.9.6.tgz",
"integrity": "sha512-2LbHJjwW/7vOLh1lSv0ipzA9Du5+LatUGiumQFaO9IcjMT8YgpmrCp/RRMyInsG0iBIX6A0WSxgei8qOFJLlNw==",
"cpu": [
"x64",
"arm64"
],
"license": "MIT",
"os": [
"darwin",
"linux",
"win32"
],
"dependencies": {
"ffi-rs": "^1.0.0"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"@ff-labs/fff-bin-darwin-arm64": "0.9.6",
"@ff-labs/fff-bin-darwin-x64": "0.9.6",
"@ff-labs/fff-bin-linux-arm64-gnu": "0.9.6",
"@ff-labs/fff-bin-linux-arm64-musl": "0.9.6",
"@ff-labs/fff-bin-linux-x64-gnu": "0.9.6",
"@ff-labs/fff-bin-linux-x64-musl": "0.9.6",
"@ff-labs/fff-bin-win32-arm64": "0.9.6",
"@ff-labs/fff-bin-win32-x64": "0.9.6"
}
},
"node_modules/@floating-ui/core": {
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
@@ -13761,6 +13896,183 @@
"dev": true,
"license": "BSD-2-Clause"
},
"node_modules/@yuuang/ffi-rs-android-arm64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-android-arm64/-/ffi-rs-android-arm64-1.3.2.tgz",
"integrity": "sha512-eDYLT0kVBkp7e2BwdRDmt6N1rkeDPUHDefk3ZX0/nok+GLsqfy1WBoSL3Yg7HVXN1EyW8OBVc2uK8Zq8HbmaSA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 12"
}
},
"node_modules/@yuuang/ffi-rs-darwin-arm64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-darwin-arm64/-/ffi-rs-darwin-arm64-1.3.2.tgz",
"integrity": "sha512-kRdgPaOM6TfuC5wHUwstlatk4HNie2lwSLJWQL2LiAUIJ7+96CoiWUNVhwBcFrhdfxhnWenYS6F668CV0vit8Q==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12"
}
},
"node_modules/@yuuang/ffi-rs-darwin-x64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-darwin-x64/-/ffi-rs-darwin-x64-1.3.2.tgz",
"integrity": "sha512-O3AlVgre8FQcZRJe44Xs7A6iDLumoPXqbw40+eJCa2gyXaXyLPdHoWrS1W9rBCa1QZRRnG7zRulPVFw8C5uo8g==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12"
}
},
"node_modules/@yuuang/ffi-rs-linux-arm-gnueabihf": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-linux-arm-gnueabihf/-/ffi-rs-linux-arm-gnueabihf-1.3.2.tgz",
"integrity": "sha512-IXiNdTbIcTCPny5eeElijFWYeKSJjQWSjt9ZyJNdLHYiB1Np+XD6K7wNZS6EOMgMelhW1kQE62T654skGkVDIA==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12"
}
},
"node_modules/@yuuang/ffi-rs-linux-arm64-gnu": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-linux-arm64-gnu/-/ffi-rs-linux-arm64-gnu-1.3.2.tgz",
"integrity": "sha512-gWFO6xufUK9lPYUqDvKa6IR243dPqdetgl9Q7HrZWaDu7wLo06QQrosw8QTzndafQnOcBKm6LoLujmGCfTgJOA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12"
}
},
"node_modules/@yuuang/ffi-rs-linux-arm64-musl": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-linux-arm64-musl/-/ffi-rs-linux-arm64-musl-1.3.2.tgz",
"integrity": "sha512-lejvOSqypPziQH5rzfkDlJ6e92qhWbDutE9ttOO6z5I2k83zoh9iZhZWhaXSU5VqgQpcshRkrbtXb9gy1ft5dA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12"
}
},
"node_modules/@yuuang/ffi-rs-linux-x64-gnu": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-linux-x64-gnu/-/ffi-rs-linux-x64-gnu-1.3.2.tgz",
"integrity": "sha512-s8VCFazaJKmgY2hgMTpWk4TtBY/zy5ovbaGgwyY0FvBD0YvyhcET4IrMsDJpHhFVTPCYfKZ1dN45clD/YiFp6g==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12"
}
},
"node_modules/@yuuang/ffi-rs-linux-x64-musl": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-linux-x64-musl/-/ffi-rs-linux-x64-musl-1.3.2.tgz",
"integrity": "sha512-Ahr5chfKZKWUik20bEZRug+be57LZ2yYrtolyjSRoo7A4ZniBUHBZUNWm6TD6i0CJayqyxWeVk/XiaABD8bY0w==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12"
}
},
"node_modules/@yuuang/ffi-rs-win32-arm64-msvc": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-win32-arm64-msvc/-/ffi-rs-win32-arm64-msvc-1.3.2.tgz",
"integrity": "sha512-yhpLcj0qel5VNlpzxPZfNmi7+rEX8444QHjUP6WWLxdRfqPllROu/Cp3OpkBpw3BLdxfcDhWkjWMD5QsJN0Pvg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12"
}
},
"node_modules/@yuuang/ffi-rs-win32-ia32-msvc": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-win32-ia32-msvc/-/ffi-rs-win32-ia32-msvc-1.3.2.tgz",
"integrity": "sha512-BFVSbdtg/7mJBw5kQFOPKFiA+SF7z3240HpzHN81Umm4Bp4dWkyx0msYn8+Q7/BBJiLQ4F6bi3Nftk58YA9r9w==",
"cpu": [
"x64",
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12"
}
},
"node_modules/@yuuang/ffi-rs-win32-x64-msvc": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-win32-x64-msvc/-/ffi-rs-win32-x64-msvc-1.3.2.tgz",
"integrity": "sha512-ZL5MJ76n2rjwGo26kCWW7wK6QT/cee00Rx8pfW79pz6vM6jqfhoE7zTnwFiw4aOQUes9+HUc5DeeJ3z+Vb9oLg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12"
}
},
"node_modules/7zip-bin": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz",
@@ -21209,6 +21521,25 @@
"integrity": "sha512-YoZjBdafyLIop9lSxXVI33oLD5kN31q4Td+CasofLLYeLXRFeOsuOw0Uo+XNRi9PZlbfdlN2GmRtm4tCEQ9/KA==",
"license": "MIT"
},
"node_modules/ffi-rs": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/ffi-rs/-/ffi-rs-1.3.2.tgz",
"integrity": "sha512-4s8dX9VbBw/jd5NOuE3EJRqXaIVdjMyiumeeDzrOhtjQRwp6Bz2za7iksWXTnvTQKV/tTdm1s1w7mObe92zPjQ==",
"license": "MIT",
"optionalDependencies": {
"@yuuang/ffi-rs-android-arm64": "1.3.2",
"@yuuang/ffi-rs-darwin-arm64": "1.3.2",
"@yuuang/ffi-rs-darwin-x64": "1.3.2",
"@yuuang/ffi-rs-linux-arm-gnueabihf": "1.3.2",
"@yuuang/ffi-rs-linux-arm64-gnu": "1.3.2",
"@yuuang/ffi-rs-linux-arm64-musl": "1.3.2",
"@yuuang/ffi-rs-linux-x64-gnu": "1.3.2",
"@yuuang/ffi-rs-linux-x64-musl": "1.3.2",
"@yuuang/ffi-rs-win32-arm64-msvc": "1.3.2",
"@yuuang/ffi-rs-win32-ia32-msvc": "1.3.2",
"@yuuang/ffi-rs-win32-x64-msvc": "1.3.2"
}
},
"node_modules/fflate": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
@@ -35132,7 +35463,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.102-beta.2",
"version": "0.1.102",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -36150,12 +36481,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.102-beta.2",
"version": "0.1.102",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.1.102-beta.2",
"@getpaseo/protocol": "0.1.102-beta.2",
"@getpaseo/server": "0.1.102-beta.2",
"@getpaseo/client": "0.1.102",
"@getpaseo/protocol": "0.1.102",
"@getpaseo/server": "0.1.102",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -36401,10 +36732,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.1.102-beta.2",
"version": "0.1.102",
"dependencies": {
"@getpaseo/protocol": "0.1.102-beta.2",
"@getpaseo/relay": "0.1.102-beta.2",
"@getpaseo/protocol": "0.1.102",
"@getpaseo/relay": "0.1.102",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -36415,7 +36746,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.102-beta.2",
"version": "0.1.102",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "*",
@@ -36658,7 +36989,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.102-beta.2",
"version": "0.1.102",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -37554,7 +37885,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.102-beta.2",
"version": "0.1.102",
"dependencies": {
"@codemirror/language": "^6.12.3",
"@codemirror/legacy-modes": "^6.5.3",
@@ -37786,7 +38117,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.1.102-beta.2",
"version": "0.1.102",
"dependencies": {
"zod": "^4.4.3"
},
@@ -37798,7 +38129,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.102-beta.2",
"version": "0.1.102",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -38016,15 +38347,16 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.102-beta.2",
"version": "0.1.102",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.195",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.1.102-beta.2",
"@getpaseo/highlight": "0.1.102-beta.2",
"@getpaseo/protocol": "0.1.102-beta.2",
"@getpaseo/relay": "0.1.102-beta.2",
"@ff-labs/fff-node": "^0.9.6",
"@getpaseo/client": "0.1.102",
"@getpaseo/highlight": "0.1.102",
"@getpaseo/protocol": "0.1.102",
"@getpaseo/relay": "0.1.102",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
@@ -38561,7 +38893,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.102-beta.2",
"version": "0.1.102",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",

View File

@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.102-beta.2",
"version": "0.1.102",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [

View File

@@ -0,0 +1,49 @@
import { randomUUID } from "node:crypto";
import { expect } from "@playwright/test";
import { test } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import { createIdleAgent } from "./helpers/archive-tab";
import { openCommandCenter } from "./helpers/command-center";
import { addOfflineHostAndReload } from "./helpers/hosts";
import { seedWorkspace } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
const PRIMARY_HOST_LABEL = "Primary Host";
const SECONDARY_HOST_ID = "host-command-center-secondary";
test.describe("Command center host labels", () => {
test.describe.configure({ timeout: 180_000 });
test("agent results show the host they live on when more than one host exists", async ({
page,
}) => {
const seeded = await seedWorkspace({ repoPrefix: "command-center-host-" });
const title = `cc-host-${randomUUID().slice(0, 8)}`;
try {
const agent = await createIdleAgent(seeded.client, {
cwd: seeded.repoPath,
workspaceId: seeded.workspaceId,
title,
});
// A second (offline) host makes the view multi-host, which is when the host label earns its space.
await gotoAppShell(page);
await addOfflineHostAndReload(page, {
serverId: SECONDARY_HOST_ID,
label: "Secondary Host",
primaryLabel: PRIMARY_HOST_LABEL,
});
const panel = await openCommandCenter(page);
// The shared daemon may carry agents from other specs, so target this agent by its id.
const row = panel.getByTestId(`command-center-agent-${getServerId()}:${agent.id}`);
await expect(row).toBeVisible({ timeout: 30_000 });
await expect(row).toContainText(title);
await expect(row).toContainText(PRIMARY_HOST_LABEL);
} finally {
await seeded.cleanup();
}
});
});

View File

@@ -1,3 +1,5 @@
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "./fixtures";
import { composerLocator, expectComposerVisible } from "./helpers/composer";
import {
@@ -209,6 +211,25 @@ async function openReadyMockAgent(
}
}
async function seedDotPrefixedWorkspaceFiles(cwd: string): Promise<void> {
await writeFile(path.join(cwd, ".env.local"), "PASEO_E2E=1\n");
await mkdir(path.join(cwd, ".opencode"), { recursive: true });
await writeFile(path.join(cwd, ".opencode", "settings.json"), "{}\n");
}
async function expectFileMentionSuggestion(
page: Page,
query: string,
suggestion: string,
): Promise<void> {
const input = composerLocator(page);
await input.fill(query);
const popover = page.getByTestId("composer-autocomplete-popover");
await expect(popover.getByText(suggestion, { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
}
async function visiblePopoverBox(
page: Page,
): Promise<{ top: number; bottom: number; height: number }> {
@@ -519,6 +540,28 @@ test.describe("Composer autocomplete", () => {
}
});
test("suggests dot-prefixed workspace entries for file mentions", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "autocomplete-dot-entries-",
title: "Dot file mention autocomplete",
});
try {
await seedDotPrefixedWorkspaceFiles(session.cwd);
await openAgentRoute(page, session);
await expectWorkspaceTabVisible(page, session.agentId);
await expectComposerVisible(page);
const input = composerLocator(page);
await expect(input).toBeEditable({ timeout: 30_000 });
await expectFileMentionSuggestion(page, "@.env", ".env.local");
await expectFileMentionSuggestion(page, "@.opencode", ".opencode/settings.json");
} finally {
await session.cleanup();
}
});
test("stays anchored to the composer when the desktop sidebar is open", async ({ page }) => {
await installListCommandsStub(page);
const agent = await openReadyMockAgent(page);

View File

@@ -4,8 +4,11 @@ import { getServerId } from "./helpers/server-id";
import {
loadRealDaemonState,
injectDesktopBridge,
openDesktopAboutSettings,
openDesktopSettings,
expectUpdateBanner,
clickCheckForUpdates,
expectPendingUpdateCheckResult,
clickInstallUpdate,
expectInstallInProgress,
interceptDaemonManagementConfirmDialog,
@@ -45,6 +48,21 @@ test.describe("Desktop updates", () => {
await clickInstallUpdate(page);
await expectInstallInProgress(page);
});
test("manual check reports a found update while it downloads", async ({ page }) => {
await injectDesktopBridge(page, {
serverId: getServerId(),
updateAvailable: true,
latestVersion: "1.2.3",
updateReadyToInstall: false,
});
await gotoAppShell(page);
await openDesktopAboutSettings(page);
await clickCheckForUpdates(page);
await expectPendingUpdateCheckResult(page, "1.2.3");
});
});
test.describe("Desktop daemon management", () => {

View File

@@ -10,6 +10,7 @@ import dotenv from "dotenv";
import { loadDaemonClientConstructor } from "./helpers/daemon-client-loader";
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./helpers/node-ws-factory";
import { forkPaseoHomeMetadata, resolvePaseoHomePath } from "./helpers/paseo-home-fork";
import { withDisabledE2ESpeechEnv } from "./helpers/speech-env";
const wranglerCliPath = path.resolve(__dirname, "../node_modules/wrangler/bin/wrangler.js");
@@ -405,15 +406,6 @@ async function waitForPairingOfferFromDaemon(args: {
);
}
const LOCAL_SPEECH_ENV_KEYS = [
"PASEO_LOCAL_MODELS_DIR",
"PASEO_DICTATION_LOCAL_STT_MODEL",
"PASEO_VOICE_LOCAL_STT_MODEL",
"PASEO_VOICE_LOCAL_TTS_MODEL",
"PASEO_VOICE_LOCAL_TTS_SPEAKER_ID",
"PASEO_VOICE_LOCAL_TTS_SPEED",
] as const;
async function loadEnvTestFile(repoRoot: string): Promise<void> {
const envTestPath = path.join(repoRoot, ".env.test");
if (existsSync(envTestPath)) {
@@ -675,7 +667,7 @@ interface DaemonSpawnArgs {
function startDaemon(args: DaemonSpawnArgs): ChildProcess {
const serverDir = path.resolve(__dirname, "../../..", "packages/server");
const tsxBin = execSync("which tsx").toString().trim();
const env: NodeJS.ProcessEnv = {
const env = withDisabledE2ESpeechEnv({
...process.env,
PATH: `${args.fakeEditorBinDir}${path.delimiter}${process.env.PATH ?? ""}`,
PASEO_HOME: args.paseoHome,
@@ -684,21 +676,9 @@ function startDaemon(args: DaemonSpawnArgs): ChildProcess {
PASEO_LISTEN: `0.0.0.0:${args.port}`,
PASEO_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`,
PASEO_CORS_ORIGINS: `http://localhost:${args.metroPort}`,
// Default app E2E does not cover speech flows. Keep these disabled so
// unrelated tests never start background local-model downloads.
PASEO_DICTATION_ENABLED: "0",
PASEO_VOICE_MODE_ENABLED: "0",
PASEO_DICTATION_STT_PROVIDER: "openai",
PASEO_VOICE_TURN_DETECTION_PROVIDER: "openai",
PASEO_VOICE_STT_PROVIDER: "openai",
PASEO_VOICE_TTS_PROVIDER: "openai",
PASEO_NODE_ENV: "development",
NODE_ENV: "development",
};
for (const key of LOCAL_SPEECH_ENV_KEYS) {
delete env[key];
}
});
const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], {
cwd: serverDir,

View File

@@ -0,0 +1,9 @@
import { expect, type Locator, type Page } from "@playwright/test";
// Opens the command center / global search palette from the sidebar and returns its panel.
export async function openCommandCenter(page: Page): Promise<Locator> {
await page.getByTestId("sidebar-command-center-search").click();
const panel = page.getByTestId("command-center-panel");
await expect(panel).toBeVisible({ timeout: 30_000 });
return panel;
}

View File

@@ -5,6 +5,7 @@ import net from "node:net";
import path from "node:path";
import { getE2EDaemonPort } from "./daemon-port";
import { withDisabledE2ESpeechEnv } from "./speech-env";
/**
* Restarts the isolated E2E daemon against the SAME PASEO_HOME and SAME port so
@@ -93,20 +94,21 @@ function spawnSupervisor(args: {
// inside the Playwright worker (the shim is a .mjs symlink, not an executable),
// so resolve the CLI module and load it with node.
const tsxCli = createRequire(path.join(serverDir, "package.json")).resolve("tsx/cli");
const env = withDisabledE2ESpeechEnv({
...process.env,
PASEO_HOME: args.paseoHome,
PASEO_E2E_EDITOR_RECORD_PATH: args.editorRecordPath,
PASEO_SERVER_ID: "srv_e2e_test_daemon",
PASEO_LISTEN: `0.0.0.0:${args.port}`,
PASEO_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`,
PASEO_CORS_ORIGINS: `http://localhost:${args.metroPort}`,
PASEO_NODE_ENV: "development",
NODE_ENV: "development",
});
const child = spawn(process.execPath, [tsxCli, "scripts/supervisor-entrypoint.ts", "--dev"], {
cwd: serverDir,
env: {
...process.env,
PASEO_HOME: args.paseoHome,
PASEO_E2E_EDITOR_RECORD_PATH: args.editorRecordPath,
PASEO_SERVER_ID: "srv_e2e_test_daemon",
PASEO_LISTEN: `0.0.0.0:${args.port}`,
PASEO_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`,
PASEO_CORS_ORIGINS: `http://localhost:${args.metroPort}`,
PASEO_NODE_ENV: "development",
NODE_ENV: "development",
},
env,
stdio: ["ignore", "pipe", "pipe"],
detached: false,
});

View File

@@ -3,7 +3,8 @@ import { appendFile } from "node:fs/promises";
import { expect, type Page } from "@playwright/test";
import { openSettings } from "./app";
import { getE2EDaemonPort } from "./daemon-port";
import { openSettingsHost, openSettingsHostSection } from "./settings";
import { escapeRegex } from "./regex";
import { openSettingsHost, openSettingsHostSection, openSettingsSection } from "./settings";
interface DaemonApiStatus {
version: string;
@@ -52,6 +53,7 @@ export interface DesktopBridgeConfig {
serverId: string;
updateAvailable?: boolean;
latestVersion?: string;
updateReadyToInstall?: boolean;
slowInstall?: boolean;
/** Initial PID reported by desktop_daemon_status. Defaults to null. */
daemonPid?: number | null;
@@ -169,7 +171,7 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
return cfg.updateAvailable
? {
hasUpdate: true,
readyToInstall: true,
readyToInstall: cfg.updateReadyToInstall ?? true,
currentVersion: "1.0.0",
latestVersion: cfg.latestVersion ?? "1.2.3",
body: null,
@@ -276,12 +278,33 @@ export async function openDesktopSettings(page: Page, serverId: string): Promise
});
}
export async function openDesktopAboutSettings(page: Page): Promise<void> {
await openSettings(page);
await openSettingsSection(page, "about");
await expect(page.getByText("App updates", { exact: true })).toBeVisible();
}
export async function expectUpdateBanner(page: Page, version: string): Promise<void> {
const callout = page.getByTestId("update-callout");
await expect(callout).toBeVisible({ timeout: 15_000 });
await expect(callout).toContainText(`v${version.replace(/^v/i, "")}`);
}
export async function clickCheckForUpdates(page: Page): Promise<void> {
await page.getByRole("button", { name: "Check" }).click();
}
export async function expectPendingUpdateCheckResult(page: Page, version: string): Promise<void> {
const normalizedVersion = `v${version.replace(/^v/i, "")}`;
await expect(
page.getByText(
new RegExp(`Update found: ${escapeRegex(normalizedVersion)}\\. Downloading\\.\\.\\.`),
),
).toBeVisible();
await expect(page.getByText(`Ready to install: ${normalizedVersion}`)).toHaveCount(0);
await expect(page.getByRole("button", { name: "Update" })).toBeDisabled();
}
export async function clickInstallUpdate(page: Page): Promise<void> {
await page.getByRole("button", { name: "Install & restart" }).click();
}

View File

@@ -0,0 +1,79 @@
import { expect, type Page } from "@playwright/test";
import { buildSeededHost } from "./daemon-registry";
const REGISTRY_KEY = "@paseo:daemon-registry";
const SEED_NONCE_KEY = "@paseo:e2e-seed-nonce";
const DISABLE_DEFAULT_SEED_ONCE_KEY = "@paseo:e2e-disable-default-seed-once";
// The multi-host UI (the command-center host label, the sidebar host filter) only renders once
// more than one host exists. The e2e harness runs a single real daemon, so we add an extra registry
// entry pointing at an unreachable endpoint: it stays offline, which is enough to make the UI treat
// the view as multi-host without standing up a second daemon.
//
// Must run AFTER the first navigation: the auto-seed fixture writes the registry + nonce on load,
// and reseeds on every navigation. We write the full registry here and set the fixture's
// disable-once flag, then reload — so the fixture skips its reset and the registry survives. This
// avoids depending on the (unspecified) ordering of multiple Playwright init scripts. Optionally
// relabels the seeded primary host so assertions can target a distinctive name.
export async function addOfflineHostAndReload(
page: Page,
input: { serverId: string; label: string; primaryLabel?: string },
): Promise<void> {
const offlineHost = buildSeededHost({
serverId: input.serverId,
label: input.label,
endpoint: "127.0.0.1:59999",
nowIso: new Date().toISOString(),
});
await page.evaluate(
({ host, keys, primaryLabel }) => {
const nonce = localStorage.getItem(keys.nonce);
if (!nonce) {
throw new Error("Expected the e2e seed nonce before overriding the host registry.");
}
const raw = localStorage.getItem(keys.registry);
const registry: Array<{ serverId: string; label?: string }> = raw ? JSON.parse(raw) : [];
if (primaryLabel && registry[0]) {
registry[0].label = primaryLabel;
}
if (!registry.some((entry) => entry.serverId === host.serverId)) {
registry.push(host);
}
localStorage.setItem(keys.registry, JSON.stringify(registry));
localStorage.setItem(keys.disableSeedOnce, nonce);
},
{
host: offlineHost,
keys: {
registry: REGISTRY_KEY,
nonce: SEED_NONCE_KEY,
disableSeedOnce: DISABLE_DEFAULT_SEED_ONCE_KEY,
},
primaryLabel: input.primaryLabel,
},
);
await page.reload();
}
export async function openSidebarDisplayPreferences(page: Page): Promise<void> {
await page.getByTestId("sidebar-display-preferences-menu").click();
await expect(page.getByTestId("sidebar-display-preferences-content")).toBeVisible({
timeout: 10_000,
});
}
// A host's filter row carries a status dot on the left next to its label.
export async function expectHostFilterRow(page: Page, serverId: string): Promise<void> {
await expect(page.getByTestId(`sidebar-host-filter-${serverId}`)).toBeVisible();
await expect(page.getByTestId(`sidebar-host-filter-status-${serverId}`)).toBeVisible();
}
export async function toggleHostFilter(page: Page, serverId: string): Promise<void> {
await page.getByTestId(`sidebar-host-filter-${serverId}`).click();
}
export async function selectAllHostsFilter(page: Page): Promise<void> {
await page.getByTestId("sidebar-host-filter-all").click();
}

View File

@@ -0,0 +1,32 @@
const LOCAL_SPEECH_ENV_KEYS = [
"PASEO_LOCAL_MODELS_DIR",
"PASEO_DICTATION_LOCAL_STT_MODEL",
"PASEO_VOICE_LOCAL_STT_MODEL",
"PASEO_VOICE_LOCAL_TTS_MODEL",
"PASEO_VOICE_LOCAL_TTS_SPEAKER_ID",
"PASEO_VOICE_LOCAL_TTS_SPEED",
] as const;
const DISABLED_E2E_SPEECH_ENV = {
PASEO_DICTATION_ENABLED: "0",
PASEO_VOICE_MODE_ENABLED: "0",
PASEO_DICTATION_STT_PROVIDER: "openai",
PASEO_VOICE_TURN_DETECTION_PROVIDER: "openai",
PASEO_VOICE_STT_PROVIDER: "openai",
PASEO_VOICE_TTS_PROVIDER: "openai",
} as const;
export function withDisabledE2ESpeechEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
// Default app E2E does not cover speech flows; keep restarts from starting
// background local-model downloads for unrelated tests.
const next: NodeJS.ProcessEnv = {
...env,
...DISABLED_E2E_SPEECH_ENV,
};
for (const key of LOCAL_SPEECH_ENV_KEYS) {
delete next[key];
}
return next;
}

View File

@@ -0,0 +1,56 @@
import { expect } from "@playwright/test";
import { test } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import {
addOfflineHostAndReload,
expectHostFilterRow,
openSidebarDisplayPreferences,
selectAllHostsFilter,
toggleHostFilter,
} from "./helpers/hosts";
import { seedWorkspace } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
const SECONDARY_HOST_ID = "host-filter-secondary";
test.describe("Sidebar host filter (multi-select)", () => {
test.describe.configure({ timeout: 120_000 });
test("pins the sidebar to multiple selected hosts at once", async ({ page }) => {
const seeded = await seedWorkspace({ repoPrefix: "host-filter-" });
const serverId = getServerId();
const workspaceRow = page.getByTestId(
`sidebar-workspace-row-${serverId}:${seeded.workspaceId}`,
);
try {
// A second (offline) host is enough to surface the host filter without a second daemon.
await gotoAppShell(page);
await addOfflineHostAndReload(page, { serverId: SECONDARY_HOST_ID, label: "Secondary Host" });
await expect(workspaceRow).toBeVisible({ timeout: 30_000 });
await openSidebarDisplayPreferences(page);
await expectHostFilterRow(page, serverId);
await expectHostFilterRow(page, SECONDARY_HOST_ID);
// Pin the primary host — its workspace stays visible.
await toggleHostFilter(page, serverId);
await expect(workspaceRow).toBeVisible();
// Add the secondary host without clearing the primary. Under single-select this would replace
// the primary and hide the workspace; multi-select keeps both pinned, so it stays visible.
await toggleHostFilter(page, SECONDARY_HOST_ID);
await expect(workspaceRow).toBeVisible();
// Drop the primary host — only the (empty) secondary host remains pinned, so the workspace hides.
await toggleHostFilter(page, serverId);
await expect(workspaceRow).toHaveCount(0, { timeout: 10_000 });
// Back to all hosts — the workspace returns.
await selectAllHostsFilter(page);
await expect(workspaceRow).toBeVisible({ timeout: 10_000 });
} finally {
await seeded.cleanup();
}
});
});

View File

@@ -10,6 +10,7 @@ import { buildHostWorkspaceRoute, decodeWorkspaceIdFromPathSegment } from "@/uti
import { buildSeededHost } from "./helpers/daemon-registry";
import { loadDaemonClientConstructor } from "./helpers/daemon-client-loader";
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./helpers/node-ws-factory";
import { withDisabledE2ESpeechEnv } from "./helpers/speech-env";
import {
expectNewWorkspaceProjectSelected,
openGlobalNewWorkspaceComposer,
@@ -243,18 +244,16 @@ async function startRestartDaemon(input: {
const tsxBin = execSync("which tsx").toString().trim();
const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], {
cwd: serverDir,
env: {
env: withDisabledE2ESpeechEnv({
...process.env,
PASEO_HOME: input.paseoHome,
PASEO_SERVER_ID: SERVER_ID,
PASEO_LISTEN: `127.0.0.1:${port}`,
PASEO_CORS_ORIGINS: input.origin,
PASEO_RELAY_ENABLED: "0",
PASEO_DICTATION_ENABLED: "0",
PASEO_VOICE_MODE_ENABLED: "0",
PASEO_NODE_ENV: "development",
NODE_ENV: "development",
},
}),
stdio: ["ignore", "ignore", "pipe"],
detached: false,
});

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.1.102-beta.2",
"version": "0.1.102",
"private": true,
"main": "index.ts",
"scripts": {

View File

@@ -112,6 +112,26 @@ function footerOwners(layout: StreamLayout): string[] {
return owners;
}
function footerAssistantIds(layout: StreamLayout): string[] {
return [
...layout.history.flatMap((item) =>
item.completedFooter ? [item.completedFooter.itemId] : [],
),
...layout.liveHead.flatMap((item) =>
item.completedFooter ? [item.completedFooter.itemId] : [],
),
...(layout.auxiliaryTurnFooter ? [layout.auxiliaryTurnFooter.itemId] : []),
];
}
function inlineFooterPlacementByItemId(layout: StreamLayout): Record<string, string> {
return Object.fromEntries(
[...layout.history, ...layout.liveHead].flatMap((item) =>
item.completedFooter ? [[item.item.id, item.completedFooter.itemId]] : [],
),
);
}
function findLayoutItem(layout: StreamLayout, id: string): StreamLayoutItem {
const item = [...layout.history, ...layout.liveHead].find(
(candidate) => candidate.item.id === id,
@@ -291,7 +311,7 @@ describe("layoutStream", () => {
});
it.each(["web", "android"] as const)(
"keeps inline footer on an assistant turn with trailing tool rows before the next user on %s",
"places inline footer after trailing visible tool rows before the next user on %s",
(platform) => {
const assistant = assistantMessage("a1", 2);
const tool = toolCall("tool-1", 3);
@@ -302,13 +322,36 @@ describe("layoutStream", () => {
});
expect(layout.auxiliaryTurnFooter).toBeNull();
expect(findLayoutItem(layout, assistant.id).completedFooter?.itemId).toBe(assistant.id);
expect(footerOwners(layout)).toEqual([assistant.id]);
expect(findLayoutItem(layout, assistant.id).completedFooter).toBeNull();
expect(findLayoutItem(layout, tool.id).completedFooter?.itemId).toBe(assistant.id);
expect(footerOwners(layout)).toEqual([tool.id]);
expect(footerAssistantIds(layout)).toEqual([assistant.id]);
},
);
it.each(["web", "android"] as const)(
"uses the latest assistant for an inline footer when a turn has multiple assistant blocks on %s",
"places split live-head tool footer using the assistant from history on %s",
(platform) => {
const assistant = assistantMessage("a1", 2);
const tool = toolCall("tool-1", 3);
const layout = layoutFor({
platform,
tail: [userMessage("u1", 1), assistant],
head: [tool, userMessage("u2", 4)],
timingIds: [assistant.id],
});
expect(layout.auxiliaryTurnFooter).toBeNull();
expect(findLayoutItem(layout, assistant.id).completedFooter).toBeNull();
expect(findLayoutItem(layout, tool.id).completedFooter?.itemId).toBe(assistant.id);
expect(inlineFooterPlacementByItemId(layout)).toEqual({
[tool.id]: assistant.id,
});
},
);
it.each(["web", "android"] as const)(
"uses the latest assistant for footer content while placing after the visible turn end on %s",
(platform) => {
const firstAssistant = assistantMessage("a1", 2);
const firstTool = toolCall("tool-1", 3);
@@ -329,10 +372,46 @@ describe("layoutStream", () => {
expect(layout.auxiliaryTurnFooter).toBeNull();
expect(findLayoutItem(layout, firstAssistant.id).completedFooter).toBeNull();
expect(findLayoutItem(layout, latestAssistant.id).completedFooter?.itemId).toBe(
expect(findLayoutItem(layout, latestAssistant.id).completedFooter).toBeNull();
expect(findLayoutItem(layout, latestTool.id).completedFooter?.itemId).toBe(
latestAssistant.id,
);
expect(footerOwners(layout)).toEqual([latestAssistant.id]);
expect(footerOwners(layout)).toEqual([latestTool.id]);
expect(footerAssistantIds(layout)).toEqual([latestAssistant.id]);
},
);
it.each(["web", "android"] as const)(
"keeps every completed turn footer while placing each one after that turn's last visible item on %s",
(platform) => {
const firstAssistant = assistantMessage("a1", 2);
const secondAssistant = assistantMessage("a2", 4);
const secondTool = toolCall("tool-2", 5);
const layout = layoutFor({
platform,
tail: [
userMessage("u1", 1),
firstAssistant,
userMessage("u2", 3),
secondAssistant,
secondTool,
userMessage("u3", 6),
],
timingIds: [firstAssistant.id, secondAssistant.id],
});
expect(layout.auxiliaryTurnFooter).toBeNull();
expect(findLayoutItem(layout, firstAssistant.id).completedFooter?.itemId).toBe(
firstAssistant.id,
);
expect(findLayoutItem(layout, secondAssistant.id).completedFooter).toBeNull();
expect(findLayoutItem(layout, secondTool.id).completedFooter?.itemId).toBe(
secondAssistant.id,
);
expect(inlineFooterPlacementByItemId(layout)).toEqual({
[firstAssistant.id]: firstAssistant.id,
[secondTool.id]: secondAssistant.id,
});
},
);

View File

@@ -44,7 +44,6 @@ export interface StreamLayoutInput {
interface LayoutSegmentInput {
strategy: StreamStrategy;
agentStatus: string;
items: StreamItem[];
timingByAssistantId: Map<string, TurnTiming>;
auxiliaryTurnFooter: TurnFooterHost | null;
@@ -52,6 +51,14 @@ interface LayoutSegmentInput {
boundaryIndex: number | null;
boundaryAboveItem: StreamItem | null;
boundaryBelowItem: StreamItem | null;
boundaryAboveItems: StreamItem[] | null;
boundaryAboveIndex: number | null;
}
interface AssistantFooterSource {
item: Extract<StreamItem, { kind: "assistant_message" }>;
items: StreamItem[];
index: number;
}
function createTurnFooterHost(input: {
@@ -68,25 +75,45 @@ function createTurnFooterHost(input: {
};
}
function findLatestAssistantIndexInTurn(input: {
function findLatestAssistantInTurn(input: {
strategy: StreamStrategy;
items: StreamItem[];
startIndex: number;
}): number | null {
for (
let index = input.startIndex;
index >= 0 && index < input.items.length;
index = input.strategy.getNeighborIndex(index, "above")
) {
const item = input.items[index];
if (!item || item.kind === "user_message") {
boundaryAboveItems?: StreamItem[] | null;
boundaryAboveIndex?: number | null;
}): AssistantFooterSource | null {
let items = input.items;
let index = input.startIndex;
let canCrossBoundary = true;
while (true) {
for (
;
index >= 0 && index < items.length;
index = input.strategy.getNeighborIndex(index, "above")
) {
const item = items[index];
if (!item || item.kind === "user_message") {
return null;
}
if (item.kind === "assistant_message") {
return { item, items, index };
}
}
if (
!canCrossBoundary ||
!input.boundaryAboveItems ||
input.boundaryAboveIndex === null ||
input.boundaryAboveIndex === undefined
) {
return null;
}
if (item.kind === "assistant_message") {
return index;
}
items = input.boundaryAboveItems;
index = input.boundaryAboveIndex;
canCrossBoundary = false;
}
return null;
}
function resolveAuxiliaryTurnFooter(input: StreamLayoutInput): TurnFooterHost | null {
@@ -100,104 +127,54 @@ function resolveAuxiliaryTurnFooter(input: StreamLayoutInput): TurnFooterHost |
return null;
}
const assistantIndex = findLatestAssistantIndexInTurn({
const assistant = findLatestAssistantInTurn({
strategy: input.strategy,
items: footerItems,
startIndex: latestIndex,
});
if (assistantIndex === null) {
return null;
}
const item = footerItems[assistantIndex];
if (!item || item.kind !== "assistant_message") {
if (!assistant) {
return null;
}
return createTurnFooterHost({
item,
items: footerItems,
index: assistantIndex,
item: assistant.item,
items: assistant.items,
index: assistant.index,
timingByAssistantId: input.timingByAssistantId,
});
}
function findTurnEndIndexInSegment(input: {
strategy: StreamStrategy;
items: StreamItem[];
startIndex: number;
}): number {
let endIndex = input.startIndex;
for (
let index = input.strategy.getNeighborIndex(input.startIndex, "below");
index >= 0 && index < input.items.length;
index = input.strategy.getNeighborIndex(index, "below")
) {
const item = input.items[index];
if (!item || item.kind === "user_message") {
break;
}
endIndex = index;
}
return endIndex;
}
function shouldRenderCompletedFooter(input: {
function resolveCompletedFooter(input: {
strategy: StreamStrategy;
items: StreamItem[];
index: number;
item: StreamItem;
belowItem: StreamItem | null;
agentStatus: string;
timingByAssistantId: Map<string, TurnTiming>;
auxiliaryTurnFooter: TurnFooterHost | null;
boundaryIndex: number | null;
boundaryBelowItem: StreamItem | null;
}): boolean {
if (
input.item.kind !== "assistant_message" ||
input.auxiliaryTurnFooter?.itemId === input.item.id
) {
return false;
boundaryAboveItems: StreamItem[] | null;
boundaryAboveIndex: number | null;
}): TurnFooterHost | null {
if (input.item.kind === "user_message" || input.belowItem?.kind !== "user_message") {
return null;
}
if (
input.belowItem?.kind === "user_message" ||
(input.belowItem === null && input.agentStatus !== "running")
) {
return true;
}
if (!isToolSequenceItem(input.belowItem)) {
return false;
}
const sameSegmentBelowItem = input.strategy.getNeighborItem(input.items, input.index, "below");
if (sameSegmentBelowItem?.id !== input.belowItem.id) {
return false;
}
const turnEndIndex = findTurnEndIndexInSegment({
const assistant = findLatestAssistantInTurn({
strategy: input.strategy,
items: input.items,
startIndex: input.index,
boundaryAboveItems: input.boundaryAboveItems,
boundaryAboveIndex: input.boundaryAboveIndex,
});
const belowTurnItem = getSegmentNeighbor({
strategy: input.strategy,
items: input.items,
index: turnEndIndex,
relation: "below",
boundaryIndex: input.boundaryIndex,
boundaryItem: input.boundaryBelowItem,
});
if (input.agentStatus === "running" && belowTurnItem?.kind !== "user_message") {
return false;
if (!assistant || input.auxiliaryTurnFooter?.itemId === assistant.item.id) {
return null;
}
const assistantIndex = findLatestAssistantIndexInTurn({
strategy: input.strategy,
items: input.items,
startIndex: turnEndIndex,
return createTurnFooterHost({
item: assistant.item,
items: assistant.items,
index: assistant.index,
timingByAssistantId: input.timingByAssistantId,
});
return assistantIndex === input.index;
}
function isToolSequenceItem(
@@ -270,24 +247,17 @@ function layoutSegment(input: LayoutSegmentInput): StreamLayoutItem[] {
aboveItem,
belowItem,
});
const completedFooter = shouldRenderCompletedFooter({
const completedFooter = resolveCompletedFooter({
strategy: input.strategy,
items: input.items,
index,
item,
belowItem,
agentStatus: input.agentStatus,
timingByAssistantId: input.timingByAssistantId,
auxiliaryTurnFooter: input.auxiliaryTurnFooter,
boundaryIndex: input.boundaryIndex,
boundaryBelowItem: input.boundaryBelowItem,
})
? createTurnFooterHost({
item,
items: input.items,
index,
timingByAssistantId: input.timingByAssistantId,
})
: null;
boundaryAboveItems: input.boundaryAboveItems,
boundaryAboveIndex: input.boundaryAboveIndex,
});
return {
item,
@@ -328,7 +298,6 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
// and .kind are stable across text-only flushes (text growth doesn't change what kind of
// item borders history), so cached layout stays valid between flushes.
const historyCacheKey = [
input.agentStatus,
frameOrder,
historyBoundaryIndex ?? "null",
liveHeadBoundaryItem?.id ?? "null",
@@ -346,7 +315,6 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
} else {
history = layoutSegment({
strategy: input.strategy,
agentStatus: input.agentStatus,
items: input.history,
timingByAssistantId: input.timingByAssistantId,
auxiliaryTurnFooter,
@@ -354,6 +322,8 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
boundaryIndex: historyBoundaryIndex,
boundaryAboveItem: null,
boundaryBelowItem: liveHeadBoundaryItem,
boundaryAboveItems: null,
boundaryAboveIndex: null,
});
byKey.set(historyCacheKey, history);
}
@@ -363,7 +333,6 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
const liveHead = layoutSegment({
strategy: input.strategy,
agentStatus: input.agentStatus,
items: input.liveHead,
timingByAssistantId: input.timingByAssistantId,
auxiliaryTurnFooter,
@@ -371,6 +340,8 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
boundaryIndex: liveHeadBoundaryIndex,
boundaryAboveItem: historyBoundaryItem,
boundaryBelowItem: null,
boundaryAboveItems: input.history,
boundaryAboveIndex: historyBoundaryIndex,
});
return {

View File

@@ -13,6 +13,7 @@ import { Home, Plus, Settings } from "lucide-react-native";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
import { useCommandCenter } from "@/hooks/use-command-center";
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useHosts } from "@/runtime/host-runtime";
import { formatTimeAgo } from "@/utils/time";
import { shortenPath } from "@/utils/shorten-path";
import { AgentStatusDot } from "@/components/agent-status-dot";
@@ -199,9 +200,10 @@ function CommandCenterAgentRow({
interface CommandCenterAgentRowContentProps {
agent: AggregatedAgent;
showHost: boolean;
}
function CommandCenterAgentRowContent({ agent }: CommandCenterAgentRowContentProps) {
function CommandCenterAgentRowContent({ agent, showHost }: CommandCenterAgentRowContentProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const titleStyle = useMemo(
@@ -213,7 +215,7 @@ function CommandCenterAgentRowContent({ agent }: CommandCenterAgentRowContentPro
[theme.colors.foregroundMuted],
);
return (
<View style={styles.rowContent}>
<View style={styles.rowContent} testID={`command-center-agent-${agent.serverId}:${agent.id}`}>
<View style={styles.rowMain}>
<View style={styles.iconSlot}>
<AgentStatusDot
@@ -226,7 +228,8 @@ function CommandCenterAgentRowContent({ agent }: CommandCenterAgentRowContentPro
<Text style={titleStyle} numberOfLines={1}>
{agent.title || t("shell.commandCenter.newAgent")}
</Text>
<Text style={subtitleStyle} numberOfLines={1}>
<Text style={subtitleStyle} numberOfLines={1} testID="command-center-agent-subtitle">
{showHost ? `${agent.serverLabel} · ` : ""}
{shortenPath(agent.cwd)} · {formatTimeAgo(agent.lastActivityAt)}
</Text>
</View>
@@ -246,6 +249,7 @@ interface AgentItemsSectionProps {
onSelect: (item: ReturnType<typeof useCommandCenter>["items"][number]) => void;
sectionDividerStyle: React.ComponentProps<typeof View>["style"];
sectionLabelStyle: React.ComponentProps<typeof Text>["style"];
showHost: boolean;
}
function AgentItemsSection({
@@ -257,6 +261,7 @@ function AgentItemsSection({
onSelect,
sectionDividerStyle,
sectionLabelStyle,
showHost,
}: AgentItemsSectionProps) {
const { t } = useTranslation();
@@ -277,7 +282,7 @@ function AgentItemsSection({
onLayout={onRowLayout(rowIndex)}
onSelect={onSelect}
>
<CommandCenterAgentRowContent agent={agent} />
<CommandCenterAgentRowContent agent={agent} showHost={showHost} />
</CommandCenterAgentRow>
);
})}
@@ -302,6 +307,8 @@ export function CommandCenter() {
const isCompact = useIsCompactFormFactor();
const showBottomSheet = isCompact && isNative;
// Host names only earn their space once results can span more than one host.
const showHost = useHosts().length > 1;
const rowRefs = useRef<Map<number, View>>(new Map());
const rowLayouts = useRef<Map<number, { y: number; height: number }>>(new Map());
@@ -477,6 +484,7 @@ export function CommandCenter() {
onSelect={handleSelectItem}
sectionDividerStyle={sectionDividerStyle}
sectionLabelStyle={sectionLabelStyle}
showHost={showHost}
/>
) : null}
</>

View File

@@ -256,7 +256,7 @@ function WorkspaceSelectionProbe({
function SidebarFrameProbe({ counts }: { counts: RenderCounts }): ReactElement {
counts.frame += 1;
const { projects } = useSidebarWorkspacesList({ hostFilter: SERVER_ID });
const { projects } = useSidebarWorkspacesList({ hostFilters: [SERVER_ID] });
return (
<>

View File

@@ -1,4 +1,4 @@
import { useCallback } from "react";
import { useCallback, useMemo } from "react";
import { Text, View, type PressableStateCallbackType } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Settings2 } from "lucide-react-native";
@@ -10,11 +10,11 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { HostStatusDot } from "@/components/host-status-dot";
import { isWeb as platformIsWeb } from "@/constants/platform";
import { useAppSettings, type WorkspaceTitleSource } from "@/hooks/use-settings";
import { useHosts, useHostRuntimeSnapshot } from "@/runtime/host-runtime";
import { useHosts } from "@/runtime/host-runtime";
import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store";
import { formatConnectionStatus } from "@/utils/daemons";
const ThemedSettings2 = withUnistyles(Settings2);
const filterColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
@@ -36,9 +36,10 @@ interface DisplayPreferenceOption<Value extends string> {
export function SidebarDisplayPreferencesMenu() {
const groupMode = useSidebarViewStore((state) => state.groupMode);
const hostFilter = useSidebarViewStore((state) => state.hostFilter);
const hostFilters = useSidebarViewStore((state) => state.hostFilters);
const setGroupMode = useSidebarViewStore((state) => state.setGroupMode);
const setHostFilter = useSidebarViewStore((state) => state.setHostFilter);
const toggleHostFilter = useSidebarViewStore((state) => state.toggleHostFilter);
const clearHostFilters = useSidebarViewStore((state) => state.clearHostFilters);
const hosts = useHosts();
const {
settings: { workspaceTitleSource },
@@ -52,13 +53,6 @@ export function SidebarDisplayPreferencesMenu() {
[setGroupMode],
);
const handleSelectHost = useCallback(
(serverId: string | null) => {
setHostFilter(serverId);
},
[setHostFilter],
);
const handleWorkspaceTitleSourceSelect = useCallback(
(source: WorkspaceTitleSource) => {
void updateSettings({ workspaceTitleSource: source });
@@ -75,6 +69,7 @@ export function SidebarDisplayPreferencesMenu() {
);
const showHostFilter = hosts.length > 1;
const allHostsSelected = hostFilters.length === 0;
return (
<DropdownMenu>
@@ -105,20 +100,21 @@ export function SidebarDisplayPreferencesMenu() {
<View style={styles.menuHeader}>
<Text style={styles.menuHeaderLabel}>Filter</Text>
</View>
<HostFilterItem
label="All hosts"
value={null}
hostFilter={hostFilter}
onSelect={handleSelectHost}
/>
<DropdownMenuItem
testID="sidebar-host-filter-all"
selected={allHostsSelected}
closeOnSelect={false}
onSelect={clearHostFilters}
>
All hosts
</DropdownMenuItem>
{hosts.map((host) => (
<HostFilterItem
key={host.serverId}
label={host.label?.trim() || host.serverId}
serverId={host.serverId}
value={host.serverId}
hostFilter={hostFilter}
onSelect={handleSelectHost}
selected={hostFilters.includes(host.serverId)}
onToggle={toggleHostFilter}
/>
))}
</>
@@ -167,25 +163,32 @@ function DisplayPreferenceMenuItem<Value extends string>({
function HostFilterItem({
label,
serverId,
value,
hostFilter,
onSelect,
selected,
onToggle,
}: {
label: string;
serverId?: string;
value: string | null;
hostFilter: string | null;
onSelect: (serverId: string | null) => void;
serverId: string;
selected: boolean;
onToggle: (serverId: string) => void;
}) {
const isSelected = hostFilter === value;
const handleSelect = useCallback(() => onSelect(value), [value, onSelect]);
const status = useHostRuntimeSnapshot(serverId ?? "");
const subtitle = serverId
? formatConnectionStatus(status?.connectionStatus ?? "idle")
: undefined;
const handleSelect = useCallback(() => onToggle(serverId), [serverId, onToggle]);
const leading = useMemo(
() => (
<View testID={`sidebar-host-filter-status-${serverId}`}>
<HostStatusDot serverId={serverId} />
</View>
),
[serverId],
);
return (
<DropdownMenuItem selected={isSelected} description={subtitle} onSelect={handleSelect}>
<DropdownMenuItem
testID={`sidebar-host-filter-${serverId}`}
selected={selected}
closeOnSelect={false}
leading={leading}
onSelect={handleSelect}
>
{label}
</DropdownMenuItem>
);

View File

@@ -86,7 +86,7 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
});
useSidebarViewStore.setState({
groupMode: "project",
hostFilter: null,
hostFilters: [],
});
act(() => {
@@ -216,7 +216,7 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
);
useSessionStore.getState().setHasHydratedWorkspaces("host-a", true);
useSessionStore.getState().setHasHydratedWorkspaces("host-b", true);
useSidebarViewStore.getState().setHostFilter("host-b");
useSidebarViewStore.getState().toggleHostFilter("host-b");
});
await act(async () => {

View File

@@ -159,10 +159,10 @@ const CATALOG_DATA = [
id: "factory-droid",
title: "Factory Droid",
description: "Factory Droid - AI coding agent powered by Factory AI",
version: "0.159.1",
version: "0.161.0",
iconId: "factory-droid",
installLink: "https://factory.ai/product/cli",
command: ["npx", "-y", "droid@0.159.1", "exec", "--output-format", "acp-daemon"],
command: ["npx", "-y", "droid@0.161.0", "exec", "--output-format", "acp-daemon"],
env: {
DROID_DISABLE_AUTO_UPDATE: "true",
FACTORY_DROID_AUTO_UPDATE_ENABLED: "false",
@@ -173,10 +173,10 @@ const CATALOG_DATA = [
id: "fast-agent",
title: "fast-agent",
description: "Code and build agents with comprehensive multi-provider support",
version: "0.8.0",
version: "0.8.1",
iconId: "fast-agent",
installLink: "https://fast-agent.ai/acp/",
command: ["uvx", "--from", "fast-agent-acp==0.8.0", "fast-agent-acp", "-x"],
command: ["uvx", "--from", "fast-agent-acp==0.8.1", "fast-agent-acp", "-x"],
},
{
id: "gemini",
@@ -284,10 +284,10 @@ const CATALOG_DATA = [
id: "nova",
title: "Nova",
description: "Nova by Compass AI - a fully-fledged software engineer at your command",
version: "1.1.21",
version: "1.1.22",
iconId: "nova",
installLink: "https://www.compassap.ai/portfolio/nova.html",
command: ["npx", "-y", "@compass-ai/nova@1.1.21", "acp"],
command: ["npx", "-y", "@compass-ai/nova@1.1.22", "acp"],
},
{
id: "poolside",

View File

@@ -114,15 +114,17 @@ describe("desktop app updater — check", () => {
});
});
it("reports 'pending' when the check resolves with an update that is not yet downloaded", async () => {
it("reports the found update while it is still preparing", async () => {
const { updater, port } = createUpdater();
port.nextCheckResult(buildFakeCheckResult({ hasUpdate: true, readyToInstall: false }));
port.nextCheckResult(
buildFakeCheckResult({ hasUpdate: true, readyToInstall: false, latestVersion: "1.2.3" }),
);
await updater.checkForUpdates({ releaseChannel: "stable" });
expect(updater.getSnapshot()).toMatchObject({
status: "pending",
availableUpdate: null,
availableUpdate: { latestVersion: "1.2.3", readyToInstall: false },
});
});
@@ -408,17 +410,17 @@ describe("formatStatusText", () => {
).toBe("Update ready: v1.2.3");
});
it("keeps manual check feedback visible while an update is pending", () => {
it("shows the found version while an update is pending", () => {
expect(
formatStatusText({
status: "pending",
availableUpdate: null,
availableUpdate: buildFakeCheckResult({ latestVersion: "1.2.3" }),
installMessage: null,
lastCheckedAt: 42,
formatVersion,
formatLastCheckedAt,
}),
).toBe("We'll let you know when the update is ready. Last checked at time-42.");
).toBe("Update found: v1.2.3. Downloading... Last checked at time-42.");
});
it("keeps manual check feedback visible when an update is available", () => {

View File

@@ -137,6 +137,18 @@ export function formatStatusText(input: {
}
if (status === "pending") {
if (availableUpdate?.latestVersion) {
return i18n.t(
lastCheckedAt != null
? "desktop.updates.status.pendingWithVersionAndLastChecked"
: "desktop.updates.status.pendingWithVersion",
{
version: formatVersion(availableUpdate.latestVersion),
time: lastCheckedAt != null ? formatLastCheckedAt(lastCheckedAt) : undefined,
},
);
}
if (lastCheckedAt != null) {
return i18n.t("desktop.updates.status.pendingWithLastChecked", {
time: formatLastCheckedAt(lastCheckedAt),
@@ -244,7 +256,7 @@ export function createDesktopAppUpdater(deps: DesktopAppUpdaterDeps): DesktopApp
nextAvailable = result;
} else if (result.hasUpdate) {
nextStatus = "pending";
nextAvailable = null;
nextAvailable = result;
} else {
nextStatus = "up-to-date";
nextAvailable = null;

View File

@@ -89,7 +89,7 @@ export interface SidebarWorkspacesListResult {
}
export function useSidebarWorkspacesList(options?: {
hostFilter?: string | null;
hostFilters?: readonly string[];
enabled?: boolean;
}): SidebarWorkspacesListResult {
const runtime = getHostRuntimeStore();
@@ -97,27 +97,31 @@ export function useSidebarWorkspacesList(options?: {
const hostRegistryLoaded = useHostRegistryLoaded();
const allServerIds = useMemo(() => allHosts.map((h) => h.serverId), [allHosts]);
const storeHostFilter = useSidebarViewStore((state) => state.hostFilter);
const hostFilter = options?.hostFilter ?? storeHostFilter;
const reconcileHostFilter = useSidebarViewStore((state) => state.reconcileHostFilter);
const hasHostFilterMatch = hostFilter ? allServerIds.includes(hostFilter) : false;
const effectiveHostFilter =
hostFilter && (!hostRegistryLoaded || hasHostFilterMatch) ? hostFilter : null;
const storeHostFilters = useSidebarViewStore((state) => state.hostFilters);
const hostFilters = options?.hostFilters ?? storeHostFilters;
const reconcileHostFilters = useSidebarViewStore((state) => state.reconcileHostFilters);
const isActive = options?.enabled !== false;
const serverIds = useMemo(() => {
if (effectiveHostFilter) {
return allServerIds.filter((id) => id === effectiveHostFilter);
if (hostFilters.length === 0) {
return allServerIds;
}
return allServerIds;
}, [allServerIds, effectiveHostFilter]);
const selected = new Set(hostFilters);
const matched = allServerIds.filter((id) => selected.has(id));
// Registry has settled but none of the pinned hosts still exist — fall back to every
// host rather than leaving the sidebar empty.
if (hostRegistryLoaded && matched.length === 0) {
return allServerIds;
}
return matched;
}, [allServerIds, hostFilters, hostRegistryLoaded]);
useEffect(() => {
if (!hostRegistryLoaded) {
return;
}
reconcileHostFilter(allServerIds);
}, [allServerIds, hostRegistryLoaded, reconcileHostFilter]);
reconcileHostFilters(allServerIds);
}, [allServerIds, hostRegistryLoaded, reconcileHostFilters]);
const persistedProjectOrder = useSidebarOrderStore((state) => state.projectOrder ?? EMPTY_ORDER);

View File

@@ -524,6 +524,12 @@ describe("translation resources", () => {
expect(en.desktop.updates.status.pendingWithLastChecked).toBe(
"We'll let you know when the update is ready. Last checked at {{time}}.",
);
expect(en.desktop.updates.status.pendingWithVersion).toBe(
"Update found: {{version}}. Downloading...",
);
expect(en.desktop.updates.status.pendingWithVersionAndLastChecked).toBe(
"Update found: {{version}}. Downloading... Last checked at {{time}}.",
);
expect(en.desktop.updates.status.availableWithVersion).toBe("Update ready: {{version}}");
expect(en.desktop.updates.status.availableWithVersionAndLastChecked).toBe(
"Update ready: {{version}}. Last checked at {{time}}.",

View File

@@ -939,6 +939,9 @@ export const ar: TranslationResources = {
upToDateWithLastChecked: "Up to date. Last checked at {{time}}.",
pending: "سنخبرك عندما يصبح التحديث جاهزًا.",
pendingWithLastChecked: "سنخبرك عندما يصبح التحديث جاهزًا. آخر فحص في {{time}}.",
pendingWithVersion: "تم العثور على تحديث: {{version}}. جارٍ التنزيل...",
pendingWithVersionAndLastChecked:
"تم العثور على تحديث: {{version}}. جارٍ التنزيل... آخر فحص في {{time}}.",
availableWithVersion: "التحديث جاهز:{{version}}",
availableWithVersionAndLastChecked: "التحديث جاهز:{{version}}. آخر فحص في {{time}}.",
available: "تحديث التطبيق جاهز للتثبيت.",

View File

@@ -947,6 +947,9 @@ export const en = {
pending: "We'll let you know when the update is ready.",
pendingWithLastChecked:
"We'll let you know when the update is ready. Last checked at {{time}}.",
pendingWithVersion: "Update found: {{version}}. Downloading...",
pendingWithVersionAndLastChecked:
"Update found: {{version}}. Downloading... Last checked at {{time}}.",
availableWithVersion: "Update ready: {{version}}",
availableWithVersionAndLastChecked: "Update ready: {{version}}. Last checked at {{time}}.",
available: "An app update is ready to install.",

View File

@@ -967,6 +967,9 @@ export const es: TranslationResources = {
pending: "Le avisaremos cuando la actualización esté lista.",
pendingWithLastChecked:
"Le avisaremos cuando la actualización esté lista. Última comprobación a las {{time}}.",
pendingWithVersion: "Actualización encontrada: {{version}}. Descargando...",
pendingWithVersionAndLastChecked:
"Actualización encontrada: {{version}}. Descargando... Última comprobación a las {{time}}.",
availableWithVersion: "Actualización lista:{{version}}",
availableWithVersionAndLastChecked:
"Actualización lista:{{version}}. Última comprobación a las {{time}}.",

View File

@@ -966,6 +966,9 @@ export const fr: TranslationResources = {
pending: "Nous vous informerons lorsque la mise à jour sera prête.",
pendingWithLastChecked:
"Nous vous informerons lorsque la mise à jour sera prête. Dernière vérification à {{time}}.",
pendingWithVersion: "Mise à jour trouvée : {{version}}. Téléchargement...",
pendingWithVersionAndLastChecked:
"Mise à jour trouvée : {{version}}. Téléchargement... Dernière vérification à {{time}}.",
availableWithVersion: "Mise à jour prête:{{version}}",
availableWithVersionAndLastChecked:
"Mise à jour prête:{{version}}. Dernière vérification à {{time}}.",

View File

@@ -951,6 +951,9 @@ export const ja: TranslationResources = {
upToDateWithLastChecked: "最新の状態です。最終確認: {{time}}。",
pending: "更新の準備ができたらお知らせします。",
pendingWithLastChecked: "更新の準備ができたらお知らせします。最終確認: {{time}}。",
pendingWithVersion: "更新が見つかりました: {{version}}。ダウンロード中...",
pendingWithVersionAndLastChecked:
"更新が見つかりました: {{version}}。ダウンロード中... 最終確認: {{time}}。",
availableWithVersion: "更新の準備ができました: {{version}}",
availableWithVersionAndLastChecked:
"更新の準備ができました: {{version}}。最終確認: {{time}}。",

View File

@@ -958,6 +958,9 @@ export const ptBR: TranslationResources = {
pending: "Avisaremos quando a atualização estiver pronta.",
pendingWithLastChecked:
"Avisaremos quando a atualização estiver pronta. Última verificação às {{time}}.",
pendingWithVersion: "Atualização encontrada: {{version}}. Baixando...",
pendingWithVersionAndLastChecked:
"Atualização encontrada: {{version}}. Baixando... Última verificação às {{time}}.",
availableWithVersion: "Atualização pronta: {{version}}",
availableWithVersionAndLastChecked:
"Atualização pronta: {{version}}. Última verificação às {{time}}.",

View File

@@ -959,6 +959,9 @@ export const ru: TranslationResources = {
pending: "Мы сообщим вам, когда обновление будет готово.",
pendingWithLastChecked:
"Мы сообщим вам, когда обновление будет готово. Последняя проверка в {{time}}.",
pendingWithVersion: "Найдено обновление: {{version}}. Загрузка...",
pendingWithVersionAndLastChecked:
"Найдено обновление: {{version}}. Загрузка... Последняя проверка в {{time}}.",
availableWithVersion: "Обновление готово:{{version}}",
availableWithVersionAndLastChecked:
"Обновление готово:{{version}}. Последняя проверка в {{time}}.",

View File

@@ -928,6 +928,9 @@ export const zhCN: TranslationResources = {
upToDateWithLastChecked: "已是最新版本。上次检查时间:{{time}}。",
pending: "更新准备好后会通知你。",
pendingWithLastChecked: "更新准备好后会通知你。上次检查时间:{{time}}。",
pendingWithVersion: "发现更新:{{version}}。正在下载...",
pendingWithVersionAndLastChecked:
"发现更新:{{version}}。正在下载... 上次检查时间:{{time}}。",
availableWithVersion: "更新已就绪:{{version}}",
availableWithVersionAndLastChecked: "更新已就绪:{{version}}。上次检查时间:{{time}}。",
available: "有 app 更新可安装。",

View File

@@ -701,6 +701,9 @@ function DesktopAppUpdateRow() {
});
}, [installUpdate, isDesktopApp, t]);
const isUpdateReady = availableUpdate?.readyToInstall === true;
const readyUpdateVersion = isUpdateReady ? availableUpdate?.latestVersion : null;
if (!isDesktopApp) {
return null;
}
@@ -725,10 +728,10 @@ function DesktopAppUpdateRow() {
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{t("settings.about.updates.label")}</Text>
<Text style={settingsStyles.rowHint}>{statusText}</Text>
{availableUpdate?.latestVersion ? (
{readyUpdateVersion ? (
<Text style={settingsStyles.rowHint}>
{t("settings.about.updates.readyToInstall", {
version: formatVersionWithPrefix(availableUpdate.latestVersion),
version: formatVersionWithPrefix(readyUpdateVersion),
})}
</Text>
) : null}
@@ -747,9 +750,9 @@ function DesktopAppUpdateRow() {
variant="default"
size="sm"
onPress={handleInstallUpdate}
disabled={isChecking || isInstalling || !availableUpdate}
disabled={isChecking || isInstalling || !isUpdateReady}
>
{getUpdateButtonLabel(t, isInstalling, availableUpdate?.latestVersion)}
{getUpdateButtonLabel(t, isInstalling, readyUpdateVersion)}
</Button>
</View>
</View>

View File

@@ -39,24 +39,44 @@ describe("sidebar view store", () => {
beforeEach(() => {
useSidebarViewStore.setState({
groupMode: "project",
hostFilter: null,
hostFilters: [],
});
});
it("keeps a host filter that still points at an available host", () => {
useSidebarViewStore.getState().setHostFilter("host-a");
it("toggles multiple hosts into and out of the filter", () => {
const store = useSidebarViewStore.getState();
store.toggleHostFilter("host-a");
store.toggleHostFilter("host-b");
useSidebarViewStore.getState().reconcileHostFilter(["host-a", "host-b"]);
expect(useSidebarViewStore.getState().hostFilters).toEqual(["host-a", "host-b"]);
expect(useSidebarViewStore.getState().hostFilter).toBe("host-a");
store.toggleHostFilter("host-a");
expect(useSidebarViewStore.getState().hostFilters).toEqual(["host-b"]);
store.clearHostFilters();
expect(useSidebarViewStore.getState().hostFilters).toEqual([]);
});
it("clears a host filter after that host is removed", () => {
useSidebarViewStore.getState().setHostFilter("removed-host");
it("keeps host filters that still point at available hosts", () => {
const store = useSidebarViewStore.getState();
store.toggleHostFilter("host-a");
store.toggleHostFilter("host-b");
useSidebarViewStore.getState().reconcileHostFilter(["host-a"]);
store.reconcileHostFilters(["host-a", "host-b", "host-c"]);
expect(useSidebarViewStore.getState().hostFilter).toBeNull();
expect(useSidebarViewStore.getState().hostFilters).toEqual(["host-a", "host-b"]);
});
it("drops a host filter after that host is removed", () => {
const store = useSidebarViewStore.getState();
store.toggleHostFilter("host-a");
store.toggleHostFilter("removed-host");
store.reconcileHostFilters(["host-a"]);
expect(useSidebarViewStore.getState().hostFilters).toEqual(["host-a"]);
});
it("migrates legacy per-host group modes to the new global mode", () => {
@@ -69,11 +89,11 @@ describe("sidebar view store", () => {
}),
).toEqual({
groupMode: "status",
hostFilter: null,
hostFilters: [],
});
});
it("keeps current persisted sidebar view state during version migration", () => {
it("migrates a pre-v2 single host filter to the multi-host list", () => {
expect(
migrateSidebarViewState({
groupMode: "status",
@@ -81,7 +101,19 @@ describe("sidebar view store", () => {
}),
).toEqual({
groupMode: "status",
hostFilter: "host-a",
hostFilters: ["host-a"],
});
});
it("keeps current persisted sidebar view state during version migration", () => {
expect(
migrateSidebarViewState({
groupMode: "status",
hostFilters: ["host-a", "host-b"],
}),
).toEqual({
groupMode: "status",
hostFilters: ["host-a", "host-b"],
});
});
@@ -108,8 +140,8 @@ describe("sidebar view store", () => {
it("uses the new storage key without reading the legacy key when current state exists", async () => {
const storage = createMemoryStorage({
"sidebar-view": JSON.stringify({
state: { groupMode: "project", hostFilter: "host-a" },
version: 1,
state: { groupMode: "project", hostFilters: ["host-a"] },
version: 2,
}),
"sidebar-group-mode": JSON.stringify({
state: { groupModeByServerId: { "host-b": "status" } },
@@ -121,8 +153,8 @@ describe("sidebar view store", () => {
expect(value).toBe(
JSON.stringify({
state: { groupMode: "project", hostFilter: "host-a" },
version: 1,
state: { groupMode: "project", hostFilters: ["host-a"] },
version: 2,
}),
);
expect(storage.reads).toEqual(["sidebar-view"]);

View File

@@ -6,19 +6,21 @@ export type SidebarGroupMode = "project" | "status";
const SIDEBAR_VIEW_STORAGE_KEY = "sidebar-view";
const LEGACY_SIDEBAR_GROUP_MODE_STORAGE_KEY = "sidebar-group-mode";
const SIDEBAR_VIEW_STORE_VERSION = 1;
const SIDEBAR_VIEW_STORE_VERSION = 2;
interface SidebarViewStoreState {
groupMode: SidebarGroupMode;
hostFilter: string | null;
// Empty means "all hosts". A non-empty list pins the sidebar to those hosts.
hostFilters: string[];
setGroupMode: (mode: SidebarGroupMode) => void;
setHostFilter: (serverId: string | null) => void;
reconcileHostFilter: (serverIds: readonly string[]) => void;
toggleHostFilter: (serverId: string) => void;
clearHostFilters: () => void;
reconcileHostFilters: (serverIds: readonly string[]) => void;
}
interface SidebarViewPersistedState {
groupMode: SidebarGroupMode;
hostFilter: string | null;
hostFilters: string[];
}
function isSidebarGroupMode(value: unknown): value is SidebarGroupMode {
@@ -40,19 +42,32 @@ function readLegacyGroupMode(persistedState: Record<string, unknown>): SidebarGr
return modes.includes("status") ? "status" : "project";
}
// Reads the host filter from any persisted shape: the current `hostFilters` array, or the
// pre-v2 single `hostFilter` string (null/absent meant "all hosts").
function readHostFilters(persistedState: Record<string, unknown>): string[] {
const hostFilters = persistedState.hostFilters;
if (Array.isArray(hostFilters)) {
return hostFilters.filter((value): value is string => typeof value === "string");
}
// COMPAT(sidebarHostFilters): added in v0.1.102, remove after 2026-12-30 once pre-v2 persisted
// sidebar state (a single `hostFilter` string) has aged out.
const legacyHostFilter = persistedState.hostFilter;
return typeof legacyHostFilter === "string" ? [legacyHostFilter] : [];
}
export function migrateSidebarViewState(persistedState: unknown): SidebarViewPersistedState {
if (!isRecord(persistedState)) {
return { groupMode: "project", hostFilter: null };
return { groupMode: "project", hostFilters: [] };
}
const legacyGroupMode = readLegacyGroupMode(persistedState);
if (legacyGroupMode) {
return { groupMode: legacyGroupMode, hostFilter: null };
return { groupMode: legacyGroupMode, hostFilters: [] };
}
return {
groupMode: isSidebarGroupMode(persistedState.groupMode) ? persistedState.groupMode : "project",
hostFilter: typeof persistedState.hostFilter === "string" ? persistedState.hostFilter : null,
hostFilters: readHostFilters(persistedState),
};
}
@@ -76,15 +91,26 @@ export const useSidebarViewStore = create<SidebarViewStoreState>()(
persist(
(set) => ({
groupMode: "project",
hostFilter: null,
hostFilters: [],
setGroupMode: (mode) => set({ groupMode: mode }),
setHostFilter: (serverId) => set({ hostFilter: serverId }),
reconcileHostFilter: (serverIds) =>
toggleHostFilter: (serverId) =>
set((state) => ({
hostFilters: state.hostFilters.includes(serverId)
? state.hostFilters.filter((id) => id !== serverId)
: [...state.hostFilters, serverId],
})),
clearHostFilters: () => set({ hostFilters: [] }),
reconcileHostFilters: (serverIds) =>
set((state) => {
if (!state.hostFilter || serverIds.includes(state.hostFilter)) {
if (state.hostFilters.length === 0) {
return state;
}
return { hostFilter: null };
const allowed = new Set(serverIds);
const next = state.hostFilters.filter((id) => allowed.has(id));
if (next.length === state.hostFilters.length) {
return state;
}
return { hostFilters: next };
}),
}),
{
@@ -93,7 +119,7 @@ export const useSidebarViewStore = create<SidebarViewStoreState>()(
storage: createJSONStorage(createSidebarViewStorage),
partialize: (state) => ({
groupMode: state.groupMode,
hostFilter: state.hostFilter,
hostFilters: state.hostFilters,
}),
migrate: migrateSidebarViewState,
},

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.102-beta.2",
"version": "0.1.102",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -27,9 +27,9 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.1.102-beta.2",
"@getpaseo/protocol": "0.1.102-beta.2",
"@getpaseo/server": "0.1.102-beta.2",
"@getpaseo/client": "0.1.102",
"@getpaseo/protocol": "0.1.102",
"@getpaseo/server": "0.1.102",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/client",
"version": "0.1.102-beta.2",
"version": "0.1.102",
"description": "Paseo client SDK package",
"files": [
"dist",
@@ -35,8 +35,8 @@
"test": "vitest run"
},
"dependencies": {
"@getpaseo/protocol": "0.1.102-beta.2",
"@getpaseo/relay": "0.1.102-beta.2",
"@getpaseo/protocol": "0.1.102",
"@getpaseo/relay": "0.1.102",
"zod": "^4.4.3"
},
"devDependencies": {

View File

@@ -16,6 +16,10 @@ files:
asarUnpack:
- dist/daemon/node-entrypoint-runner.js
- node_modules/@getpaseo/server/dist/server/terminal/shell-integration/**/*
- node_modules/@ff-labs/fff-node/**/*
- node_modules/@ff-labs/fff-bin-*/**/*
- node_modules/ffi-rs/**/*
- node_modules/@yuuang/ffi-rs-*/**/*
extraResources:
- from: ../app/dist
to: app-dist

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.1.102-beta.2",
"version": "0.1.102",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",

View File

@@ -14,6 +14,38 @@ const RIPGREP_PLATFORM_DIR = {
win32: { arm64: "arm64-win32", x64: "x64-win32" },
};
const FFF_BIN_PACKAGE = {
darwin: {
arm64: ["fff-bin-darwin-arm64"],
x64: ["fff-bin-darwin-x64"],
universal: ["fff-bin-darwin-arm64", "fff-bin-darwin-x64"],
},
linux: {
arm64: ["fff-bin-linux-arm64-gnu"],
x64: ["fff-bin-linux-x64-gnu"],
},
win32: {
arm64: ["fff-bin-win32-arm64"],
x64: ["fff-bin-win32-x64"],
},
};
const FFI_RS_PACKAGE = {
darwin: {
arm64: ["ffi-rs-darwin-arm64"],
x64: ["ffi-rs-darwin-x64"],
universal: ["ffi-rs-darwin-arm64", "ffi-rs-darwin-x64"],
},
linux: {
arm64: ["ffi-rs-linux-arm64-gnu"],
x64: ["ffi-rs-linux-x64-gnu"],
},
win32: {
arm64: ["ffi-rs-win32-arm64-msvc"],
x64: ["ffi-rs-win32-x64-msvc"],
},
};
function rmSafe(target) {
fs.rmSync(target, { recursive: true, force: true });
}
@@ -73,6 +105,43 @@ function pruneSharpLibvips(nodeModules, platform, arch) {
}
}
function keepPackagesForPlatform(packagesByPlatform, platform, arch) {
return packagesByPlatform[platform]?.[arch] ?? [];
}
function pruneScopedNativePackages(nodeModules, scope, prefix, keepNames) {
if (keepNames.length === 0) {
return;
}
const scopeDir = path.join(nodeModules, scope);
if (!fs.existsSync(scopeDir)) {
return;
}
const keep = new Set(keepNames);
for (const entry of fs.readdirSync(scopeDir)) {
if (entry.startsWith(prefix) && !keep.has(entry)) {
rmSafe(path.join(scopeDir, entry));
}
}
}
function pruneFffNativePackages(nodeModules, platform, arch) {
pruneScopedNativePackages(
nodeModules,
"@ff-labs",
"fff-bin-",
keepPackagesForPlatform(FFF_BIN_PACKAGE, platform, arch),
);
pruneScopedNativePackages(
nodeModules,
"@yuuang",
"ffi-rs-",
keepPackagesForPlatform(FFI_RS_PACKAGE, platform, arch),
);
}
function pruneNativeModules(appOutDir, platform, arch) {
const resourcesDir =
platform === "darwin"
@@ -87,6 +156,7 @@ function pruneNativeModules(appOutDir, platform, arch) {
pruneClaudeAgentSdk(nodeModules, platform, arch);
pruneNodePty(nodeModules, platform, arch);
pruneSharpLibvips(nodeModules, platform, arch);
pruneFffNativePackages(nodeModules, platform, arch);
const after = dirSizeSync(nodeModules);
const savedMB = ((before - after) / 1024 / 1024).toFixed(1);

View File

@@ -1,9 +1,10 @@
const { spawn, spawnSync } = require("node:child_process");
const { execFileSync, spawn, spawnSync } = require("node:child_process");
const fs = require("node:fs");
const net = require("node:net");
const os = require("node:os");
const path = require("node:path");
const { setTimeout: delay } = require("node:timers/promises");
const { pathToFileURL } = require("node:url");
const EXECUTABLE_NAME = "Paseo";
const SMOKE_TIMEOUT_MS = 60_000;
@@ -621,6 +622,97 @@ async function smokeCliTerminal({ appPath, env }) {
}
}
async function loadConnectToDaemon() {
const clientModulePath = path.join(__dirname, "..", "..", "cli", "dist", "utils", "client.js");
const clientModule = await import(pathToFileURL(clientModulePath).href);
if (typeof clientModule.connectToDaemon !== "function") {
throw new Error(`CLI client module did not export connectToDaemon: ${clientModulePath}`);
}
return clientModule.connectToDaemon;
}
function createWorkspaceSearchFixture() {
const cwd = createTempDir("paseo-smoke-search-cwd-");
execFileSync("git", ["init"], { cwd, stdio: "ignore" });
fs.writeFileSync(path.join(cwd, ".env.local"), "PASEO_PACKAGED_SMOKE=1\n");
fs.mkdirSync(path.join(cwd, ".opencode"), { recursive: true });
fs.writeFileSync(path.join(cwd, ".opencode", "settings.json"), "{}\n");
fs.writeFileSync(path.join(cwd, ".gitignore"), "ignored.secret\n");
fs.writeFileSync(path.join(cwd, "ignored.secret"), "ignore me\n");
return cwd;
}
function assertSuggestionPayload(payload, label) {
if (payload?.error) {
throw new Error(`${label} returned error: ${payload.error}`);
}
if (!Array.isArray(payload?.entries)) {
throw new Error(`${label} returned invalid payload: ${JSON.stringify(payload)}`);
}
}
function assertSuggestionEntry(payload, expected, label) {
assertSuggestionPayload(payload, label);
const found = payload.entries.some(
(entry) => entry.path === expected.path && entry.kind === expected.kind,
);
if (!found) {
throw new Error(
`${label} did not include ${expected.kind} ${expected.path}. Entries: ${JSON.stringify(
payload.entries,
)}`,
);
}
}
async function smokeWorkspaceSearch({ host }) {
console.log("Packaged desktop smoke: querying workspace search through daemon RPC");
const cwd = createWorkspaceSearchFixture();
const connectToDaemon = await loadConnectToDaemon();
const client = await connectToDaemon({ host, timeout: SMOKE_TIMEOUT_MS });
try {
const envResults = await client.getDirectorySuggestions({
cwd,
query: "env",
includeFiles: true,
includeDirectories: true,
limit: 20,
});
assertSuggestionEntry(envResults, { path: ".env.local", kind: "file" }, "Dotfile search");
const opencodeResults = await client.getDirectorySuggestions({
cwd,
query: "opencode",
includeFiles: true,
includeDirectories: true,
limit: 20,
});
assertSuggestionEntry(
opencodeResults,
{ path: ".opencode/settings.json", kind: "file" },
"Dot-directory search",
);
const ignoredResults = await client.getDirectorySuggestions({
cwd,
query: "ignored",
includeFiles: true,
includeDirectories: true,
limit: 20,
});
assertSuggestionPayload(ignoredResults, "Ignored file search");
if (ignoredResults.entries.some((entry) => entry.path === "ignored.secret")) {
throw new Error(
`Ignored file search returned ignored.secret: ${JSON.stringify(ignoredResults.entries)}`,
);
}
} finally {
await client.close().catch(() => {});
await removeTempDir(cwd);
}
}
async function stopCliDaemon({ appPath, env }) {
console.log("Packaged desktop smoke: stopping daemon through bundled CLI shim");
await runCliShimCommand({
@@ -677,9 +769,10 @@ async function smokePackagedDesktopApp({ appPath }) {
const cliEnv = createDefaultDaemonEnv();
await smokeCliShim({ appPath, env: cliEnv });
await smokeCliTerminal({ appPath, env: cliEnv });
await smokeWorkspaceSearch({ host: message.status.listen });
await stopDaemonForCleanup();
console.log(
`Packaged desktop smoke passed: desktop-managed daemon pid ${message.status.pid}, listen ${message.status.listen}; CLI shim daemon status and terminal smoke succeeded`,
`Packaged desktop smoke passed: desktop-managed daemon pid ${message.status.pid}, listen ${message.status.listen}; CLI shim daemon status, terminal smoke, and workspace search succeeded`,
);
} catch (error) {
if (smokeStarted && !daemonStopped) {

View File

@@ -74,6 +74,15 @@ describe("desktop packaging", () => {
);
});
it("unpacks native workspace search packages", () => {
const config = readFileSync(join(packageRoot, "electron-builder.yml"), "utf8");
expect(config).toContain("node_modules/@ff-labs/fff-node/**/*");
expect(config).toContain("node_modules/@ff-labs/fff-bin-*/**/*");
expect(config).toContain("node_modules/ffi-rs/**/*");
expect(config).toContain("node_modules/@yuuang/ffi-rs-*/**/*");
});
it("excludes package debug/source files from the packaged app", () => {
const config = readFileSync(join(packageRoot, "electron-builder.yml"), "utf8");

View File

@@ -58,6 +58,10 @@ class FakeAppUpdateRuntime implements AppUpdateRuntime {
this.configuration?.onUpdateAvailable(info);
}
finishUpdateDownload(info: RuntimeUpdateInfo): void {
this.configuration?.onUpdateDownloaded(info);
}
async checkForUpdates(): Promise<{
isUpdateAvailable: boolean;
updateInfo: RuntimeUpdateInfo;
@@ -144,6 +148,37 @@ describe("app update service", () => {
});
});
it("performs a fresh manual check when an update is already cached", async () => {
const { runtime, service } = createService({ bucket: async () => 0 });
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });
await service.checkForAppUpdate({
currentVersion: "1.2.3",
releaseChannel: "stable",
intent: "automatic",
});
runtime.nextCheck({
isUpdateAvailable: true,
updateInfo: { ...rolledOutUpdate, version: "1.2.5" },
});
const result = await service.checkForAppUpdate({
currentVersion: "1.2.3",
releaseChannel: "stable",
intent: "manual",
});
expect(result).toEqual({
hasUpdate: true,
readyToInstall: false,
currentVersion: "1.2.3",
latestVersion: "1.2.5",
body: null,
date: "2026-04-28T00:00:00.000Z",
errorMessage: null,
});
});
it("trusts the runtime availability decision before comparing versions", async () => {
const { runtime, service } = createService({ bucket: async () => 0 });
runtime.nextCheck({ isUpdateAvailable: false, updateInfo: rolledOutUpdate });
@@ -347,6 +382,70 @@ describe("app update service", () => {
});
});
it("performs a fresh manual check after an update preparation error", async () => {
const { runtime, service } = createService();
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });
await service.checkForAppUpdate({
currentVersion: "1.2.3",
releaseChannel: "stable",
intent: "manual",
});
runtime.failRuntime(new Error("sha512 checksum mismatch"));
runtime.nextCheck({
isUpdateAvailable: true,
updateInfo: { ...rolledOutUpdate, version: "1.2.5" },
});
const result = await service.checkForAppUpdate({
currentVersion: "1.2.3",
releaseChannel: "stable",
intent: "manual",
});
expect(result).toEqual({
hasUpdate: true,
readyToInstall: false,
currentVersion: "1.2.3",
latestVersion: "1.2.5",
body: null,
date: "2026-04-28T00:00:00.000Z",
errorMessage: null,
});
});
it("keeps a downloaded update ready when a manual check re-announces it", async () => {
const { runtime, service } = createService();
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });
await service.checkForAppUpdate({
currentVersion: "1.2.3",
releaseChannel: "stable",
intent: "manual",
});
runtime.finishUpdateDownload(rolledOutUpdate);
const recheck = runtime.deferNextCheck();
const pending = service.checkForAppUpdate({
currentVersion: "1.2.3",
releaseChannel: "stable",
intent: "manual",
});
runtime.prepareUpdate(rolledOutUpdate);
recheck.resolve({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });
const result = await pending;
expect(result).toEqual({
hasUpdate: true,
readyToInstall: true,
currentVersion: "1.2.3",
latestVersion: "1.2.4",
body: null,
date: "2026-04-28T00:00:00.000Z",
errorMessage: null,
});
});
it("returns runtime update errors to multiple automatic checks before a manual retry clears them", async () => {
const { runtime, service } = createService();
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });

View File

@@ -163,9 +163,10 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer
});
},
onUpdateAvailable(info) {
const alreadyReady = downloadedUpdateVersion === info.version;
cachedUpdateInfo = info;
downloadedUpdateVersion = null;
downloading = true;
downloadedUpdateVersion = alreadyReady ? info.version : null;
downloading = !alreadyReady;
runtimeErrorMessage = null;
},
onUpdateDownloaded(info) {
@@ -212,7 +213,12 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer
}
const cachedVersion = cachedUpdateInfo?.version ?? null;
if (!runtimeErrorResult && cachedVersion && cachedVersion !== currentVersion) {
if (
!runtimeErrorResult &&
intent === "automatic" &&
cachedVersion &&
cachedVersion !== currentVersion
) {
return buildCheckResult({
currentVersion,
hasUpdate: true,

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.102-beta.2",
"version": "0.1.102",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.1.102-beta.2",
"version": "0.1.102",
"files": [
"dist",
"!dist/**/*.map"

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/protocol",
"version": "0.1.102-beta.2",
"version": "0.1.102",
"description": "Paseo shared protocol schemas and wire types",
"files": [
"dist",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.1.102-beta.2",
"version": "0.1.102",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist",

View File

@@ -1,6 +1,14 @@
# OpenAI API Key (for GPT-4, Whisper STT, and TTS)
OPENAI_API_KEY=
# Optional: point speech-to-text and text-to-speech at different OpenAI-compatible
# endpoints. Each falls back to OPENAI_API_KEY / OPENAI_BASE_URL when unset.
# STT covers composer dictation + voice mode STT; TTS covers voice mode TTS.
OPENAI_STT_API_KEY=
OPENAI_STT_BASE_URL=
OPENAI_TTS_API_KEY=
OPENAI_TTS_BASE_URL=
# TTS Configuration (optional - defaults shown)
TTS_VOICE=alloy
# Available voices: alloy, echo, fable, onyx, nova, shimmer

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.1.102-beta.2",
"version": "0.1.102",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -65,10 +65,11 @@
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.195",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.1.102-beta.2",
"@getpaseo/highlight": "0.1.102-beta.2",
"@getpaseo/protocol": "0.1.102-beta.2",
"@getpaseo/relay": "0.1.102-beta.2",
"@ff-labs/fff-node": "^0.9.6",
"@getpaseo/client": "0.1.102",
"@getpaseo/highlight": "0.1.102",
"@getpaseo/protocol": "0.1.102",
"@getpaseo/relay": "0.1.102",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",

View File

@@ -26,7 +26,7 @@ async function main(): Promise<void> {
const daemon = await createTestPaseoDaemon({
logger,
agentClients: {},
openai: { apiKey },
openai: { stt: { apiKey }, tts: { apiKey } },
speech: {
providers: {
dictationStt: { provider: "openai", explicit: true },

View File

@@ -33,6 +33,7 @@ import { CodexAppServerAgentClient } from "./providers/codex-app-server-agent.js
import { CopilotACPAgentClient } from "./providers/copilot-acp-agent.js";
import { CursorACPAgentClient } from "./providers/cursor-acp-agent.js";
import { GenericACPAgentClient } from "./providers/generic-acp-agent.js";
import { KiroACPAgentClient } from "./providers/kiro-acp-agent.js";
import { OpenCodeAgentClient } from "./providers/opencode-agent.js";
import { PiRpcAgentClient } from "./providers/pi/agent.js";
import { MockLoadTestAgentClient } from "./providers/mock-load-test-agent.js";
@@ -644,24 +645,23 @@ function addDerivedProviders(
enabled: override.enabled !== false,
derivedFromProviderId: null,
providerParams: override.params,
createBaseClient: (logger) =>
providerId === "cursor"
? new CursorACPAgentClient({
logger,
command,
env: override.env,
providerId,
label: override.label ?? providerId,
providerParams: override.params,
})
: new GenericACPAgentClient({
logger,
command,
env: override.env,
providerId,
label: override.label ?? providerId,
providerParams: override.params,
}),
createBaseClient: (logger) => {
const acpOptions = {
logger,
command,
env: override.env,
providerId,
label: override.label ?? providerId,
providerParams: override.params,
};
if (providerId === "cursor") {
return new CursorACPAgentClient(acpOptions);
}
if (providerId === "kiro") {
return new KiroACPAgentClient(acpOptions);
}
return new GenericACPAgentClient(acpOptions);
},
});
continue;
}

View File

@@ -41,6 +41,7 @@ import {
writeCopilotProviderMode,
} from "./copilot-acp-agent.js";
import { GenericACPAgentClient } from "./generic-acp-agent.js";
import { parseKiroExtensionCommands } from "./kiro-acp-agent.js";
import { transformPiModels } from "./pi/agent.js";
import type { AgentStreamEvent } from "../agent-sdk-types.js";
import type { AgentCapabilityFlags, AgentPersistenceHandle } from "../agent-sdk-types.js";
@@ -165,6 +166,35 @@ function createSessionWithConfig(
);
}
function createKiroSession(
options: { waitForInitialCommands?: boolean; initialCommandsWaitTimeoutMs?: number } = {},
logger: ReturnType<typeof createTestLogger> = createTestLogger(),
): ACPAgentSession {
return new ACPAgentSession(
{
provider: "kiro",
cwd: "/tmp/paseo-acp-test",
},
{
provider: "kiro",
logger,
defaultCommand: ["kiro-cli", "acp"],
defaultModes: [],
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
extensionCommandsParser: parseKiroExtensionCommands,
waitForInitialCommands: options.waitForInitialCommands ?? false,
initialCommandsWaitTimeoutMs: options.initialCommandsWaitTimeoutMs,
},
);
}
function createTerminalChildStub(): ChildProcess {
const child = new EventEmitter() as ChildProcess;
child.stdout = new EventEmitter() as ChildProcess["stdout"];
@@ -1902,6 +1932,86 @@ describe("ACPAgentSession", () => {
);
});
test("maps the Kiro _kiro.dev/commands/available notification into slash commands and skills", async () => {
const session = createKiroSession({
waitForInitialCommands: true,
initialCommandsWaitTimeoutMs: 1500,
});
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
const listCommandsPromise = session.listCommands();
await session.extNotification("_kiro.dev/commands/available", {
sessionId: "session-1",
commands: [
{
name: "/agent",
description: "Select or list available agents",
meta: { inputType: "selection", hint: "swap <name>" },
},
],
prompts: [
{
name: "agent-sync-doctor",
description: "Hand off Claude or Codex state across Macs",
arguments: [],
serverName: "skill:config",
},
],
// Tools are not slash commands and must be ignored.
tools: [{ name: "code", description: "Code intelligence", source: "built-in" }],
});
expect(await listCommandsPromise).toEqual([
{
name: "agent",
description: "Select or list available agents",
argumentHint: "swap <name>",
kind: "command",
},
{
name: "agent-sync-doctor",
description: "Hand off Claude or Codex state across Macs",
argumentHint: "",
kind: "skill",
},
]);
});
test("ignores Kiro _kiro.dev/commands/available for a different session", async () => {
const session = createKiroSession();
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
await session.extNotification("_kiro.dev/commands/available", {
sessionId: "other-session",
commands: [{ name: "/agent", description: "Select or list available agents" }],
prompts: [],
});
expect(await session.listCommands()).toEqual([]);
});
test("settles listCommands() immediately on an empty Kiro commands batch", async () => {
// A long timeout means a resolution can only come from settleCommandsReady()
// firing — not from the wait timer — so this test would hang if the empty
// batch failed to unblock listCommands() (the P1 regression).
const session = createKiroSession({
waitForInitialCommands: true,
initialCommandsWaitTimeoutMs: 60_000,
});
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
const listCommandsPromise = session.listCommands();
await session.extNotification("_kiro.dev/commands/available", {
sessionId: "session-1",
commands: [],
prompts: [],
});
expect(await listCommandsPromise).toEqual([]);
});
test("emits assistant and reasoning chunks as deltas while user chunks stay accumulated", async () => {
const session = createSession();
const events: Array<{ type: string; item?: { type: string; text?: string } }> = [];

View File

@@ -334,6 +334,17 @@ export function createLoggedNdJsonStream(
return { readable, writable };
}
// Lets a provider that publishes its slash commands through a vendor-specific
// ACP extension notification (rather than the standard
// `available_commands_update` session update) translate that payload into Paseo
// slash commands, without the generic ACP session/client carrying any vendor
// knowledge. Return the parsed commands (possibly empty) for a notification this
// provider owns, or null to ignore notifications it does not handle.
export type ACPExtensionCommandsParser = (
method: string,
params: Record<string, unknown>,
) => AgentSlashCommand[] | null;
interface ACPAgentClientOptions {
provider: string;
logger: Logger;
@@ -356,6 +367,7 @@ interface ACPAgentClientOptions {
thinkingOptionId: string,
) => Promise<void>;
capabilities?: AgentCapabilityFlags;
extensionCommandsParser?: ACPExtensionCommandsParser;
waitForInitialCommands?: boolean;
initialCommandsWaitTimeoutMs?: number;
terminateProcess?: ProcessTerminator;
@@ -383,6 +395,7 @@ interface ACPAgentSessionOptions {
thinkingOptionId: string,
) => Promise<void>;
capabilities: AgentCapabilityFlags;
extensionCommandsParser?: ACPExtensionCommandsParser;
handle?: AgentPersistenceHandle;
agentId?: string;
launchEnv?: Record<string, string>;
@@ -692,6 +705,7 @@ export class ACPAgentClient implements AgentClient {
) => Promise<void>;
private readonly waitForInitialCommands: boolean;
private readonly initialCommandsWaitTimeoutMs: number;
private readonly extensionCommandsParser?: ACPExtensionCommandsParser;
protected readonly terminateProcess: ProcessTerminator;
constructor(options: ACPAgentClientOptions) {
@@ -716,6 +730,7 @@ export class ACPAgentClient implements AgentClient {
this.thinkingOptionWriter = options.thinkingOptionWriter;
this.waitForInitialCommands = options.waitForInitialCommands ?? false;
this.initialCommandsWaitTimeoutMs = options.initialCommandsWaitTimeoutMs ?? 1500;
this.extensionCommandsParser = options.extensionCommandsParser;
}
async createSession(
@@ -743,6 +758,7 @@ export class ACPAgentClient implements AgentClient {
capabilities: this.capabilities,
agentId: launchContext?.agentId,
launchEnv: launchContext?.env,
extensionCommandsParser: this.extensionCommandsParser,
waitForInitialCommands: this.waitForInitialCommands,
initialCommandsWaitTimeoutMs: this.initialCommandsWaitTimeoutMs,
},
@@ -791,6 +807,7 @@ export class ACPAgentClient implements AgentClient {
handle,
agentId: launchContext?.agentId,
launchEnv: launchContext?.env,
extensionCommandsParser: this.extensionCommandsParser,
waitForInitialCommands: this.waitForInitialCommands,
initialCommandsWaitTimeoutMs: this.initialCommandsWaitTimeoutMs,
});
@@ -1273,6 +1290,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
private commandsReadySettled = false;
private waitForInitialCommands: boolean;
private initialCommandsWaitTimeoutMs: number;
private readonly extensionCommandsParser?: ACPExtensionCommandsParser;
private currentTurnUsage: AgentUsage | undefined;
private activeForegroundTurnId: string | null = null;
private closed = false;
@@ -1309,6 +1327,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
this.currentTitle = config.title ?? null;
this.waitForInitialCommands = options.waitForInitialCommands ?? false;
this.initialCommandsWaitTimeoutMs = options.initialCommandsWaitTimeoutMs ?? 1500;
this.extensionCommandsParser = options.extensionCommandsParser;
}
get id(): string | null {
@@ -2082,6 +2101,39 @@ export class ACPAgentSession implements AgentSession, ACPClient {
},
"provider.acp.extension_notification",
);
const parsedCommands = this.extensionCommandsParser?.(method, params);
if (parsedCommands) {
this.applyResolvedCommands(parsedCommands, {
sessionId: typeof params.sessionId === "string" ? params.sessionId : undefined,
});
}
}
// Cache an asynchronously-delivered slash-command batch and unblock any
// listCommands() call that is waiting on the initial batch. Used when a
// provider supplies an extensionCommandsParser whose result arrives after
// session/new (e.g. via a vendor extension notification). The ready gate is
// always settled — even for an empty batch — so a provider that legitimately
// reports no commands does not leave listCommands() blocked for the full
// initial-commands timeout. An optional sessionId scopes the batch to this
// session; notifications addressed to a different session are ignored.
private applyResolvedCommands(
commands: AgentSlashCommand[],
options?: { sessionId?: string },
): void {
if (
options?.sessionId !== undefined &&
this.sessionId !== null &&
options.sessionId !== this.sessionId
) {
return;
}
if (commands.length > 0) {
this.cachedCommands = commands;
}
this.settleCommandsReady();
}
async readTextFile(params: ReadTextFileRequest): Promise<{ content: string }> {

View File

@@ -3,7 +3,11 @@ import { z } from "zod";
import type { AgentCapabilityFlags } from "../agent-sdk-types.js";
import { checkProviderLaunchAvailable, resolveProviderLaunch } from "../provider-launch-config.js";
import { ACPAgentClient, DEFAULT_ACP_CAPABILITIES } from "./acp-agent.js";
import {
ACPAgentClient,
DEFAULT_ACP_CAPABILITIES,
type ACPExtensionCommandsParser,
} from "./acp-agent.js";
import {
buildBinaryDiagnosticRows,
formatProviderDiagnostic,
@@ -29,6 +33,7 @@ interface GenericACPAgentClientOptions {
waitForInitialCommands?: boolean;
initialCommandsWaitTimeoutMs?: number;
diagnosticPhaseTimeoutMs?: number;
extensionCommandsParser?: ACPExtensionCommandsParser;
}
export class GenericACPAgentClient extends ACPAgentClient {
@@ -48,6 +53,7 @@ export class GenericACPAgentClient extends ACPAgentClient {
capabilities: buildGenericACPCapabilities(options),
waitForInitialCommands: options.waitForInitialCommands,
initialCommandsWaitTimeoutMs: options.initialCommandsWaitTimeoutMs,
extensionCommandsParser: options.extensionCommandsParser,
});
this.command = options.command;

View File

@@ -0,0 +1,101 @@
import type { Logger } from "pino";
import type { ACPExtensionCommandsParser } from "./acp-agent.js";
import { GenericACPAgentClient } from "./generic-acp-agent.js";
import type { AgentSlashCommand, AgentSlashCommandKind } from "../agent-sdk-types.js";
interface KiroACPAgentClientOptions {
logger: Logger;
command: [string, ...string[]];
env?: Record<string, string>;
providerId?: string;
label?: string;
providerParams?: unknown;
}
// Kiro CLI publishes its slash commands and skills asynchronously through the
// `_kiro.dev/commands/available` extension notification shortly after
// `session/new` resolves. Wait for that first batch so listCommands() doesn't
// resolve to an empty list before Kiro has reported its commands.
const KIRO_INITIAL_COMMANDS_WAIT_TIMEOUT_MS = 10_000;
// ACP extension method (per the `_`-prefixed vendor namespace convention) that
// Kiro CLI uses to publish its slash commands and skills after session/new.
const KIRO_COMMANDS_AVAILABLE_METHOD = "_kiro.dev/commands/available";
function isRecord(value: unknown): value is Record<string, unknown> {
return value != null && typeof value === "object" && !Array.isArray(value);
}
// Maps a `_kiro.dev/commands/available` payload onto Paseo slash commands.
// Kiro reports built-in slash commands under `commands` (names arrive with a
// leading "/", e.g. "/agent") and skills/prompts under `prompts` (names without
// a slash, tagged with a `skill:` serverName). Paseo stores command names
// without the leading slash — the composer prepends it on insertion.
function mapKiroAvailableCommands(params: Record<string, unknown>): AgentSlashCommand[] {
const result: AgentSlashCommand[] = [];
const seen = new Set<string>();
const pushEntry = (entry: unknown): void => {
if (!isRecord(entry)) {
return;
}
const rawName = typeof entry.name === "string" ? entry.name.trim() : "";
const name = rawName.replace(/^\/+/, "");
if (!name || seen.has(name)) {
return;
}
seen.add(name);
const description = typeof entry.description === "string" ? entry.description : "";
const meta = isRecord(entry.meta) ? entry.meta : null;
const argumentHint = meta && typeof meta.hint === "string" ? meta.hint : "";
const serverName = typeof entry.serverName === "string" ? entry.serverName : "";
const kind: AgentSlashCommandKind = serverName.startsWith("skill:") ? "skill" : "command";
result.push({ name, description, argumentHint, kind });
};
if (Array.isArray(params.commands)) {
for (const entry of params.commands) {
pushEntry(entry);
}
}
if (Array.isArray(params.prompts)) {
for (const entry of params.prompts) {
pushEntry(entry);
}
}
return result;
}
// Provider-specific parser injected into the generic ACP session via the
// `extensionCommandsParser` option (mirrors how Cursor/Copilot inject their
// behavior through constructor options). Kiro advertises its slash commands and
// skills through the `_kiro.dev/commands/available` extension notification
// instead of the standard `available_commands_update` session update; this
// recognizes that one method and returns the parsed commands (possibly empty),
// or null for any other notification so the base session ignores it.
export const parseKiroExtensionCommands: ACPExtensionCommandsParser = (method, params) => {
if (method !== KIRO_COMMANDS_AVAILABLE_METHOD) {
return null;
}
return mapKiroAvailableCommands(params);
};
export class KiroACPAgentClient extends GenericACPAgentClient {
constructor(options: KiroACPAgentClientOptions) {
super({
logger: options.logger,
command: options.command,
env: options.env,
providerId: options.providerId,
label: options.label,
providerParams: options.providerParams,
waitForInitialCommands: true,
initialCommandsWaitTimeoutMs: KIRO_INITIAL_COMMANDS_WAIT_TIMEOUT_MS,
extensionCommandsParser: parseKiroExtensionCommands,
});
}
}

View File

@@ -21,7 +21,7 @@ describe("paseo daemon bootstrap", () => {
test("starts and serves health endpoint", async () => {
const daemonHandle = await createTestPaseoDaemon({
openai: { apiKey: "test-openai-api-key" },
openai: { stt: { apiKey: "test-openai-api-key" }, tts: { apiKey: "test-openai-api-key" } },
speech: {
providers: {
dictationStt: { provider: "openai", explicit: true },

View File

@@ -547,7 +547,9 @@ beforeAll(async () => {
ctx = await createDaemonTestContext({
dictationFinalTimeoutMs: 5000,
...(openaiApiKey ? { openai: { apiKey: openaiApiKey } } : {}),
...(openaiApiKey
? { openai: { stt: { apiKey: openaiApiKey }, tts: { apiKey: openaiApiKey } } }
: {}),
...(speechConfig ? { speech: speechConfig } : {}),
});
}, 60000);

View File

@@ -116,20 +116,26 @@ describe("PersistedConfigSchema worktrees config", () => {
});
describe("PersistedConfigSchema provider credentials", () => {
test("accepts OpenAI voice credentials", () => {
test("accepts separate OpenAI STT and TTS credentials", () => {
const parsed = PersistedConfigSchema.parse({
providers: {
openai: {
voice: {
apiKey: " voice-secret ",
baseUrl: " https://voice.example.com/v1 ",
stt: {
apiKey: " stt-secret ",
baseUrl: " https://stt.example.com/v1 ",
},
tts: {
apiKey: " tts-secret ",
baseUrl: " https://tts.example.com/v1 ",
},
},
},
});
expect(parsed.providers?.openai?.voice?.apiKey).toBe("voice-secret");
expect(parsed.providers?.openai?.voice?.baseUrl).toBe("https://voice.example.com/v1");
expect(parsed.providers?.openai?.stt?.apiKey).toBe("stt-secret");
expect(parsed.providers?.openai?.stt?.baseUrl).toBe("https://stt.example.com/v1");
expect(parsed.providers?.openai?.tts?.apiKey).toBe("tts-secret");
expect(parsed.providers?.openai?.tts?.baseUrl).toBe("https://tts.example.com/v1");
});
});
@@ -650,6 +656,38 @@ describe("loadPersistedConfig", () => {
rmSync(home, { recursive: true, force: true });
}
});
test("loads a config that still uses the removed providers.openai.voice block", () => {
const home = createTempHome();
const configPath = path.join(home, "config.json");
try {
writeFileSync(
configPath,
`${JSON.stringify(
{
version: 1,
providers: {
openai: {
apiKey: "global-key",
voice: { apiKey: "voice-key", baseUrl: "https://voice.example.com/v1" },
},
},
},
null,
2,
)}\n`,
);
const config = loadPersistedConfig(home);
expect(config.providers?.openai?.apiKey).toBe("global-key");
expect((config.providers?.openai as Record<string, unknown>)?.voice).toBeUndefined();
expect(config.providers?.openai?.stt).toBeUndefined();
expect(config.providers?.openai?.tts).toBeUndefined();
} finally {
rmSync(home, { recursive: true, force: true });
}
});
});
describe.skipIf(process.platform === "win32")("persisted config file permissions", () => {

View File

@@ -45,7 +45,7 @@ const LogConfigSchema = z
})
.strict();
const OpenAiVoiceProviderSchema = z
const OpenAiSpeechEndpointSchema = z
.object({
apiKey: z.string().trim().min(1).optional(),
baseUrl: z.string().trim().min(1).optional(),
@@ -55,8 +55,9 @@ const OpenAiVoiceProviderSchema = z
const OpenAiProviderSchema = z
.object({
apiKey: z.string().min(1).optional(),
voice: OpenAiVoiceProviderSchema.optional(),
baseUrl: z.string().trim().min(1).optional(),
stt: OpenAiSpeechEndpointSchema.optional(),
tts: OpenAiSpeechEndpointSchema.optional(),
})
.strict();
@@ -346,7 +347,11 @@ function getLogger(logger: LoggerLike | undefined): LoggerLike | undefined {
return logger?.child({ module: "config" });
}
function stripDeprecatedLocalSpeechConfigFields(parsed: unknown): unknown {
// Removed config fields are stripped before parsing so the strict schema does not
// reject a config written by an older release. The stripped values are discarded,
// not migrated — there is no back-compat for the removed `providers.openai.voice`
// block (use `providers.openai.stt` / `providers.openai.tts`).
function stripRemovedConfigFields(parsed: unknown): unknown {
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return parsed;
}
@@ -358,18 +363,25 @@ function stripDeprecatedLocalSpeechConfigFields(parsed: unknown): unknown {
}
const providersRecord = { ...(providers as Record<string, unknown>) };
const local = providersRecord.local;
if (!local || typeof local !== "object" || Array.isArray(local)) {
root.providers = providersRecord;
return root;
}
const localRecord = { ...(local as Record<string, unknown>) };
if ("autoDownload" in localRecord) {
if (local && typeof local === "object" && !Array.isArray(local)) {
const localRecord = { ...(local as Record<string, unknown>) };
delete localRecord.autoDownload;
providersRecord.local = localRecord;
}
const openai = providersRecord.openai;
if (openai && typeof openai === "object" && !Array.isArray(openai)) {
const openaiRecord = { ...(openai as Record<string, unknown>) };
// COMPAT(openaiVoiceConfig): added 2026-06-30, remove after 2026-12-30.
// Drop a `providers.openai.voice` block left by an older release so the strict
// schema doesn't reject it. The value is discarded, not migrated — there is no
// back-compat; configure `providers.openai.stt` / `providers.openai.tts` instead.
delete openaiRecord.voice;
providersRecord.openai = openaiRecord;
}
providersRecord.local = localRecord;
root.providers = providersRecord;
return root;
}
@@ -412,7 +424,7 @@ export function loadPersistedConfig(paseoHome: string, logger?: LoggerLike): Per
});
}
const migrated = stripDeprecatedLocalSpeechConfigFields(parsed);
const migrated = stripRemovedConfigFields(parsed);
const result = PersistedConfigSchema.safeParse(migrated);
if (!result.success) {
const issues = result.error.issues

View File

@@ -1,9 +1,11 @@
import { execFileSync } from "node:child_process";
import { mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { isPlatform } from "../test-utils/platform.js";
import { searchHomeDirectories, searchWorkspaceEntries } from "./directory-suggestions.js";
import { isPlatform } from "../../test-utils/platform.js";
import { searchHomeDirectories } from "./home-directories.js";
import { clearWorkspaceSearchCacheForTests, searchWorkspaceEntries } from "./workspace-entries.js";
const isWindows = isPlatform("win32");
@@ -204,6 +206,7 @@ describe("searchWorkspaceEntries", () => {
});
mkdirSync(path.join(workspaceDir, "docs"), { recursive: true });
mkdirSync(path.join(outsideDir, "escaped"), { recursive: true });
execFileSync("git", ["init"], { cwd: workspaceDir, stdio: "ignore" });
writeFileSync(path.join(workspaceDir, "README.md"), "# paseo\n");
writeFileSync(
@@ -218,6 +221,7 @@ describe("searchWorkspaceEntries", () => {
});
afterEach(() => {
clearWorkspaceSearchCacheForTests();
rmSync(tempRoot, { recursive: true, force: true });
});
@@ -322,17 +326,17 @@ describe("searchWorkspaceEntries", () => {
matchMode: "suffix",
});
expect(basenameResults).toEqual([
{ path: "src/file.ts", kind: "file" },
{ path: "packages/app/src/file.ts", kind: "file" },
]);
expect(suffixResults).toEqual([
{ path: "src/file.ts", kind: "file" },
{ path: "packages/app/src/file.ts", kind: "file" },
]);
const expectedMatches = [
{ path: "src/file.ts", kind: "file" as const },
{ path: "packages/app/src/file.ts", kind: "file" as const },
];
expect(basenameResults).toHaveLength(2);
expect(basenameResults).toEqual(expect.arrayContaining(expectedMatches));
expect(suffixResults).toHaveLength(2);
expect(suffixResults).toEqual(expect.arrayContaining(expectedMatches));
});
it("suffix mode resolves exact workspace file paths before broad traversal", async () => {
it("suffix mode resolves exact workspace file paths", async () => {
const targetPath = path.join(
workspaceDir,
"packages",
@@ -353,7 +357,6 @@ describe("searchWorkspaceEntries", () => {
includeFiles: true,
includeDirectories: false,
matchMode: "suffix",
maxEntriesScanned: 1,
});
expect(results).toEqual([
@@ -364,7 +367,7 @@ describe("searchWorkspaceEntries", () => {
]);
});
it("suffix mode resolves explicit hidden file paths without broad hidden traversal", async () => {
it("suffix mode resolves explicit dot-prefixed file paths", async () => {
const targetPath = path.join(workspaceDir, ".dev", "paseo-home", "daemon.log");
mkdirSync(path.dirname(targetPath), { recursive: true });
writeFileSync(targetPath, "daemon log\n");
@@ -376,13 +379,40 @@ describe("searchWorkspaceEntries", () => {
includeFiles: true,
includeDirectories: false,
matchMode: "suffix",
maxEntriesScanned: 1,
});
expect(results).toEqual([{ path: ".dev/paseo-home/daemon.log", kind: "file" }]);
});
it("suffix mode finds files under allowlisted hidden workspace directories", async () => {
it("finds dot-prefixed files and files under dot-prefixed directories", async () => {
mkdirSync(path.join(workspaceDir, ".opencode"), { recursive: true });
writeFileSync(path.join(workspaceDir, ".env.local"), "PASEO_TEST=1\n");
writeFileSync(path.join(workspaceDir, ".opencode", "settings.json"), "{}");
const envResults = await searchWorkspaceEntries({
cwd: workspaceDir,
query: "env",
limit: 20,
includeFiles: true,
includeDirectories: true,
});
const opencodeResults = await searchWorkspaceEntries({
cwd: workspaceDir,
query: "opencode",
limit: 20,
includeFiles: true,
includeDirectories: true,
});
expect(envResults).toContainEqual({ path: ".env.local", kind: "file" });
expect(opencodeResults).toContainEqual({ path: ".opencode", kind: "directory" });
expect(opencodeResults).toContainEqual({
path: ".opencode/settings.json",
kind: "file",
});
});
it("suffix mode finds files under dot-prefixed workspace directories", async () => {
mkdirSync(path.join(workspaceDir, ".claude"), { recursive: true });
mkdirSync(path.join(workspaceDir, ".github", "workflows"), { recursive: true });
writeFileSync(path.join(workspaceDir, ".claude", "settings.local.json"), "{}");
@@ -409,7 +439,7 @@ describe("searchWorkspaceEntries", () => {
expect(githubResults).toEqual([{ path: ".github/workflows/ci.yml", kind: "file" }]);
});
it("does not broadly traverse unlisted hidden workspace directories", async () => {
it("searches files under arbitrary dot-prefixed workspace directories", async () => {
mkdirSync(path.join(workspaceDir, ".dev", "cache"), { recursive: true });
writeFileSync(path.join(workspaceDir, ".dev", "cache", "needle.ts"), "");
writeFileSync(path.join(workspaceDir, "src", "needle.ts"), "");
@@ -423,10 +453,15 @@ describe("searchWorkspaceEntries", () => {
matchMode: "suffix",
});
expect(results).toEqual([{ path: "src/needle.ts", kind: "file" }]);
expect(results).toEqual(
expect.arrayContaining([
{ path: ".dev/cache/needle.ts", kind: "file" },
{ path: "src/needle.ts", kind: "file" },
]),
);
});
it("does not suggest hidden directories even when includeDirectories is true", async () => {
it("suggests dot-prefixed directories when includeDirectories is true", async () => {
mkdirSync(path.join(workspaceDir, ".claude"), { recursive: true });
writeFileSync(path.join(workspaceDir, ".claude", "settings.local.json"), "{}");
@@ -439,35 +474,52 @@ describe("searchWorkspaceEntries", () => {
matchMode: "fuzzy",
});
expect(results.some((entry) => entry.path === ".claude" && entry.kind === "directory")).toBe(
false,
);
expect(results).toContainEqual({ path: ".claude", kind: "directory" });
expect(results).toContainEqual({
path: ".claude/settings.local.json",
kind: "file",
});
});
it("path mode does not suggest hidden workspace directories", async () => {
it("path-style queries include dot-prefixed workspace directories", async () => {
mkdirSync(path.join(workspaceDir, ".claude"), { recursive: true });
writeFileSync(path.join(workspaceDir, ".claude", "settings.local.json"), "{}");
const results = await searchWorkspaceEntries({
cwd: workspaceDir,
query: "./",
query: "./.claude",
limit: 20,
includeFiles: true,
includeDirectories: true,
matchMode: "fuzzy",
});
expect(results).toContainEqual({
path: "README.md",
expect(results).toContainEqual({ path: ".claude", kind: "directory" });
expect(results).not.toContainEqual({
path: ".claude/settings.local.json",
kind: "file",
});
expect(results.some((entry) => entry.path === ".claude" && entry.kind === "directory")).toBe(
false,
);
});
it("constrains fuzzy path-style queries to the typed parent directory", async () => {
mkdirSync(path.join(workspaceDir, "src", "nested"), { recursive: true });
writeFileSync(path.join(workspaceDir, "src", "config.ts"), "");
writeFileSync(path.join(workspaceDir, "src", "nested", "config.ts"), "");
writeFileSync(path.join(workspaceDir, "docs", "config.ts"), "");
const results = await searchWorkspaceEntries({
cwd: workspaceDir,
query: "src/co",
limit: 20,
includeFiles: true,
includeDirectories: true,
matchMode: "fuzzy",
});
expect(results).toContainEqual({ path: "src/components", kind: "directory" });
expect(results).toContainEqual({ path: "src/config.ts", kind: "file" });
expect(results).not.toContainEqual({ path: "src/nested/config.ts", kind: "file" });
expect(results).not.toContainEqual({ path: "docs/config.ts", kind: "file" });
});
it("does not traverse .git while searching workspace files", async () => {
@@ -513,7 +565,8 @@ describe("searchWorkspaceEntries", () => {
},
);
it("ignores node_modules entries so deep workspace files still resolve under scan limits", async () => {
it("respects workspace ignore rules for node_modules", async () => {
writeFileSync(path.join(workspaceDir, ".gitignore"), "node_modules/\n");
mkdirSync(path.join(workspaceDir, "packages", "app", "src", "app"), { recursive: true });
writeFileSync(path.join(workspaceDir, "packages", "app", "src", "app", "_layout.tsx"), "");
@@ -532,7 +585,6 @@ describe("searchWorkspaceEntries", () => {
limit: 20,
includeFiles: true,
includeDirectories: true,
maxEntriesScanned: 60,
});
expect(results).toContainEqual({
@@ -542,11 +594,12 @@ describe("searchWorkspaceEntries", () => {
expect(results.some((entry) => entry.path.startsWith("node_modules/"))).toBe(false);
});
it("ignores common build/cache directories so large generated trees do not exhaust scan budget", async () => {
it("respects workspace ignore rules for generated trees", async () => {
mkdirSync(path.join(workspaceDir, "packages", "app", "src"), { recursive: true });
writeFileSync(path.join(workspaceDir, "packages", "app", "src", "needle.ts"), "");
const heavyDirs = ["dist", "build", "target", "out", "coverage", "vendor", "__pycache__"];
writeFileSync(path.join(workspaceDir, ".gitignore"), `${heavyDirs.join("/\n")}/\n`);
for (const heavyDir of heavyDirs) {
for (let index = 0; index < 30; index += 1) {
mkdirSync(path.join(workspaceDir, heavyDir, `bundle-${index}`), { recursive: true });
@@ -560,7 +613,6 @@ describe("searchWorkspaceEntries", () => {
limit: 20,
includeFiles: true,
includeDirectories: true,
maxEntriesScanned: 80,
});
expect(results).toContainEqual({

View File

@@ -0,0 +1,478 @@
import type { Dirent } from "node:fs";
import { readdir, realpath, stat } from "node:fs/promises";
import path from "node:path";
import { isPathInsideRoot } from "../../utils/path.js";
export interface SearchHomeDirectoriesOptions {
homeDir: string;
query: string;
limit?: number;
maxDepth?: number;
maxDirectoriesScanned?: number;
}
const DEFAULT_LIMIT = 30;
const MAX_LIMIT = 100;
const DEFAULT_MAX_DEPTH = 12;
const DEFAULT_MAX_DIRECTORIES_SCANNED = 20000;
const DIRECTORY_LIST_CACHE_TTL_MS = 8_000;
const DIRECTORY_LIST_CACHE_MAX_ENTRIES = 4_000;
const NO_SEGMENT_INDEX = Number.MAX_SAFE_INTEGER;
const NO_MATCH_OFFSET = Number.MAX_SAFE_INTEGER;
const IGNORED_SUGGESTION_DIRECTORY_NAMES = new Set([
"node_modules",
"venv",
"env",
"virtualenv",
"dist",
"build",
"target",
"out",
"coverage",
"vendor",
"__pycache__",
".git",
]);
interface QueryParts {
isPathQuery: boolean;
parentPart: string;
searchTerm: string;
}
interface RankedDirectory {
absolutePath: string;
matchTier: number;
segmentIndex: number;
matchOffset: number;
depth: number;
}
interface ChildDirectoryEntry {
name: string;
absolutePath: string;
}
interface DirectoryListCacheEntry {
expiresAt: number;
entries: ChildDirectoryEntry[];
}
const directoryListCache = new Map<string, DirectoryListCacheEntry>();
export async function searchHomeDirectories(
options: SearchHomeDirectoriesOptions,
): Promise<string[]> {
const query = options.query.trim();
if (!query) {
return [];
}
const limit = normalizeLimit(options.limit);
const homeRoot = await resolveDirectory(options.homeDir);
if (!homeRoot) {
return [];
}
const queryParts = normalizeQueryParts(query, homeRoot);
if (!queryParts) {
return [];
}
if (queryParts.isPathQuery) {
return searchWithinParentDirectory({
homeRoot,
parentPart: queryParts.parentPart,
searchTerm: queryParts.searchTerm,
limit,
});
}
return searchAcrossHomeTree({
homeRoot,
searchTerm: queryParts.searchTerm,
limit,
maxDepth: options.maxDepth ?? DEFAULT_MAX_DEPTH,
maxDirectoriesScanned: options.maxDirectoriesScanned ?? DEFAULT_MAX_DIRECTORIES_SCANNED,
});
}
function normalizeLimit(limit: number | undefined): number {
const candidate = limit ?? DEFAULT_LIMIT;
if (!Number.isFinite(candidate)) {
return DEFAULT_LIMIT;
}
const bounded = Math.trunc(candidate);
return Math.max(1, Math.min(MAX_LIMIT, bounded));
}
async function searchWithinParentDirectory(input: {
homeRoot: string;
parentPart: string;
searchTerm: string;
limit: number;
}): Promise<string[]> {
const parentPath = path.resolve(input.homeRoot, input.parentPart || ".");
const parentRoot = await resolveDirectory(parentPath);
if (!parentRoot || !isPathInsideRoot(input.homeRoot, parentRoot)) {
return [];
}
const searchLower = input.searchTerm.toLowerCase();
const ranked: RankedDirectory[] = [];
const entries = await listChildDirectories({
directory: parentRoot,
homeRoot: input.homeRoot,
});
for (const entry of entries) {
if (searchLower && !entry.name.toLowerCase().includes(searchLower)) {
continue;
}
ranked.push(
rankDirectory({
absolutePath: entry.absolutePath,
homeRoot: input.homeRoot,
searchLower,
}),
);
}
return dedupeAndSort(ranked).slice(0, input.limit);
}
async function searchAcrossHomeTree(input: {
homeRoot: string;
searchTerm: string;
limit: number;
maxDepth: number;
maxDirectoriesScanned: number;
}): Promise<string[]> {
const queue: Array<{ directory: string; depth: number }> = [
{ directory: input.homeRoot, depth: 0 },
];
const visited = new Set<string>([input.homeRoot]);
const ranked: RankedDirectory[] = [];
let scanned = 0;
const searchLower = input.searchTerm.toLowerCase();
for (
let queueIndex = 0;
queueIndex < queue.length && scanned < input.maxDirectoriesScanned;
queueIndex += 1
) {
const current = queue[queueIndex];
if (!current) continue;
const entries = await listChildDirectories({
directory: current.directory,
homeRoot: input.homeRoot,
});
for (const entry of entries) {
const resolvedCandidate = entry.absolutePath;
if (visited.has(resolvedCandidate)) {
continue;
}
visited.add(resolvedCandidate);
scanned += 1;
const relativePath = normalizeRelativePath(input.homeRoot, resolvedCandidate);
if (
relativePath.toLowerCase().includes(searchLower) ||
entry.name.toLowerCase().includes(searchLower)
) {
ranked.push(
rankDirectory({
absolutePath: resolvedCandidate,
homeRoot: input.homeRoot,
searchLower,
}),
);
}
if (current.depth < input.maxDepth && scanned < input.maxDirectoriesScanned) {
queue.push({ directory: resolvedCandidate, depth: current.depth + 1 });
}
}
}
return dedupeAndSort(ranked).slice(0, input.limit);
}
function dedupeAndSort(ranked: RankedDirectory[]): string[] {
const byPath = new Map<string, RankedDirectory>();
for (const entry of ranked) {
const existing = byPath.get(entry.absolutePath);
if (!existing || compareRankedDirectories(entry, existing) < 0) {
byPath.set(entry.absolutePath, entry);
}
}
return Array.from(byPath.values())
.sort(compareRankedDirectories)
.map((entry) => entry.absolutePath);
}
function compareRankedDirectories(left: RankedDirectory, right: RankedDirectory): number {
if (left.matchTier !== right.matchTier) {
return left.matchTier - right.matchTier;
}
if (left.segmentIndex !== right.segmentIndex) {
return left.segmentIndex - right.segmentIndex;
}
if (left.matchOffset !== right.matchOffset) {
return left.matchOffset - right.matchOffset;
}
if (left.depth !== right.depth) {
return left.depth - right.depth;
}
return left.absolutePath.localeCompare(right.absolutePath);
}
function rankDirectory(input: {
absolutePath: string;
homeRoot: string;
searchLower: string;
}): RankedDirectory {
const relative = normalizeRelativePath(input.homeRoot, input.absolutePath);
const relativeLower = relative.toLowerCase();
const depth = relative === "." ? 0 : relative.split("/").length;
const searchLower = input.searchLower;
if (!searchLower) {
return {
absolutePath: input.absolutePath,
matchTier: 3,
segmentIndex: NO_SEGMENT_INDEX,
matchOffset: 0,
depth,
};
}
const segments = relativeLower === "." ? [] : relativeLower.split("/");
const exactSegmentIndex = findSegmentMatchIndex(segments, (segment) => segment === searchLower);
const prefixSegmentIndex = findSegmentMatchIndex(segments, (segment) =>
segment.startsWith(searchLower),
);
const partialSegmentIndex = findSegmentMatchIndex(segments, (segment) =>
segment.includes(searchLower),
);
const matchOffset = relativeLower.indexOf(searchLower);
let matchTier = 4;
let segmentIndex = NO_SEGMENT_INDEX;
if (exactSegmentIndex >= 0) {
matchTier = 0;
segmentIndex = exactSegmentIndex;
} else if (prefixSegmentIndex >= 0) {
matchTier = 1;
segmentIndex = prefixSegmentIndex;
} else if (partialSegmentIndex >= 0) {
matchTier = 2;
segmentIndex = partialSegmentIndex;
} else if (relativeLower.startsWith(searchLower)) {
matchTier = 3;
}
return {
absolutePath: input.absolutePath,
matchTier,
segmentIndex,
matchOffset: matchOffset >= 0 ? matchOffset : NO_MATCH_OFFSET,
depth,
};
}
function findSegmentMatchIndex(
segments: string[],
predicate: (segment: string) => boolean,
): number {
for (let index = 0; index < segments.length; index += 1) {
const segment = segments[index];
if (!segment) {
continue;
}
if (predicate(segment)) {
return index;
}
}
return -1;
}
function normalizeRelativePath(homeRoot: string, absolutePath: string): string {
const relative = path.relative(homeRoot, absolutePath);
if (!relative) {
return ".";
}
return relative.split(path.sep).join("/");
}
function normalizeQueryParts(query: string, homeRoot: string): QueryParts | null {
const typedQuery = query.trim().replace(/\\/g, "/");
let normalized = typedQuery;
if (!normalized) {
return null;
}
// Only treat the query as a literal path when the user explicitly roots it
// with ~, ~/, ./, or an absolute path. Bare queries like "faro/main" are
// search terms, not paths.
let isRooted = false;
if (normalized.startsWith("~")) {
isRooted = true;
normalized = normalized.slice(1);
if (normalized.startsWith("/")) {
normalized = normalized.slice(1);
}
}
if (path.isAbsolute(normalized)) {
isRooted = true;
const absolute = path.resolve(normalized);
if (!isPathInsideRoot(homeRoot, absolute)) {
return null;
}
normalized = normalizeRelativePath(homeRoot, absolute);
}
if (normalized.startsWith("./")) {
isRooted = true;
}
normalized = normalized.replace(/^\.\/+/, "").replace(/\/{2,}/g, "/");
if (!normalized) {
// Treat "~" and "~/" as a request to browse the home root.
if (typedQuery === "~" || typedQuery === "~/") {
return {
isPathQuery: true,
parentPart: "",
searchTerm: "",
};
}
return null;
}
const isPathQuery = isRooted && normalized.includes("/");
if (!isPathQuery) {
return {
isPathQuery: false,
parentPart: "",
searchTerm: normalized,
};
}
const slashIndex = normalized.lastIndexOf("/");
const parentPart = normalized.slice(0, slashIndex);
const searchTerm = normalized.slice(slashIndex + 1);
return {
isPathQuery: true,
parentPart,
searchTerm,
};
}
async function resolveDirectory(inputPath: string): Promise<string | null> {
try {
const resolved = await realpath(path.resolve(inputPath));
const stats = await stat(resolved);
if (!stats.isDirectory()) {
return null;
}
return resolved;
} catch {
return null;
}
}
async function listChildDirectories(input: {
directory: string;
homeRoot: string;
}): Promise<ChildDirectoryEntry[]> {
const now = Date.now();
const cached = directoryListCache.get(input.directory);
if (cached && cached.expiresAt > now) {
return cached.entries;
}
const dirents = await readdir(input.directory, { withFileTypes: true }).catch(
() => [] as Dirent[],
);
const candidates = dirents.filter(
(dirent) =>
!isHiddenDirectoryName(dirent.name) &&
!isIgnoredSuggestionDirectoryName(dirent.name) &&
(dirent.isDirectory() || dirent.isSymbolicLink()),
);
const resolved = await Promise.all(
candidates.map(async (dirent) => {
const candidatePath = path.join(input.directory, dirent.name);
const absolutePath = await resolveDirectoryCandidate({
candidatePath,
dirent,
homeRoot: input.homeRoot,
});
return absolutePath ? { name: dirent.name, absolutePath } : null;
}),
);
const entries: ChildDirectoryEntry[] = resolved.filter(
(entry): entry is ChildDirectoryEntry => entry !== null,
);
setDirectoryListCache(input.directory, {
expiresAt: now + DIRECTORY_LIST_CACHE_TTL_MS,
entries,
});
return entries;
}
async function resolveDirectoryCandidate(input: {
candidatePath: string;
dirent: Dirent;
homeRoot: string;
}): Promise<string | null> {
if (input.dirent.isDirectory()) {
const resolved = path.resolve(input.candidatePath);
return isPathInsideRoot(input.homeRoot, resolved) ? resolved : null;
}
const resolved = await resolveDirectory(input.candidatePath);
if (!resolved || !isPathInsideRoot(input.homeRoot, resolved)) {
return null;
}
return resolved;
}
function isHiddenDirectoryName(name: string): boolean {
return name.startsWith(".");
}
function isIgnoredSuggestionDirectoryName(name: string): boolean {
return IGNORED_SUGGESTION_DIRECTORY_NAMES.has(name);
}
function setDirectoryListCache(cacheKey: string, entry: DirectoryListCacheEntry): void {
directoryListCache.set(cacheKey, entry);
pruneDirectoryListCache();
}
function pruneDirectoryListCache(): void {
if (directoryListCache.size <= DIRECTORY_LIST_CACHE_MAX_ENTRIES) {
return;
}
const now = Date.now();
for (const [cacheKey, entry] of directoryListCache) {
if (entry.expiresAt <= now) {
directoryListCache.delete(cacheKey);
}
}
while (directoryListCache.size > DIRECTORY_LIST_CACHE_MAX_ENTRIES) {
const oldestKey = directoryListCache.keys().next().value;
if (!oldestKey) {
return;
}
directoryListCache.delete(oldestKey);
}
}

View File

@@ -0,0 +1,706 @@
import { existsSync, readFileSync } from "node:fs";
import { realpath, stat } from "node:fs/promises";
import { homedir } from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import type {
DirItem,
FileFinder,
FileItem,
InitOptions,
MixedItem,
Result,
} from "@ff-labs/fff-node";
import { isPathInsideRoot } from "../../utils/path.js";
export type WorkspaceSuggestionKind = "file" | "directory";
export interface WorkspaceSuggestionEntry {
path: string;
kind: WorkspaceSuggestionKind;
}
export type WorkspaceMatchMode = "fuzzy" | "suffix";
export interface SearchWorkspaceEntriesOptions {
cwd: string;
query: string;
limit?: number;
includeFiles?: boolean;
includeDirectories?: boolean;
matchMode?: WorkspaceMatchMode;
}
type FffModule = typeof import("@ff-labs/fff-node");
interface WorkspaceFinderCacheEntry {
finder: FileFinder;
expiresAt: number;
}
interface WorkspaceQueryParts {
isPathQuery: boolean;
normalizedQuery: string;
parentPart: string;
searchTerm: string;
}
interface PackagedFffPackageJson {
main?: unknown;
exports?: unknown;
}
const DEFAULT_LIMIT = 30;
const MAX_LIMIT = 100;
const FFF_SCAN_WAIT_TIMEOUT_MS = 5_000;
const FFF_SEARCH_PAGE_SIZE = 1_000;
const FFF_FINDER_CACHE_TTL_MS = 120_000;
const FFF_FINDER_CACHE_MAX_ENTRIES = 24;
const workspaceFinderCache = new Map<string, WorkspaceFinderCacheEntry>();
let fffModulePromise: Promise<FffModule> | null = null;
export async function searchWorkspaceEntries(
options: SearchWorkspaceEntriesOptions,
): Promise<WorkspaceSuggestionEntry[]> {
const limit = normalizeLimit(options.limit);
const includeDirectories = options.includeDirectories ?? true;
const includeFiles = options.includeFiles ?? false;
if (!includeDirectories && !includeFiles) {
return [];
}
const workspaceRoot = await resolveDirectory(options.cwd);
if (!workspaceRoot) {
return [];
}
const queryParts = normalizeWorkspaceQueryParts(options.query, workspaceRoot);
if (!queryParts) {
return [];
}
const matchMode = options.matchMode ?? "fuzzy";
const exactEntry =
matchMode === "suffix"
? await resolveWorkspaceExactEntry({
workspaceRoot,
query: options.query,
includeDirectories,
includeFiles,
})
: null;
if (exactEntry && limit <= 1) {
return [exactEntry];
}
const searchQuery =
matchMode === "suffix" || queryParts.isPathQuery
? queryParts.normalizedQuery
: queryParts.searchTerm;
const parentConstraint =
matchMode === "fuzzy" && queryParts.isPathQuery ? queryParts.parentPart : null;
const finder = await getWorkspaceFinder(workspaceRoot);
const scan = await finder.waitForScan(FFF_SCAN_WAIT_TIMEOUT_MS);
if (!scan.ok) {
throw new Error(`Workspace search scan failed: ${scan.error}`);
}
if (!scan.value) {
throw new Error(`Workspace search scan timed out after ${FFF_SCAN_WAIT_TIMEOUT_MS}ms`);
}
const candidates = await searchFffEntries({
finder,
query: searchQuery,
includeDirectories,
includeFiles,
});
const entries = await normalizeFffEntries({
workspaceRoot,
candidates,
includeDirectories,
includeFiles,
matchMode,
parentConstraint,
suffixQuery: searchQuery,
});
const deduped = dedupeWorkspaceEntries(entries);
return exactEntry
? prependWorkspaceEntry(exactEntry, deduped).slice(0, limit)
: deduped.slice(0, limit);
}
export function clearWorkspaceSearchCacheForTests(): void {
for (const entry of workspaceFinderCache.values()) {
destroyFinder(entry.finder);
}
workspaceFinderCache.clear();
fffModulePromise = null;
}
async function getWorkspaceFinder(workspaceRoot: string): Promise<FileFinder> {
pruneWorkspaceFinderCache();
const now = Date.now();
const cached = workspaceFinderCache.get(workspaceRoot);
if (cached && cached.expiresAt > now && !cached.finder.isDestroyed) {
cached.expiresAt = now + FFF_FINDER_CACHE_TTL_MS;
return cached.finder;
}
if (cached) {
workspaceFinderCache.delete(workspaceRoot);
destroyFinder(cached.finder);
}
const { FileFinder } = await loadFffModule();
pruneWorkspaceFinderCache();
const afterImportCached = workspaceFinderCache.get(workspaceRoot);
if (
afterImportCached &&
afterImportCached.expiresAt > Date.now() &&
!afterImportCached.finder.isDestroyed
) {
afterImportCached.expiresAt = Date.now() + FFF_FINDER_CACHE_TTL_MS;
return afterImportCached.finder;
}
const created = FileFinder.create({
basePath: workspaceRoot,
aiMode: true,
disableMmapCache: true,
disableContentIndexing: true,
...(await getRootScanningOptions(workspaceRoot)),
});
if (!created.ok) {
throw new Error(`Workspace search initialization failed: ${created.error}`);
}
workspaceFinderCache.set(workspaceRoot, {
finder: created.value,
expiresAt: Date.now() + FFF_FINDER_CACHE_TTL_MS,
});
pruneWorkspaceFinderCache();
return created.value;
}
async function loadFffModule(): Promise<FffModule> {
if (!fffModulePromise) {
const packagedEntrypoint = resolvePackagedFffEntrypoint();
fffModulePromise = packagedEntrypoint
? import(pathToFileURL(packagedEntrypoint).href)
: import("@ff-labs/fff-node");
}
return fffModulePromise;
}
function resolvePackagedFffEntrypoint(): string | null {
const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath;
if (!resourcesPath) {
return null;
}
const packageRoot = path.join(
resourcesPath,
"app.asar.unpacked",
"node_modules",
"@ff-labs",
"fff-node",
);
// Load the unpacked package entry declared by FFF instead of duplicating its dist layout here.
const candidate = resolvePackagedFffEntrypointFromPackage(packageRoot);
return candidate && existsSync(candidate) ? candidate : null;
}
async function searchFffEntries(input: {
finder: FileFinder;
query: string;
includeDirectories: boolean;
includeFiles: boolean;
}): Promise<WorkspaceSuggestionEntry[]> {
if (input.includeDirectories && input.includeFiles) {
const result = unwrapFffResult(
input.finder.mixedSearch(input.query, { pageSize: FFF_SEARCH_PAGE_SIZE }),
"Workspace mixed search failed",
);
return result.items.map((item) => normalizeMixedItem(item));
}
if (input.includeFiles) {
const result = unwrapFffResult(
input.finder.fileSearch(input.query, { pageSize: FFF_SEARCH_PAGE_SIZE }),
"Workspace file search failed",
);
return result.items.map((item) => normalizeFileItem(item));
}
const result = unwrapFffResult(
input.finder.directorySearch(input.query, { pageSize: FFF_SEARCH_PAGE_SIZE }),
"Workspace directory search failed",
);
// FFF directory search only returns directories whose own names match. File search lets us
// derive matching ancestor directories from files nested below them.
const fileResult = unwrapFffResult(
input.finder.fileSearch(input.query, { pageSize: FFF_SEARCH_PAGE_SIZE }),
"Workspace file search failed",
);
return [
...result.items.map((item) => normalizeDirectoryItem(item)),
...fileResult.items.map((item) => normalizeFileItem(item)),
];
}
function unwrapFffResult<T>(result: Result<T>, message: string): T {
if (result.ok) {
return result.value;
}
throw new Error(`${message}: ${result.error}`);
}
function normalizeMixedItem(item: MixedItem): WorkspaceSuggestionEntry {
return item.type === "file" ? normalizeFileItem(item.item) : normalizeDirectoryItem(item.item);
}
function normalizeFileItem(item: FileItem): WorkspaceSuggestionEntry {
return {
path: normalizeFffRelativePath(item.relativePath),
kind: "file",
};
}
function normalizeDirectoryItem(item: DirItem): WorkspaceSuggestionEntry {
return {
path: normalizeFffRelativePath(item.relativePath).replace(/\/+$/, ""),
kind: "directory",
};
}
async function normalizeFffEntries(input: {
workspaceRoot: string;
candidates: WorkspaceSuggestionEntry[];
includeDirectories: boolean;
includeFiles: boolean;
matchMode: WorkspaceMatchMode;
parentConstraint: string | null;
suffixQuery: string;
}): Promise<WorkspaceSuggestionEntry[]> {
const entries: WorkspaceSuggestionEntry[] = [];
for (const candidate of input.candidates) {
for (const entry of expandFffCandidate({
candidate,
includeDirectories: input.includeDirectories,
matchMode: input.matchMode,
query: input.suffixQuery,
})) {
if (entry.kind === "directory" && !input.includeDirectories) {
continue;
}
if (entry.kind === "file" && !input.includeFiles) {
continue;
}
if (
input.parentConstraint !== null &&
!workspaceEntryIsDirectChildOfParent({
relativePath: entry.path,
parentPart: input.parentConstraint,
})
) {
continue;
}
if (
input.matchMode === "suffix" &&
!workspaceEntryMatchesSuffixQuery({
relativePath: entry.path,
query: input.suffixQuery,
})
) {
continue;
}
const resolved = await resolveIndexedWorkspaceEntry({
workspaceRoot: input.workspaceRoot,
relativePath: entry.path,
expectedKind: entry.kind,
});
if (resolved) {
entries.push(resolved);
}
}
}
return entries;
}
function expandFffCandidate(input: {
candidate: WorkspaceSuggestionEntry;
includeDirectories: boolean;
matchMode: WorkspaceMatchMode;
query: string;
}): WorkspaceSuggestionEntry[] {
if (!input.includeDirectories || input.candidate.kind !== "file") {
return [input.candidate];
}
return [
...collectMatchingAncestorDirectories({
relativePath: input.candidate.path,
matchMode: input.matchMode,
query: input.query,
}),
input.candidate,
];
}
function collectMatchingAncestorDirectories(input: {
relativePath: string;
matchMode: WorkspaceMatchMode;
query: string;
}): WorkspaceSuggestionEntry[] {
const segments = normalizeFffRelativePath(input.relativePath).split("/").filter(Boolean);
const directories: WorkspaceSuggestionEntry[] = [];
for (let segmentCount = 1; segmentCount < segments.length; segmentCount += 1) {
const directoryPath = segments.slice(0, segmentCount).join("/");
if (
workspaceDirectoryMatchesQuery({
relativePath: directoryPath,
matchMode: input.matchMode,
query: input.query,
})
) {
directories.push({ path: directoryPath, kind: "directory" });
}
}
return directories;
}
function workspaceDirectoryMatchesQuery(input: {
relativePath: string;
matchMode: WorkspaceMatchMode;
query: string;
}): boolean {
const normalizedQuery = normalizeFffRelativePath(input.query)
.trim()
.replace(/^\.\/+/, "")
.toLowerCase();
if (!normalizedQuery) {
return true;
}
if (input.matchMode === "suffix") {
return workspaceEntryMatchesSuffixQuery({
relativePath: input.relativePath,
query: normalizedQuery,
});
}
return normalizeFffRelativePath(input.relativePath).toLowerCase().includes(normalizedQuery);
}
async function resolveIndexedWorkspaceEntry(input: {
workspaceRoot: string;
relativePath: string;
expectedKind: WorkspaceSuggestionKind;
}): Promise<WorkspaceSuggestionEntry | null> {
const candidatePath = path.resolve(input.workspaceRoot, input.relativePath);
if (!isPathInsideRoot(input.workspaceRoot, candidatePath)) {
return null;
}
let resolvedPath: string;
try {
resolvedPath = await realpath(candidatePath);
} catch {
return null;
}
if (!isPathInsideRoot(input.workspaceRoot, resolvedPath)) {
return null;
}
const stats = await stat(resolvedPath).catch(() => null);
if (!stats) {
return null;
}
if (input.expectedKind === "file" && !stats.isFile()) {
return null;
}
if (input.expectedKind === "directory" && !stats.isDirectory()) {
return null;
}
return {
path: normalizeRelativePath(input.workspaceRoot, candidatePath),
kind: input.expectedKind,
};
}
async function resolveWorkspaceExactEntry(input: {
workspaceRoot: string;
query: string;
includeDirectories: boolean;
includeFiles: boolean;
}): Promise<WorkspaceSuggestionEntry | null> {
const normalized = normalizeFffRelativePath(input.query)
.trim()
.replace(/^\.\/+/, "")
.replace(/\/{2,}/g, "/");
if (!normalized) {
return null;
}
const candidatePath = path.isAbsolute(normalized)
? path.resolve(normalized)
: path.resolve(input.workspaceRoot, normalized);
let resolvedPath: string;
try {
resolvedPath = await realpath(candidatePath);
} catch {
return null;
}
if (!isPathInsideRoot(input.workspaceRoot, resolvedPath)) {
return null;
}
const stats = await stat(resolvedPath).catch(() => null);
if (!stats) {
return null;
}
if (stats.isFile() && input.includeFiles) {
return {
path: normalizeRelativePath(input.workspaceRoot, candidatePath),
kind: "file",
};
}
if (stats.isDirectory() && input.includeDirectories) {
return {
path: normalizeRelativePath(input.workspaceRoot, candidatePath),
kind: "directory",
};
}
return null;
}
function prependWorkspaceEntry(
entry: WorkspaceSuggestionEntry,
entries: WorkspaceSuggestionEntry[],
): WorkspaceSuggestionEntry[] {
return [
entry,
...entries.filter(
(candidate) => candidate.kind !== entry.kind || candidate.path !== entry.path,
),
];
}
function dedupeWorkspaceEntries(entries: WorkspaceSuggestionEntry[]): WorkspaceSuggestionEntry[] {
const seen = new Set<string>();
const deduped: WorkspaceSuggestionEntry[] = [];
for (const entry of entries) {
const key = `${entry.kind}:${entry.path}`;
if (seen.has(key)) {
continue;
}
seen.add(key);
deduped.push(entry);
}
return deduped;
}
function workspaceEntryMatchesSuffixQuery(input: { relativePath: string; query: string }): boolean {
const querySegments = normalizeFffRelativePath(input.query)
.trim()
.replace(/^\.\/+/, "")
.split("/")
.filter(Boolean)
.map((segment) => segment.toLowerCase());
if (querySegments.length === 0) {
return false;
}
const pathSegments = normalizeFffRelativePath(input.relativePath)
.split("/")
.filter(Boolean)
.map((segment) => segment.toLowerCase());
if (querySegments.length > pathSegments.length) {
return false;
}
const offset = pathSegments.length - querySegments.length;
return querySegments.every((segment, index) => pathSegments[offset + index] === segment);
}
function normalizeWorkspaceQueryParts(
query: string,
workspaceRoot: string,
): WorkspaceQueryParts | null {
let normalized = normalizeFffRelativePath(query.trim());
let isPathQuery = normalized.startsWith("./") || normalized.startsWith("../");
if (path.isAbsolute(normalized)) {
const absolute = path.resolve(normalized);
if (!isPathInsideRoot(workspaceRoot, absolute)) {
return null;
}
normalized = normalizeRelativePath(workspaceRoot, absolute);
isPathQuery = true;
}
normalized = normalized.replace(/^\.\/+/, "").replace(/\/{2,}/g, "/");
isPathQuery = isPathQuery || normalized.includes("/");
if (!normalized) {
return {
isPathQuery,
normalizedQuery: "",
parentPart: "",
searchTerm: "",
};
}
const slashIndex = normalized.lastIndexOf("/");
return {
isPathQuery,
normalizedQuery: normalized,
parentPart: slashIndex >= 0 ? normalized.slice(0, slashIndex) : "",
searchTerm: slashIndex >= 0 ? normalized.slice(slashIndex + 1) : normalized,
};
}
async function getRootScanningOptions(
workspaceRoot: string,
): Promise<Pick<InitOptions, "enableFsRootScanning" | "enableHomeDirScanning">> {
const homeRoot = await resolveDirectory(homedir());
return {
enableFsRootScanning: workspaceRoot === path.parse(workspaceRoot).root,
enableHomeDirScanning: homeRoot === workspaceRoot,
};
}
function resolvePackagedFffEntrypointFromPackage(packageRoot: string): string | null {
const packageJsonPath = path.join(packageRoot, "package.json");
if (!existsSync(packageJsonPath)) {
return null;
}
let packageJson: PackagedFffPackageJson;
try {
packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as PackagedFffPackageJson;
} catch {
return null;
}
const entrypoint = getPackageEntrypoint(packageJson) ?? null;
return entrypoint ? path.resolve(packageRoot, entrypoint) : null;
}
function getPackageEntrypoint(packageJson: PackagedFffPackageJson): string | null {
const exportedEntrypoint = getRootExportEntrypoint(packageJson.exports);
if (exportedEntrypoint) {
return exportedEntrypoint;
}
return typeof packageJson.main === "string" ? packageJson.main : null;
}
function getRootExportEntrypoint(exportsField: unknown): string | null {
if (typeof exportsField === "string") {
return exportsField;
}
if (!exportsField || typeof exportsField !== "object") {
return null;
}
const rootExport = (exportsField as Record<string, unknown>)["."];
if (typeof rootExport === "string") {
return rootExport;
}
if (!rootExport || typeof rootExport !== "object") {
return null;
}
const conditions = rootExport as Record<string, unknown>;
if (typeof conditions.import === "string") {
return conditions.import;
}
return typeof conditions.default === "string" ? conditions.default : null;
}
function workspaceEntryIsDirectChildOfParent(input: {
relativePath: string;
parentPart: string;
}): boolean {
const relativePath = normalizeFffRelativePath(input.relativePath).replace(/^\.\/+/, "");
const parentPart = normalizeFffRelativePath(input.parentPart)
.replace(/^\.\/+/, "")
.replace(/\/+$/, "");
if (!parentPart) {
return relativePath.length > 0 && !relativePath.includes("/");
}
if (!relativePath.startsWith(`${parentPart}/`)) {
return false;
}
const childPart = relativePath.slice(parentPart.length + 1);
return childPart.length > 0 && !childPart.includes("/");
}
function normalizeLimit(limit: number | undefined): number {
const candidate = limit ?? DEFAULT_LIMIT;
if (!Number.isFinite(candidate)) {
return DEFAULT_LIMIT;
}
const bounded = Math.trunc(candidate);
return Math.max(1, Math.min(MAX_LIMIT, bounded));
}
async function resolveDirectory(inputPath: string): Promise<string | null> {
try {
const resolved = await realpath(path.resolve(inputPath));
const stats = await stat(resolved);
if (!stats.isDirectory()) {
return null;
}
return resolved;
} catch {
return null;
}
}
function normalizeRelativePath(root: string, absolutePath: string): string {
const relative = path.relative(root, absolutePath);
if (!relative) {
return ".";
}
return relative.split(path.sep).join("/");
}
function normalizeFffRelativePath(value: string): string {
return value.replace(/\\/g, "/");
}
function pruneWorkspaceFinderCache(): void {
const now = Date.now();
for (const [cacheKey, entry] of workspaceFinderCache) {
if (entry.expiresAt <= now || entry.finder.isDestroyed) {
workspaceFinderCache.delete(cacheKey);
destroyFinder(entry.finder);
}
}
while (workspaceFinderCache.size > FFF_FINDER_CACHE_MAX_ENTRIES) {
const oldestKey = workspaceFinderCache.keys().next().value;
if (!oldestKey) {
return;
}
const oldest = workspaceFinderCache.get(oldestKey);
workspaceFinderCache.delete(oldestKey);
if (oldest) {
destroyFinder(oldest.finder);
}
}
}
function destroyFinder(finder: FileFinder): void {
if (finder.isDestroyed) {
return;
}
try {
finder.destroy();
} catch {
// Destroy is best-effort cleanup for native handles.
}
}

View File

@@ -174,7 +174,8 @@ import {
type AgentUpdatesService,
} from "./session/agent-updates/agent-updates-service.js";
import { expandTilde } from "../utils/path.js";
import { searchHomeDirectories, searchWorkspaceEntries } from "../utils/directory-suggestions.js";
import { searchHomeDirectories } from "./search/home-directories.js";
import { searchWorkspaceEntries } from "./search/workspace-entries.js";
import type { CheckoutDiffManager } from "./checkout-diff-manager.js";
import type { Resolvable } from "./speech/provider-resolver.js";
import type { SpeechReadinessSnapshot } from "./speech/speech-runtime.js";

View File

@@ -2,6 +2,14 @@ import { describe, expect, test } from "vitest";
import { PersistedConfigSchema } from "../../../persisted-config.js";
import { resolveOpenAiSpeechConfig } from "./config.js";
import type { RequestedSpeechProviders } from "../../speech-types.js";
const ALL_OPENAI: RequestedSpeechProviders = {
dictationStt: { provider: "openai", explicit: true },
voiceTurnDetection: { provider: "local", explicit: false },
voiceStt: { provider: "openai", explicit: true },
voiceTts: { provider: "openai", explicit: true },
};
describe("resolveOpenAiSpeechConfig", () => {
test("treats empty OPENAI_API_KEY as unset", () => {
@@ -14,6 +22,7 @@ describe("resolveOpenAiSpeechConfig", () => {
env,
persisted,
providers: {
...ALL_OPENAI,
dictationStt: { provider: "local", explicit: false },
voiceStt: { provider: "local", explicit: false },
voiceTts: { provider: "local", explicit: false },
@@ -23,138 +32,175 @@ describe("resolveOpenAiSpeechConfig", () => {
expect(resolved).toBeUndefined();
});
test("uses trimmed OPENAI_API_KEY when configured", () => {
test("applies trimmed OPENAI_API_KEY to both STT and TTS", () => {
const persisted = PersistedConfigSchema.parse({});
const env = {
OPENAI_API_KEY: " sk-test ",
} as NodeJS.ProcessEnv;
const resolved = resolveOpenAiSpeechConfig({
env,
persisted,
providers: {
dictationStt: { provider: "openai", explicit: true },
voiceStt: { provider: "openai", explicit: true },
voiceTts: { provider: "openai", explicit: true },
},
});
const resolved = resolveOpenAiSpeechConfig({ env, persisted, providers: ALL_OPENAI });
expect(resolved?.apiKey).toBe("sk-test");
expect(resolved?.stt?.apiKey).toBe("sk-test");
expect(resolved?.tts?.apiKey).toBe("sk-test");
});
test("uses nested voice config before env and non-voice fallbacks", () => {
test("resolves distinct endpoints for STT and TTS", () => {
const persisted = PersistedConfigSchema.parse({
providers: {
openai: {
apiKey: "fallback-config-key",
voice: {
apiKey: "voice-config-key",
baseUrl: " https://voice.example.com/v1 ",
stt: {
apiKey: "stt-key",
baseUrl: " https://stt.example.com/v1 ",
},
tts: {
apiKey: "tts-key",
baseUrl: " https://tts.example.com/v1 ",
},
baseUrl: "https://legacy-config.example.com/v1",
},
},
});
const env = {
OPENAI_API_KEY: "env-key",
OPENAI_VOICE_API_KEY: "voice-env-key",
OPENAI_VOICE_BASE_URL: "https://voice-env.example.com/v1",
OPENAI_BASE_URL: "https://env.example.com/v1",
} as NodeJS.ProcessEnv;
const resolved = resolveOpenAiSpeechConfig({
env,
env: {} as NodeJS.ProcessEnv,
persisted,
providers: {
dictationStt: { provider: "openai", explicit: true },
voiceStt: { provider: "openai", explicit: true },
voiceTts: { provider: "openai", explicit: true },
},
providers: ALL_OPENAI,
});
expect(resolved?.apiKey).toBe("voice-config-key");
expect(resolved?.baseUrl).toBe("https://voice.example.com/v1");
expect(resolved?.stt?.apiKey).toBe("voice-config-key");
expect(resolved?.stt?.baseUrl).toBe("https://voice.example.com/v1");
expect(resolved?.tts?.apiKey).toBe("voice-config-key");
expect(resolved?.tts?.baseUrl).toBe("https://voice.example.com/v1");
expect(resolved?.stt?.apiKey).toBe("stt-key");
expect(resolved?.stt?.baseUrl).toBe("https://stt.example.com/v1");
expect(resolved?.tts?.apiKey).toBe("tts-key");
expect(resolved?.tts?.baseUrl).toBe("https://tts.example.com/v1");
});
test("uses voice env config when nested voice config is unset", () => {
const persisted = PersistedConfigSchema.parse({});
const env = {
OPENAI_API_KEY: "sk-test",
OPENAI_VOICE_API_KEY: "voice-env-key",
OPENAI_VOICE_BASE_URL: " https://voice-env.example.com/v1 ",
OPENAI_BASE_URL: "https://env.example.com/v1",
} as NodeJS.ProcessEnv;
const resolved = resolveOpenAiSpeechConfig({
env,
persisted,
providers: {
dictationStt: { provider: "openai", explicit: true },
voiceStt: { provider: "openai", explicit: true },
voiceTts: { provider: "openai", explicit: true },
},
});
expect(resolved?.apiKey).toBe("voice-env-key");
expect(resolved?.stt?.apiKey).toBe("voice-env-key");
expect(resolved?.tts?.apiKey).toBe("voice-env-key");
expect(resolved?.baseUrl).toBe("https://voice-env.example.com/v1");
expect(resolved?.stt?.baseUrl).toBe("https://voice-env.example.com/v1");
expect(resolved?.tts?.baseUrl).toBe("https://voice-env.example.com/v1");
});
test("falls back to non-voice OpenAI config", () => {
test("prefers nested STT/TTS config over env and global fallbacks", () => {
const persisted = PersistedConfigSchema.parse({
providers: {
openai: {
apiKey: "fallback-config-key",
baseUrl: " https://legacy-config.example.com/v1 ",
baseUrl: "https://global-config.example.com/v1",
stt: { apiKey: "stt-config-key", baseUrl: " https://stt.example.com/v1 " },
tts: { apiKey: "tts-config-key", baseUrl: " https://tts.example.com/v1 " },
},
},
});
const env = {
OPENAI_API_KEY: "sk-test",
OPENAI_BASE_URL: " https://env.example.com/v1 ",
OPENAI_API_KEY: "env-key",
OPENAI_STT_API_KEY: "stt-env-key",
OPENAI_STT_BASE_URL: "https://stt-env.example.com/v1",
OPENAI_TTS_API_KEY: "tts-env-key",
OPENAI_TTS_BASE_URL: "https://tts-env.example.com/v1",
OPENAI_BASE_URL: "https://env.example.com/v1",
} as NodeJS.ProcessEnv;
const resolved = resolveOpenAiSpeechConfig({
env,
persisted,
providers: {
dictationStt: { provider: "openai", explicit: true },
voiceStt: { provider: "openai", explicit: true },
voiceTts: { provider: "openai", explicit: true },
},
});
const resolved = resolveOpenAiSpeechConfig({ env, persisted, providers: ALL_OPENAI });
expect(resolved?.apiKey).toBe("fallback-config-key");
expect(resolved?.baseUrl).toBe("https://legacy-config.example.com/v1");
expect(resolved?.stt?.apiKey).toBe("stt-config-key");
expect(resolved?.stt?.baseUrl).toBe("https://stt.example.com/v1");
expect(resolved?.tts?.apiKey).toBe("tts-config-key");
expect(resolved?.tts?.baseUrl).toBe("https://tts.example.com/v1");
});
test("falls back to global OpenAI env config when voice-specific inputs are unset", () => {
test("uses STT/TTS env config when nested config is unset", () => {
const persisted = PersistedConfigSchema.parse({});
const env = {
OPENAI_API_KEY: "sk-test",
OPENAI_STT_API_KEY: "stt-env-key",
OPENAI_STT_BASE_URL: " https://stt-env.example.com/v1 ",
OPENAI_TTS_API_KEY: "tts-env-key",
OPENAI_TTS_BASE_URL: " https://tts-env.example.com/v1 ",
OPENAI_BASE_URL: "https://env.example.com/v1",
} as NodeJS.ProcessEnv;
const resolved = resolveOpenAiSpeechConfig({ env, persisted, providers: ALL_OPENAI });
expect(resolved?.stt?.apiKey).toBe("stt-env-key");
expect(resolved?.stt?.baseUrl).toBe("https://stt-env.example.com/v1");
expect(resolved?.tts?.apiKey).toBe("tts-env-key");
expect(resolved?.tts?.baseUrl).toBe("https://tts-env.example.com/v1");
});
test("falls back to global OpenAI config for both STT and TTS", () => {
const persisted = PersistedConfigSchema.parse({
providers: {
openai: {
apiKey: "fallback-config-key",
baseUrl: " https://global-config.example.com/v1 ",
},
},
});
const env = {} as NodeJS.ProcessEnv;
const resolved = resolveOpenAiSpeechConfig({ env, persisted, providers: ALL_OPENAI });
expect(resolved?.stt?.apiKey).toBe("fallback-config-key");
expect(resolved?.stt?.baseUrl).toBe("https://global-config.example.com/v1");
expect(resolved?.tts?.apiKey).toBe("fallback-config-key");
expect(resolved?.tts?.baseUrl).toBe("https://global-config.example.com/v1");
});
test("falls back to global OpenAI env config when feature inputs are unset", () => {
const persisted = PersistedConfigSchema.parse({});
const env = {
OPENAI_API_KEY: "env-key",
OPENAI_BASE_URL: " https://env.example.com/v1 ",
} as NodeJS.ProcessEnv;
const resolved = resolveOpenAiSpeechConfig({
env,
persisted,
const resolved = resolveOpenAiSpeechConfig({ env, persisted, providers: ALL_OPENAI });
expect(resolved?.stt?.apiKey).toBe("env-key");
expect(resolved?.stt?.baseUrl).toBe("https://env.example.com/v1");
expect(resolved?.tts?.apiKey).toBe("env-key");
expect(resolved?.tts?.baseUrl).toBe("https://env.example.com/v1");
});
test("ignores empty endpoint env vars and falls back to OPENAI_API_KEY", () => {
const persisted = PersistedConfigSchema.parse({});
const env = {
OPENAI_API_KEY: "global-key",
OPENAI_STT_API_KEY: "",
OPENAI_STT_BASE_URL: " ",
OPENAI_TTS_API_KEY: "",
OPENAI_TTS_BASE_URL: "",
} as NodeJS.ProcessEnv;
const resolved = resolveOpenAiSpeechConfig({ env, persisted, providers: ALL_OPENAI });
expect(resolved?.stt?.apiKey).toBe("global-key");
expect(resolved?.tts?.apiKey).toBe("global-key");
});
test("omits TTS when only an STT key is configured", () => {
const persisted = PersistedConfigSchema.parse({
providers: {
dictationStt: { provider: "openai", explicit: true },
voiceStt: { provider: "openai", explicit: true },
voiceTts: { provider: "openai", explicit: true },
openai: {
stt: { apiKey: "stt-only-key" },
},
},
});
expect(resolved?.apiKey).toBe("env-key");
expect(resolved?.baseUrl).toBe("https://env.example.com/v1");
const resolved = resolveOpenAiSpeechConfig({
env: {} as NodeJS.ProcessEnv,
persisted,
providers: ALL_OPENAI,
});
expect(resolved?.stt?.apiKey).toBe("stt-only-key");
expect(resolved?.tts).toBeUndefined();
});
test("resolves STT even when an unused TTS env var is invalid", () => {
const persisted = PersistedConfigSchema.parse({
providers: {
openai: {
stt: { apiKey: "stt-only-key" },
},
},
});
const env = { TTS_VOICE: "not-a-real-voice", TTS_MODEL: "bogus-model" } as NodeJS.ProcessEnv;
const resolved = resolveOpenAiSpeechConfig({ env, persisted, providers: ALL_OPENAI });
expect(resolved?.stt?.apiKey).toBe("stt-only-key");
expect(resolved?.tts).toBeUndefined();
});
});

View File

@@ -8,8 +8,6 @@ import type { TTSConfig } from "./tts.js";
export const DEFAULT_OPENAI_TTS_MODEL = "tts-1";
export interface OpenAiSpeechProviderConfig {
apiKey?: string;
baseUrl?: string;
stt?: Partial<STTConfig> & { apiKey?: string };
tts?: Partial<TTSConfig> & { apiKey?: string };
}
@@ -30,11 +28,23 @@ const OptionalTrimmedStringSchema = z
.optional()
.transform((value) => (value && value.length > 0 ? value : undefined));
const OpenAiSpeechResolutionSchema = z.object({
apiKey: OptionalTrimmedStringSchema,
baseUrl: OptionalTrimmedStringSchema,
// Endpoint credentials only — plain trimmed strings, so this never throws on a
// malformed value. The STT/TTS option groups parse separately and only for the
// endpoint that is actually configured, so a stale env var for an unused endpoint
// (e.g. a leftover TTS_VOICE in an STT-only setup) can't break the other one.
const OpenAiEndpointKeysSchema = z.object({
sttApiKey: OptionalTrimmedStringSchema,
sttBaseUrl: OptionalTrimmedStringSchema,
ttsApiKey: OptionalTrimmedStringSchema,
ttsBaseUrl: OptionalTrimmedStringSchema,
});
const OpenAiSttOptionsSchema = z.object({
sttConfidenceThreshold: OptionalFiniteNumberSchema,
sttModel: OptionalTrimmedStringSchema,
});
const OpenAiTtsOptionsSchema = z.object({
ttsVoice: z.string().trim().toLowerCase().pipe(OpenAiTtsVoiceSchema).default("alloy"),
ttsModel: z
.string()
@@ -57,9 +67,15 @@ function pickIfOpenAi<T>(
function firstDefined<T>(values: Array<T | null | undefined>): T | undefined {
for (const value of values) {
if (value !== undefined && value !== null) {
return value;
if (value === undefined || value === null) {
continue;
}
// Empty/whitespace env vars (e.g. a copied .env.example with OPENAI_STT_API_KEY=)
// must not shadow a later fallback such as OPENAI_API_KEY.
if (typeof value === "string" && value.trim().length === 0) {
continue;
}
return value;
}
return undefined;
}
@@ -108,18 +124,32 @@ function buildOpenAiResolutionInput(params: {
persisted: PersistedConfig;
providers: RequestedSpeechProviders;
}): Record<string, unknown> {
const { env } = params;
const openai = params.persisted.providers?.openai;
return {
apiKey: firstDefined<string>([
params.persisted.providers?.openai?.voice?.apiKey,
params.env.OPENAI_VOICE_API_KEY,
params.persisted.providers?.openai?.apiKey,
params.env.OPENAI_API_KEY,
sttApiKey: firstDefined<string>([
openai?.stt?.apiKey,
env.OPENAI_STT_API_KEY,
openai?.apiKey,
env.OPENAI_API_KEY,
]),
baseUrl: firstDefined<string>([
params.persisted.providers?.openai?.voice?.baseUrl,
params.env.OPENAI_VOICE_BASE_URL,
params.persisted.providers?.openai?.baseUrl,
params.env.OPENAI_BASE_URL,
sttBaseUrl: firstDefined<string>([
openai?.stt?.baseUrl,
env.OPENAI_STT_BASE_URL,
openai?.baseUrl,
env.OPENAI_BASE_URL,
]),
ttsApiKey: firstDefined<string>([
openai?.tts?.apiKey,
env.OPENAI_TTS_API_KEY,
openai?.apiKey,
env.OPENAI_API_KEY,
]),
ttsBaseUrl: firstDefined<string>([
openai?.tts?.baseUrl,
env.OPENAI_TTS_BASE_URL,
openai?.baseUrl,
env.OPENAI_BASE_URL,
]),
...buildOpenAiSttInput(params),
...buildOpenAiTtsInput(params),
@@ -131,29 +161,46 @@ export function resolveOpenAiSpeechConfig(params: {
persisted: PersistedConfig;
providers: RequestedSpeechProviders;
}): OpenAiSpeechProviderConfig | undefined {
const parsed = OpenAiSpeechResolutionSchema.parse(buildOpenAiResolutionInput(params));
const input = buildOpenAiResolutionInput(params);
const keys = OpenAiEndpointKeysSchema.parse(input);
if (!parsed.apiKey) {
if (!keys.sttApiKey && !keys.ttsApiKey) {
return undefined;
}
return {
apiKey: parsed.apiKey,
...(parsed.baseUrl ? { baseUrl: parsed.baseUrl } : {}),
stt: {
apiKey: parsed.apiKey,
...(parsed.baseUrl ? { baseUrl: parsed.baseUrl } : {}),
...(parsed.sttConfidenceThreshold !== undefined
? { confidenceThreshold: parsed.sttConfidenceThreshold }
: {}),
...(parsed.sttModel ? { model: parsed.sttModel } : {}),
},
tts: {
apiKey: parsed.apiKey,
...(parsed.baseUrl ? { baseUrl: parsed.baseUrl } : {}),
voice: parsed.ttsVoice,
model: parsed.ttsModel,
responseFormat: "pcm",
},
...(keys.sttApiKey ? { stt: buildSttConfig(keys.sttApiKey, keys.sttBaseUrl, input) } : {}),
...(keys.ttsApiKey ? { tts: buildTtsConfig(keys.ttsApiKey, keys.ttsBaseUrl, input) } : {}),
};
}
function buildSttConfig(
apiKey: string,
baseUrl: string | undefined,
input: Record<string, unknown>,
): OpenAiSpeechProviderConfig["stt"] {
const options = OpenAiSttOptionsSchema.parse(input);
return {
apiKey,
...(baseUrl ? { baseUrl } : {}),
...(options.sttConfidenceThreshold !== undefined
? { confidenceThreshold: options.sttConfidenceThreshold }
: {}),
...(options.sttModel ? { model: options.sttModel } : {}),
};
}
function buildTtsConfig(
apiKey: string,
baseUrl: string | undefined,
input: Record<string, unknown>,
): OpenAiSpeechProviderConfig["tts"] {
const options = OpenAiTtsOptionsSchema.parse(input);
return {
apiKey,
...(baseUrl ? { baseUrl } : {}),
voice: options.ttsVoice,
model: options.ttsModel,
responseFormat: "pcm",
};
}

View File

@@ -15,7 +15,6 @@ describe("initializeOpenAiSpeechServices", () => {
voiceTts: { provider: "openai", explicit: true },
},
openaiConfig: {
apiKey: "sk-test",
stt: { apiKey: "sk-test" },
tts: { apiKey: "sk-test" },
},

View File

@@ -29,11 +29,11 @@ export interface SpeechServices {
function resolveOpenAiCredentials(
openaiConfig: OpenAiSpeechProviderConfig | undefined,
): OpenAiCredentialState {
const openaiApiKey = openaiConfig?.apiKey;
const sttApiKey = openaiConfig?.stt?.apiKey;
return {
openaiSttApiKey: openaiConfig?.stt?.apiKey ?? openaiApiKey,
openaiTtsApiKey: openaiConfig?.tts?.apiKey ?? openaiApiKey,
openaiDictationApiKey: openaiApiKey,
openaiSttApiKey: sttApiKey,
openaiTtsApiKey: openaiConfig?.tts?.apiKey,
openaiDictationApiKey: sttApiKey,
};
}

View File

@@ -129,7 +129,8 @@ describe("resolveSpeechConfig", () => {
dictation: "es",
voice: "pt",
});
expect(result.openai?.apiKey).toBe("persisted-key");
expect(result.openai?.stt?.apiKey).toBe("persisted-key");
expect(result.openai?.tts?.apiKey).toBe("persisted-key");
expect(result.openai?.stt?.model).toBe("gpt-4o-transcribe");
});

View File

@@ -79,7 +79,7 @@ function waitForSignal<T>(
beforeAll(async () => {
ctx = await createDaemonTestContext({
agentClients: {},
openai: { apiKey: openaiApiKey! },
openai: { stt: { apiKey: openaiApiKey! }, tts: { apiKey: openaiApiKey! } },
speech: {
providers: {
dictationStt: { provider: "openai", explicit: true },

View File

@@ -161,7 +161,7 @@ let ctx: DaemonTestContext;
beforeAll(async () => {
ctx = await createDaemonTestContext({
agentClients: {},
openai: { apiKey: openaiApiKey! },
openai: { stt: { apiKey: openaiApiKey! }, tts: { apiKey: openaiApiKey! } },
speech: {
providers: {
dictationStt: { provider: "openai", explicit: true },

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.1.102-beta.2",
"version": "0.1.102",
"private": true,
"type": "module",
"scripts": {

View File

@@ -174,6 +174,38 @@
"apiKey": {
"type": "string",
"minLength": 1
},
"baseUrl": {
"type": "string",
"minLength": 1
},
"stt": {
"type": "object",
"properties": {
"apiKey": {
"type": "string",
"minLength": 1
},
"baseUrl": {
"type": "string",
"minLength": 1
}
},
"additionalProperties": false
},
"tts": {
"type": "object",
"properties": {
"apiKey": {
"type": "string",
"minLength": 1
},
"baseUrl": {
"type": "string",
"minLength": 1
}
},
"additionalProperties": false
}
},
"additionalProperties": false

View File

@@ -16,6 +16,8 @@ Projects built by the Paseo community. These **aren't official Paseo projects**
- **[zenghongtu/paseo-relay](https://github.com/zenghongtu/paseo-relay)**, a lightweight self-hosted relay server for Paseo, written in Go. Run your own relay instead of the hosted one for fully self-hosted remote access. For how the relay fits into Paseo's connection model, see [Security](/docs/security).
- **[paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode)**, a VS Code extension.
## Add your project
Built something for Paseo, a relay, a deployment recipe, an integration, a client? Open a pull request or an issue on [GitHub](https://github.com/getpaseo/paseo) to get it listed here.

View File

@@ -204,6 +204,8 @@ In the mobile app, enter the password in the direct connection setup screen.
- `PASEO_LOG_FILE_ROTATE_COUNT`, override `log.file.rotate.maxFiles`
- `PASEO_LOG`, `PASEO_LOG_FORMAT`, legacy log overrides (still supported)
- `OPENAI_API_KEY`, override OpenAI provider key
- `OPENAI_STT_API_KEY`, `OPENAI_STT_BASE_URL`, OpenAI speech-to-text endpoint (dictation + voice mode STT)
- `OPENAI_TTS_API_KEY`, `OPENAI_TTS_BASE_URL`, OpenAI text-to-speech endpoint (voice mode TTS)
- `PASEO_VOICE_LLM_PROVIDER`, override voice LLM provider (`claude`, `codex`, `opencode`)
- `PASEO_DICTATION_STT_PROVIDER`, `PASEO_VOICE_STT_PROVIDER`, `PASEO_VOICE_TTS_PROVIDER`, override voice provider selection (`local` or `openai`)
- `PASEO_LOCAL_MODELS_DIR`, control local model directory

View File

@@ -90,7 +90,11 @@ You can switch dictation, voice STT, and voice TTS to OpenAI by setting provider
},
"providers": {
"openai": {
"voice": {
"stt": {
"apiKey": "...",
"baseUrl": "https://api.openai.com/v1"
},
"tts": {
"apiKey": "...",
"baseUrl": "https://api.openai.com/v1"
}
@@ -99,7 +103,7 @@ You can switch dictation, voice STT, and voice TTS to OpenAI by setting provider
}
```
`providers.openai.voice.apiKey` and `providers.openai.voice.baseUrl` configure only Paseo OpenAI voice traffic, without changing Codex or other OpenAI-backed tools.
`providers.openai.stt` covers dictation and voice mode speech-to-text, and `providers.openai.tts` covers voice mode text-to-speech. Because they resolve independently, you can point STT and TTS at different endpoints. Each falls back to `providers.openai.apiKey`/`baseUrl`, then `OPENAI_API_KEY`/`OPENAI_BASE_URL`, when unset. These settings configure only Paseo OpenAI speech traffic, without changing Codex or other OpenAI-backed tools.
Paseo uses these paths under the configured OpenAI base URL:
@@ -111,6 +115,8 @@ Paseo uses these paths under the configured OpenAI base URL:
- `PASEO_VOICE_LLM_PROVIDER`, voice agent provider override
- `PASEO_DICTATION_STT_PROVIDER`, `PASEO_VOICE_STT_PROVIDER`, `PASEO_VOICE_TTS_PROVIDER`, speech provider selection (`local` or `openai`)
- `OPENAI_STT_API_KEY`, `OPENAI_STT_BASE_URL`, OpenAI speech-to-text endpoint (dictation + voice mode STT)
- `OPENAI_TTS_API_KEY`, `OPENAI_TTS_BASE_URL`, OpenAI text-to-speech endpoint (voice mode TTS)
- `PASEO_LOCAL_MODELS_DIR`, local model storage directory
- `PASEO_DICTATION_LOCAL_STT_MODEL`, local dictation STT model ID
- `PASEO_VOICE_LOCAL_STT_MODEL`, `PASEO_VOICE_LOCAL_TTS_MODEL`, local voice STT/TTS model IDs

View File

@@ -27,6 +27,29 @@ const entries = [
"packages/server/dist/server/terminal/terminal-worker-process.js",
];
const FFF_BIN_PACKAGE = {
darwin: { arm64: "@ff-labs/fff-bin-darwin-arm64", x64: "@ff-labs/fff-bin-darwin-x64" },
linux: { arm64: "@ff-labs/fff-bin-linux-arm64-gnu", x64: "@ff-labs/fff-bin-linux-x64-gnu" },
win32: { arm64: "@ff-labs/fff-bin-win32-arm64", x64: "@ff-labs/fff-bin-win32-x64" },
};
const FFI_RS_PACKAGE = {
darwin: { arm64: "@yuuang/ffi-rs-darwin-arm64", x64: "@yuuang/ffi-rs-darwin-x64" },
linux: { arm64: "@yuuang/ffi-rs-linux-arm64-gnu", x64: "@yuuang/ffi-rs-linux-x64-gnu" },
win32: {
arm64: "@yuuang/ffi-rs-win32-arm64-msvc",
x64: "@yuuang/ffi-rs-win32-x64-msvc",
},
};
function packageGlob(packageName) {
return packageName ? [`node_modules/${packageName}/**`] : [];
}
function packageForHost(packagesByPlatform) {
return packagesByPlatform[process.platform]?.[process.arch] ?? null;
}
// Files read at runtime via fs APIs rather than `require`. nft only
// traces the module graph; data files have to be listed explicitly.
const additionalInputs = [
@@ -44,6 +67,11 @@ const additionalInputs = [
// the Nix derivation builds for one platform at a time and ships only
// its own binaries.
`node_modules/node-pty/prebuilds/${process.platform}-${process.arch}/**`,
// FFF resolves its native search library and ffi-rs binding dynamically.
"node_modules/@ff-labs/fff-node/**",
"node_modules/ffi-rs/**",
...packageGlob(packageForHost(FFF_BIN_PACKAGE)),
...packageGlob(packageForHost(FFI_RS_PACKAGE)),
];
// Trace.
@@ -65,6 +93,10 @@ const { fileList, warnings } = await nodeFileTrace(entries, {
// node-fetch optional peer for non-UTF-8 charset decoding; not
// loaded in our usage.
"encoding/**",
// Host packages are added explicitly above; other optional native
// variants are intentionally not part of the daemon closure.
"@ff-labs/fff-bin-*/**",
"@yuuang/ffi-rs-*/**",
// Tests are stripped during the daemon build; nft sometimes still
// tries to walk into them via index files. Belt and suspenders.
"**/*.test.js",