Compare commits

..

5 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
46 changed files with 2015 additions and 1374 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,11 +1,5 @@
# Changelog
## 0.1.103 - 2026-07-01
### Added
- Claude Sonnet 5 is available in the Claude model picker ([#1850](https://github.com/getpaseo/paseo/pull/1850))
## 0.1.102 - 2026-06-30
### Added

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

@@ -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-uGqJm/y14KJYBwutBkPwxhNbStzpeeVx2PcB081r4nk=
sha256-qdRCpAtiqTk5mqz+lE6xTO18PtmD1ROQuDRtqDz6reI=

374
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.103",
"version": "0.1.102",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.103",
"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.103",
"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.103",
"version": "0.1.102",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.1.103",
"@getpaseo/protocol": "0.1.103",
"@getpaseo/server": "0.1.103",
"@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.103",
"version": "0.1.102",
"dependencies": {
"@getpaseo/protocol": "0.1.103",
"@getpaseo/relay": "0.1.103",
"@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.103",
"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.103",
"version": "0.1.102",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -37554,7 +37885,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.103",
"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.103",
"version": "0.1.102",
"dependencies": {
"zod": "^4.4.3"
},
@@ -37798,7 +38129,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.103",
"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.103",
"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.103",
"@getpaseo/highlight": "0.1.103",
"@getpaseo/protocol": "0.1.103",
"@getpaseo/relay": "0.1.103",
"@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.103",
"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.103",
"version": "0.1.102",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [

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

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

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

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

@@ -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.103",
"version": "0.1.102",
"private": true,
"main": "index.ts",
"scripts": {

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.103",
"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.103",
"@getpaseo/protocol": "0.1.103",
"@getpaseo/server": "0.1.103",
"@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

@@ -60,11 +60,6 @@ const EXPECTED_CLAUDE_MODELS = [
model: "Opus 4.8",
descriptionFragment: "Latest release",
},
{
id: "claude-sonnet-5",
model: "Sonnet 5",
descriptionFragment: "Efficient for routine tasks",
},
{
id: "claude-opus-4-7[1m]",
model: "Opus 4.7 1M",
@@ -397,7 +392,7 @@ try {
"--quiet should print the current Claude catalog IDs",
);
assert(
claudeModelsFromJson.some((m) => m.id === "claude-sonnet-5"),
claudeModelsFromJson.some((m) => m.id === "claude-sonnet-4-6"),
"captured --json output should include the current Claude everyday model id",
);
console.log("✓ provider models --quiet outputs model IDs only\n");

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/client",
"version": "0.1.103",
"version": "0.1.102",
"description": "Paseo client SDK package",
"files": [
"dist",
@@ -35,8 +35,8 @@
"test": "vitest run"
},
"dependencies": {
"@getpaseo/protocol": "0.1.103",
"@getpaseo/relay": "0.1.103",
"@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.103",
"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

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.103",
"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.103",
"version": "0.1.102",
"files": [
"dist",
"!dist/**/*.map"

View File

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

View File

@@ -77,7 +77,6 @@ export interface AgentModelDefinition {
label: string;
description?: string;
isDefault?: boolean;
contextWindowMaxTokens?: number;
metadata?: AgentMetadata;
thinkingOptions?: AgentSelectOption[];
defaultThinkingOptionId?: string;

View File

@@ -48,7 +48,6 @@ describe("provider snapshot message schemas", () => {
id: "MiniMax-M2.7",
label: "MiniMax-M2.7",
isDefault: true,
contextWindowMaxTokens: 200_000,
thinkingOptions: [
{ id: "off", label: "Off" },
{ id: "max", label: "Max", isDefault: true },
@@ -63,7 +62,6 @@ describe("provider snapshot message schemas", () => {
id: "MiniMax-M2.7",
label: "MiniMax-M2.7",
isDefault: true,
contextWindowMaxTokens: 200_000,
thinkingOptions: [
{ id: "off", label: "Off" },
{ id: "max", label: "Max", isDefault: true },

View File

@@ -242,7 +242,6 @@ const AgentModelDefinitionSchema: z.ZodType<AgentModelDefinition> = z
label: z.string(),
description: z.string().optional(),
isDefault: z.boolean().optional(),
contextWindowMaxTokens: z.number().optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
thinkingOptions: z.array(AgentSelectOptionSchema).optional(),
defaultThinkingOptionId: z.string().optional(),

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.1.103",
"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.103",
"@getpaseo/highlight": "0.1.103",
"@getpaseo/protocol": "0.1.103",
"@getpaseo/relay": "0.1.103",
"@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

@@ -78,7 +78,6 @@ export interface AgentModelDefinition {
label: string;
description?: string;
isDefault?: boolean;
contextWindowMaxTokens?: number;
metadata?: AgentMetadata;
thinkingOptions?: AgentSelectOption[];
defaultThinkingOptionId?: string;

View File

@@ -416,7 +416,6 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
"claude-fable-5",
"claude-opus-4-8[1m]",
"claude-opus-4-8",
"claude-sonnet-5",
"claude-opus-4-7[1m]",
"claude-opus-4-7",
"claude-opus-4-6[1m]",
@@ -458,8 +457,6 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
expect(getThinkingIds("claude-fable-5")).toContain("ultracode");
expect(getThinkingIds("claude-opus-4-8[1m]")).toContain("ultracode");
expect(getThinkingIds("claude-opus-4-8")).toContain("ultracode");
expect(getThinkingIds("claude-sonnet-5")).toContain("xhigh");
expect(getThinkingIds("claude-sonnet-5")).not.toContain("ultracode");
expect(getThinkingIds("claude-opus-4-7")).not.toContain("ultracode");
expect(getThinkingIds("claude-sonnet-4-6")).not.toContain("ultracode");
} finally {
@@ -1892,45 +1889,6 @@ describe("ClaudeAgentSession context window usage", () => {
}
});
test("selected native-1M Claude models seed active context window usage with 1M max tokens", async () => {
const session = await createSessionForTurns(
[
[
createInitMessage(),
createMessageStartEvent(),
createSuccessResult({ modelUsage: undefined }),
],
],
{ model: "claude-sonnet-5" },
);
try {
const events = await collectStreamEvents(session);
expect(events).toContainEqual(
expect.objectContaining({
type: "usage_updated",
provider: "claude",
usage: {
contextWindowMaxTokens: 1_000_000,
contextWindowUsedTokens: 150,
},
}),
);
expect(events).toContainEqual(
expect.objectContaining({
type: "turn_completed",
provider: "claude",
usage: expect.objectContaining({
contextWindowMaxTokens: 1_000_000,
}),
}),
);
} finally {
await session.close();
}
});
test("message_delta stream events update per-request usage", async () => {
const session = await createSessionForTurns([
[

View File

@@ -29,11 +29,7 @@ import {
mapTaskNotificationSystemRecordToToolCall,
mapTaskNotificationUserContentToToolCall,
} from "./task-notification-tool-call.js";
import {
findClaudeModel,
getClaudeModelsWithSettings,
normalizeClaudeRuntimeModelId,
} from "./models.js";
import { getClaudeModelsWithSettings, normalizeClaudeRuntimeModelId } from "./models.js";
import { parsePartialJsonObject } from "./partial-json.js";
import { ClaudeSidechainTracker } from "./sidechain-tracker.js";
import { buildClaudeFeatures, claudeModelSupportsFastMode } from "./feature-definitions.js";
@@ -1630,6 +1626,23 @@ function extractContextWindowSize(modelUsage: unknown): number | undefined {
return maxContextWindow;
}
function resolveInitialContextWindowSize(modelId: string | null | undefined): number | undefined {
const normalized = typeof modelId === "string" ? modelId.trim().toLowerCase() : "";
if (!normalized) {
return undefined;
}
if (normalized.includes("[1m]") || normalized.includes("context-1m")) {
return 1_000_000;
}
if (normalized.includes("claude-fable-5")) {
return 1_000_000;
}
if (/(?:^|[~/_-])(?:claude[-_ ]*)?(opus|sonnet|haiku)(?:$|[-_ ./])/.test(normalized)) {
return 200_000;
}
return undefined;
}
function readStreamRequestInputTokens(event: Record<string, unknown>): number | undefined {
const messageUsage = toObjectRecord(toObjectRecord(event.message)?.usage);
if (!messageUsage) {
@@ -1928,7 +1941,7 @@ class ClaudeAgentSession implements AgentSession {
this.queryFactory = options.queryFactory;
this.resolveBinary = options.resolveBinary;
this.contextUsage = new ClaudeContextUsageState(
findClaudeModel(this.config.model)?.contextWindowMaxTokens,
resolveInitialContextWindowSize(this.config.model),
);
const handle = options.handle;
@@ -2174,7 +2187,7 @@ class ClaudeAgentSession implements AgentSession {
await this.applyFastModeFeature(false, activeQuery);
}
this.contextUsage.setInitialContextWindowMaxTokens(
findClaudeModel(this.config.model)?.contextWindowMaxTokens,
resolveInitialContextWindowSize(this.config.model),
);
this.lastOptionsModel = normalizedModelId ?? this.lastOptionsModel;
this.lastRuntimeModel = null;

View File

@@ -5,7 +5,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import { ClaudeAgentClient } from "./agent.js";
import { findClaudeModel, getClaudeModels, normalizeClaudeRuntimeModelId } from "./models.js";
import { getClaudeModels, normalizeClaudeRuntimeModelId } from "./models.js";
const createdClaudeConfigDirs: string[] = [];
@@ -38,7 +38,6 @@ describe("getClaudeModels", () => {
"claude-fable-5",
"claude-opus-4-8[1m]",
"claude-opus-4-8",
"claude-sonnet-5",
"claude-opus-4-7[1m]",
"claude-opus-4-7",
"claude-opus-4-6[1m]",
@@ -56,22 +55,6 @@ describe("getClaudeModels", () => {
expect(defaults[0].id).toBe("claude-opus-4-8");
});
it("keeps fixed context windows on the model entries", () => {
const modelsById = new Map(getClaudeModels().map((model) => [model.id, model]));
expect(modelsById.get("claude-fable-5")?.contextWindowMaxTokens).toBe(1_000_000);
expect(modelsById.get("claude-opus-4-8[1m]")?.contextWindowMaxTokens).toBe(1_000_000);
expect(modelsById.get("claude-opus-4-8")?.contextWindowMaxTokens).toBe(200_000);
expect(modelsById.get("claude-sonnet-5")?.contextWindowMaxTokens).toBe(1_000_000);
expect(modelsById.get("claude-sonnet-4-6")?.contextWindowMaxTokens).toBe(200_000);
expect(modelsById.get("claude-haiku-4-5")?.contextWindowMaxTokens).toBe(200_000);
});
it("finds models through the runtime normalizer", () => {
expect(findClaudeModel("claude-sonnet-5-20260101")?.id).toBe("claude-sonnet-5");
expect(findClaudeModel("claude-sonnet-5[1m]")?.contextWindowMaxTokens).toBe(1_000_000);
});
it("returns fresh copies each call", () => {
const a = getClaudeModels();
const b = getClaudeModels();
@@ -220,14 +203,12 @@ describe("normalizeClaudeRuntimeModelId", () => {
expect(normalizeClaudeRuntimeModelId("claude-fable-5")).toBe("claude-fable-5");
expect(normalizeClaudeRuntimeModelId("claude-opus-4-6")).toBe("claude-opus-4-6");
expect(normalizeClaudeRuntimeModelId("claude-opus-4-6[1m]")).toBe("claude-opus-4-6[1m]");
expect(normalizeClaudeRuntimeModelId("claude-sonnet-5")).toBe("claude-sonnet-5");
expect(normalizeClaudeRuntimeModelId("claude-sonnet-4-6")).toBe("claude-sonnet-4-6");
expect(normalizeClaudeRuntimeModelId("claude-haiku-4-5")).toBe("claude-haiku-4-5");
});
it("normalizes dated model IDs to base model", () => {
expect(normalizeClaudeRuntimeModelId("claude-fable-5-20260301")).toBe("claude-fable-5");
expect(normalizeClaudeRuntimeModelId("claude-sonnet-5-20260101")).toBe("claude-sonnet-5");
expect(normalizeClaudeRuntimeModelId("claude-opus-4-6-20260101")).toBe("claude-opus-4-6");
expect(normalizeClaudeRuntimeModelId("claude-sonnet-4-6-20260101")).toBe("claude-sonnet-4-6");
expect(normalizeClaudeRuntimeModelId("claude-haiku-4-5-20251001")).toBe("claude-haiku-4-5");
@@ -237,10 +218,6 @@ describe("normalizeClaudeRuntimeModelId", () => {
expect(normalizeClaudeRuntimeModelId("claude-opus-4-6[1m]")).toBe("claude-opus-4-6[1m]");
});
it("strips [1m] suffix for natively-1M models", () => {
expect(normalizeClaudeRuntimeModelId("claude-sonnet-5[1m]")).toBe("claude-sonnet-5");
});
it("returns null for empty/null/undefined", () => {
expect(normalizeClaudeRuntimeModelId(null)).toBeNull();
expect(normalizeClaudeRuntimeModelId(undefined)).toBeNull();

View File

@@ -12,7 +12,7 @@ const CLAUDE_THINKING_OPTIONS = [
{ id: "max", label: "Max" },
] as const;
const CLAUDE_EXTENDED_THINKING_OPTIONS = [
const CLAUDE_OPUS_EXTENDED_THINKING_OPTIONS = [
{ id: "low", label: "Low" },
{ id: "medium", label: "Medium" },
{ id: "high", label: "High" },
@@ -21,7 +21,7 @@ const CLAUDE_EXTENDED_THINKING_OPTIONS = [
] as const;
const CLAUDE_ULTRACODE_THINKING_OPTIONS = [
...CLAUDE_EXTENDED_THINKING_OPTIONS,
...CLAUDE_OPUS_EXTENDED_THINKING_OPTIONS,
{ id: "ultracode", label: "Ultracode" },
] as const;
@@ -31,7 +31,6 @@ const CLAUDE_MODELS: AgentModelDefinition[] = [
id: "claude-fable-5",
label: "Fable 5",
description: "Fable 5 · Most powerful model",
contextWindowMaxTokens: 1_000_000,
thinkingOptions: [...CLAUDE_ULTRACODE_THINKING_OPTIONS],
},
{
@@ -39,7 +38,6 @@ const CLAUDE_MODELS: AgentModelDefinition[] = [
id: "claude-opus-4-8[1m]",
label: "Opus 4.8 1M",
description: "Opus 4.8 with 1M context window",
contextWindowMaxTokens: 1_000_000,
thinkingOptions: [...CLAUDE_ULTRACODE_THINKING_OPTIONS],
},
{
@@ -48,39 +46,27 @@ const CLAUDE_MODELS: AgentModelDefinition[] = [
label: "Opus 4.8",
description: "Opus 4.8 · Latest release",
isDefault: true,
contextWindowMaxTokens: 200_000,
thinkingOptions: [...CLAUDE_ULTRACODE_THINKING_OPTIONS],
},
{
provider: "claude",
id: "claude-sonnet-5",
label: "Sonnet 5",
description: "Sonnet 5 · Efficient for routine tasks",
contextWindowMaxTokens: 1_000_000,
thinkingOptions: [...CLAUDE_EXTENDED_THINKING_OPTIONS],
},
{
provider: "claude",
id: "claude-opus-4-7[1m]",
label: "Opus 4.7 1M",
description: "Opus 4.7 with 1M context window",
contextWindowMaxTokens: 1_000_000,
thinkingOptions: [...CLAUDE_EXTENDED_THINKING_OPTIONS],
thinkingOptions: [...CLAUDE_OPUS_EXTENDED_THINKING_OPTIONS],
},
{
provider: "claude",
id: "claude-opus-4-7",
label: "Opus 4.7",
description: "Opus 4.7 · Previous release",
contextWindowMaxTokens: 200_000,
thinkingOptions: [...CLAUDE_EXTENDED_THINKING_OPTIONS],
thinkingOptions: [...CLAUDE_OPUS_EXTENDED_THINKING_OPTIONS],
},
{
provider: "claude",
id: "claude-opus-4-6[1m]",
label: "Opus 4.6 1M",
description: "Opus 4.6 with 1M context window",
contextWindowMaxTokens: 1_000_000,
thinkingOptions: [...CLAUDE_THINKING_OPTIONS],
},
{
@@ -88,7 +74,6 @@ const CLAUDE_MODELS: AgentModelDefinition[] = [
id: "claude-opus-4-6",
label: "Opus 4.6",
description: "Opus 4.6 · Most capable for complex work",
contextWindowMaxTokens: 200_000,
thinkingOptions: [...CLAUDE_THINKING_OPTIONS],
},
{
@@ -96,7 +81,6 @@ const CLAUDE_MODELS: AgentModelDefinition[] = [
id: "claude-sonnet-4-6[1m]",
label: "Sonnet 4.6 1M",
description: "Sonnet 4.6 with 1M context window",
contextWindowMaxTokens: 1_000_000,
thinkingOptions: [...CLAUDE_THINKING_OPTIONS],
},
{
@@ -104,7 +88,6 @@ const CLAUDE_MODELS: AgentModelDefinition[] = [
id: "claude-sonnet-4-6",
label: "Sonnet 4.6",
description: "Sonnet 4.6 · Best for everyday tasks",
contextWindowMaxTokens: 200_000,
thinkingOptions: [...CLAUDE_THINKING_OPTIONS],
},
{
@@ -112,7 +95,6 @@ const CLAUDE_MODELS: AgentModelDefinition[] = [
id: "claude-haiku-4-5",
label: "Haiku 4.5",
description: "Haiku 4.5 · Fastest for quick answers",
contextWindowMaxTokens: 200_000,
},
];
@@ -128,14 +110,6 @@ export function getClaudeModels(): AgentModelDefinition[] {
return CLAUDE_MODELS.map((model) => ({ ...model }));
}
export function findClaudeModel(modelId: string | null | undefined): AgentModelDefinition | null {
const normalizedModelId = normalizeClaudeRuntimeModelId(modelId);
if (!normalizedModelId) {
return null;
}
return CLAUDE_MODELS.find((model) => model.id === normalizedModelId) ?? null;
}
export async function getClaudeModelsWithSettings(
logger: Logger,
configDir?: string,
@@ -244,17 +218,13 @@ export function normalizeClaudeRuntimeModelId(value: string | null | undefined):
return trimmed;
}
// Some new Claude model families use single-segment versions, not the
// {major}-{minor} scheme of older opus/sonnet/haiku models. Map dated or
// suffixed runtime strings back to the matching catalog ID.
const singleSegmentMatch = trimmed.match(/(?:claude-)?(fable|sonnet)[-_ ]+(\d+)/i);
if (singleSegmentMatch) {
const family = singleSegmentMatch[1].toLowerCase();
const major = singleSegmentMatch[2];
const modelId = `claude-${family}-${major}`;
if (CLAUDE_MODELS.some((model) => model.id === modelId)) {
return modelId;
}
// Fable uses a single-segment version (claude-fable-5), not the {major}-{minor}
// scheme of opus/sonnet/haiku, so match it separately. This maps dated runtime
// strings (e.g. claude-fable-5-20260301) back to the catalog ID. No [1m] variant:
// Fable 5 is natively 1M, so there is no 200K-default model to opt into 1M.
const fableMatch = trimmed.match(/(?:claude-)?fable[-_ ]+(\d+)/i);
if (fableMatch) {
return `claude-fable-${fableMatch[1]}`;
}
// Match: claude-{family}-{major}-{minor}[1m]? possibly followed by a date suffix

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";

File diff suppressed because it is too large Load Diff

View File

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

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

@@ -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",