diff --git a/.github/workflows/android-apk-release.yml b/.github/workflows/android-apk-release.yml new file mode 100644 index 000000000..ba4fc3a73 --- /dev/null +++ b/.github/workflows/android-apk-release.yml @@ -0,0 +1,119 @@ +name: Android APK Release + +on: + push: + tags: + - "v*" + workflow_dispatch: + inputs: + tag: + description: "Existing tag to build (e.g. v0.1.0)" + required: true + type: string + +concurrency: + group: android-apk-release-${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }} + cancel-in-progress: false + +env: + RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }} + +jobs: + publish-android-apk: + permissions: + contents: write + packages: read + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }} + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: "npm" + registry-url: "https://npm.pkg.github.com" + scope: "@boudra" + + - name: Install JS dependencies + run: npm ci + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup Expo and EAS + uses: expo/expo-github-action@v8 + with: + eas-version: latest + token: ${{ secrets.EXPO_TOKEN }} + + - name: Build Android APK on EAS + id: eas_build + shell: bash + run: | + set -euo pipefail + cd packages/app + + build_json="$(npx eas build --platform android --profile production-apk --non-interactive --wait --json)" + echo "$build_json" > "$RUNNER_TEMP/eas-build.json" + + build_id="$(jq -r 'if type == "array" then .[0].id // empty else .id // empty end' "$RUNNER_TEMP/eas-build.json")" + if [ -z "$build_id" ]; then + echo "Failed to determine EAS build ID." + cat "$RUNNER_TEMP/eas-build.json" + exit 1 + fi + + echo "build_id=$build_id" >> "$GITHUB_OUTPUT" + + - name: Resolve APK artifact URL + id: artifact + shell: bash + run: | + set -euo pipefail + cd packages/app + + build_view_json="$(npx eas build:view '${{ steps.eas_build.outputs.build_id }}' --json)" + echo "$build_view_json" > "$RUNNER_TEMP/eas-build-view.json" + + artifact_url="$(jq -r '.artifacts.buildUrl // .artifacts.applicationArchiveUrl // empty' "$RUNNER_TEMP/eas-build-view.json")" + if [ -z "$artifact_url" ]; then + echo "Failed to determine APK artifact URL." + cat "$RUNNER_TEMP/eas-build-view.json" + exit 1 + fi + + asset_name="paseo-${RELEASE_TAG}-android.apk" + asset_path="$RUNNER_TEMP/$asset_name" + + curl --fail --location --output "$asset_path" "$artifact_url" + + echo "asset_name=$asset_name" >> "$GITHUB_OUTPUT" + echo "asset_path=$asset_path" >> "$GITHUB_OUTPUT" + + - name: Wait for GitHub release tag + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + set -euo pipefail + for attempt in $(seq 1 90); do + if gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" >/dev/null 2>&1; then + echo "Found release for tag $RELEASE_TAG" + exit 0 + fi + echo "Release for $RELEASE_TAG is not available yet (attempt $attempt/90)." + sleep 20 + done + + echo "Timed out waiting for GitHub release tag $RELEASE_TAG." + exit 1 + + - name: Upload APK to GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release upload "$RELEASE_TAG" "${{ steps.artifact.outputs.asset_path }}" --clobber --repo "${{ github.repository }}" diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..9f1d08fd4 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,99 @@ +# Changelog + +## [0.1.9] - 2026-02-17 +### Improved +- Unified structured-output generation through a single shared schema-validation and retry pipeline. +- Reused provider availability checks for structured generation fallback selection. +- Added structured generation waterfall ordering for internal metadata and git text generation: Claude Haiku, then Codex, then OpenCode. + +### Fixed +- Fixed CLI `run --output-schema` to use the shared structured-output path instead of ad-hoc JSON parsing. +- Fixed `run --output-schema` failures where providers returned empty `lastMessage` by recovering from timeline assistant output. +- Fixed internal commit message, pull request text, and agent metadata generation to follow one consistent structured pipeline. + +## [0.1.8] - 2026-02-17 +### Added +- Added a cross-platform confirm dialog flow for daemon restarts. + +### Improved +- Simplified local speech bootstrap and daemon startup locking behavior. +- Updated website hero copy to emphasize local execution. + +### Fixed +- Fixed stuck "send while running" recovery across app and server session handling. +- Fixed Claude session identity preservation when reloading existing agents. +- Fixed combobox option behavior and related interactions. +- Fixed Tauri file-drop listener cleanup to avoid uncaught unlisten errors. +- Fixed web tool-detail wheel event routing at scroll edges. + +## [0.1.7] - 2026-02-16 +### Added +- Improved agent workspace flows with better directory suggestions. +- Added iOS TestFlight and Android app access request forms on the website. + +### Improved +- Unified daemon startup behavior between dev and CLI paths for more predictable local runs. +- Improved website app download and update guidance. + +### Fixed +- Prevented an initial desktop combobox `0,0` position flash. +- Fixed CLI version output issues. +- Hardened server runtime loading for local speech dependencies. + +## [0.1.6] - 2026-02-16 +### Notes +- No major visible product changes in this patch release. + +## [0.1.5] - 2026-02-16 +### Added +- Added terminal reattach support and better worktree terminal handling. +- Added global keyboard shortcut help in the app. +- Added sidebar host filtering and improved agent workflow controls. + +### Improved +- Improved worktree setup visibility by streaming setup progress. +- Improved terminal streaming reliability and lifecycle handling. +- Preserved explorer tab state so context survives navigation better. + +## [0.1.4] - 2026-02-14 +### Added +- Added voice capability status reporting in the client. +- Added background local speech model downloads with runtime gating. +- Added adaptive dictation finish timing based on server-provided budgets. +- Added relay reconnect behavior with grace periods and branch suggestions. + +### Improved +- Improved connection selection and agent hydration reliability. +- Improved timeline loading with cursor-based fetch behavior. +- Improved first-run experience by bootstrapping a default localhost connection. +- Improved inline code rendering by auto-linkifying URLs. + +### Fixed +- Fixed Linux checkout diff watch behavior to avoid recursive watches. +- Fixed stale relay client timer behavior. +- Fixed unnecessary git diff header auto-scroll on collapse. + +## [0.1.3] - 2026-02-12 +### Added +- Added CLI onboarding command. +- Added CLI `--output-schema` support for structured agent output. +- Added CLI agent metadata update support for names and labels. +- Added provider availability detection with normalization of legacy default model IDs. + +### Improved +- Improved file explorer refresh feedback and unresolved checkout fallback handling. +- Added better voice interrupt handling with a speech-start grace period. +- Improved CLI defaults to list all non-archived agents by default. +- Improved website UX with clearer install CTA and privacy policy access. + +### Fixed +- Fixed dev runner entry issues and sherpa TTS initialization behavior. + +## [0.1.2] - 2026-02-11 +### Notes +- No major visible product changes in this patch release. + +## [0.1.1] - 2026-02-11 + +### Added +- Initial `0.1.x` release line. diff --git a/CLAUDE.md b/CLAUDE.md index fac8b28ca..3692961f1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -109,7 +109,7 @@ Use `APP_VARIANT` in `packages/app/app.config.js` to control app name + package - `production` -> app name `Paseo`, package `sh.paseo` - `development` -> app name `Paseo Debug`, package `sh.paseo.debug` -EAS profiles live in `packages/app/eas.json` as `development` and `production`. +EAS profiles live in `packages/app/eas.json` as `development`, `production`, and `production-apk`. `development` uses Android `debug`. @@ -136,6 +136,29 @@ npm run android:production `npm run android:prod` and `npm run android:release` are aliases for `npm run android:production`. +### Cloud build + submit (EAS Workflows) + +Tag pushes like `v0.1.0` trigger `packages/app/.eas/workflows/release-mobile.yml` on Expo servers. +Tag pushes like `v0.1.0` also trigger `.github/workflows/android-apk-release.yml` on GitHub Actions to publish an APK asset on the matching GitHub Release. + +That workflow does: +- Build iOS with the `production` profile +- Build Android with the `production` profile +- Submit each build with the `production` submit profile + +Useful commands: + +```bash +# List recent mobile workflow runs +cd packages/app && npx eas workflow:runs --workflow release-mobile.yml --limit 10 + +# Inspect one run (jobs, status, outputs) +cd packages/app && npx eas workflow:view + +# Stream logs for all steps in one failed job +cd packages/app && npx eas workflow:logs --non-interactive --all-steps +``` + ## Testing with Playwright MCP **CRITICAL:** When asked to test the app, you MUST use the Playwright MCP connecting to Metro at `http://localhost:8081`. @@ -160,14 +183,22 @@ npm run release:patch npm run version:all:patch # npm version across all workspaces (creates commit + local tag) npm run release:check npm run release:publish -npm run release:push # pushes HEAD and current version tag (triggers desktop release) +npm run release:push # pushes HEAD and current version tag (triggers desktop + Android APK + EAS mobile workflows) ``` Notes: -- `version:all:*` uses `npm version` with workspace support and runs the root `version` lifecycle script to sync internal `@getpaseo/*` dependency versions before the release commit/tag is created. +- `version:all:*` bumps the root package version and runs the root `version` lifecycle script to sync workspace versions and internal `@getpaseo/*` dependency versions before the release commit/tag is created. - `release:prepare` refreshes workspace `node_modules` links to prevent stale local package types during release checks. - If `release:publish` fails after a successful publish of one workspace, re-run `npm run release:publish`; npm will skip already-published versions and continue where possible. -- After each release, update the website Mac download CTA URL to the new version tag in `packages/website/src/routes/index.tsx`. +- If a user asks to "release paseo" (without specifying major/minor), treat it as a patch release and run `npm run release:patch`. +- All workspaces share one version by design. Keep versions synchronized and release together. +- The website Mac download CTA URL is derived from `packages/website/package.json` version at build time, so no manual update is required after release. + +Release completion checklist: +- `npm run release:patch` completes successfully. +- GitHub `Desktop Release` workflow for the new `v*` tag is green. +- GitHub `Android APK Release` workflow for the same tag is green. +- EAS `release-mobile.yml` workflow for the same tag is green (Expo queues can take longer on the free plan). ## Orchestrator Mode diff --git a/README.md b/README.md index c0eedb329..ccbdaee9a 100644 --- a/README.md +++ b/README.md @@ -17,77 +17,44 @@ Paseo is a self-hosted daemon for Claude Code, Codex, and OpenCode. Agents run on your machine with your full dev environment. Connect from phone, desktop, or web. -## Features - -- **Self-hosted:** The daemon runs on your laptop, home server, or VPS -- **Multi-provider:** Works with Claude Code, Codex, and OpenCode from one interface -- **Multi-host:** Connect to multiple daemons and see all your agents in one place -- **Voice input:** Dictate prompts when you're away from your keyboard -- **Optional relay:** Use the hosted end-to-end encrypted relay, or connect directly -- **Cross-device:** iOS, Android, desktop, web, and CLI -- **Git integration:** Manage agents in isolated worktrees, review diffs, ship from the app -- **Open source:** Free and open source under MIT license - -## Quick Start +## Getting Started ```bash -npm install -g @getpaseo/cli && paseo +npm install -g @getpaseo/cli +paseo ``` Then open the app and connect to your daemon. -## Local speech (STT/TTS) +For full setup and configuration, see: +- [Docs](https://paseo.sh/docs) +- [Configuration reference](https://paseo.sh/docs/configuration) -Paseo can run dictation + voice mode STT/TTS fully locally via `sherpa-onnx`. +## Development -When the daemon starts with a local speech provider selected, it will download any missing model files automatically (unless `PASEO_SHERPA_ONNX_AUTO_DOWNLOAD=0`). +Quick monorepo package map: +- `packages/server`: Paseo daemon (agent process orchestration, WebSocket API, MCP server) +- `packages/app`: Expo client (iOS, Android, web) +- `packages/cli`: `paseo` CLI for daemon and agent workflows +- `packages/desktop`: Tauri desktop app +- `packages/relay`: Relay package for remote connectivity +- `packages/website`: Marketing site and documentation (`paseo.sh`) + +Common commands: ```bash -npm run speech:download --workspace=@getpaseo/server +# run all local dev services +npm run dev + +# run individual surfaces +npm run dev:server +npm run dev:app +npm run dev:website + +# repo-wide checks +npm run typecheck ``` -Optional configuration: - -- `PASEO_SHERPA_ONNX_MODELS_DIR` (defaults to `~/.paseo/models/sherpa-onnx`) -- `PASEO_SHERPA_ONNX_AUTO_DOWNLOAD` (`1` by default; set `0` to disable automatic downloads on daemon start) -- `PASEO_SHERPA_STT_PRESET` (`zipformer`, `paraformer`, or `parakeet` for NVIDIA Parakeet TDT v3) -- `PASEO_SHERPA_TTS_PRESET` (`pocket-tts` (Kyutai Pocket TTS), `kitten`, or `kokoro`) -- `PASEO_DICTATION_STT_PROVIDER`, `PASEO_VOICE_STT_PROVIDER`, `PASEO_VOICE_TTS_PROVIDER` (`sherpa` or `openai`) - -To see all supported local model IDs: - -```bash -npm run speech:models --workspace=@getpaseo/server -``` - -Optional: run an end-to-end test that downloads real models and exercises streaming STT + streaming TTS: - -```bash -PASEO_SPEECH_E2E_DOWNLOAD=1 PASEO_SPEECH_E2E_MODEL_SET=parakeet-pocket \ - npx vitest run --workspace=@getpaseo/server src/server/speech/sherpa/speech-download.e2e.test.ts -``` - -## Documentation - -See [paseo.sh/docs](https://paseo.sh/docs) for full documentation. - -## Desktop releases - -Desktop app binaries are built and attached to a GitHub Release when you push a version tag (for example `v0.1.0` or `desktop-v0.1.0`). - -```bash -npm run version:all:patch -npm run release:push -``` - -For the full package release flow, use: - -```bash -npm run release:patch -``` - -This triggers the `Desktop Release` workflow (`.github/workflows/desktop-release.yml`). - ## License MIT diff --git a/package-lock.json b/package-lock.json index f3f8a5593..73c65656a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "paseo", - "version": "0.1.4", + "version": "0.1.13", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "paseo", - "version": "0.1.4", + "version": "0.1.13", "hasInstallScript": true, "license": "MIT", "workspaces": [ @@ -13681,6 +13681,15 @@ "node": ">= 0.8.0" } }, + "node_modules/lezer-elixir": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/lezer-elixir/-/lezer-elixir-1.1.2.tgz", + "integrity": "sha512-K3yPMJcNhqCL6ugr5NkgOC1g37rcOM38XZezO9lBXy0LwWFd8zdWXfmRbY829vZVk0OGCQoI02yDWp9FF2OWZA==", + "dependencies": { + "@lezer/highlight": "^1.2.0", + "@lezer/lr": "^1.3.0" + } + }, "node_modules/lighthouse-logger": { "version": "1.4.2", "license": "Apache-2.0", @@ -14705,7 +14714,9 @@ }, "node_modules/nan": { "version": "2.23.0", - "license": "MIT" + "dev": true, + "license": "MIT", + "optional": true }, "node_modules/nanoid": { "version": "3.3.11", @@ -14770,6 +14781,12 @@ "version": "2.0.1", "license": "MIT" }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, "node_modules/node-domexception": { "version": "1.0.0", "funding": [ @@ -14816,6 +14833,16 @@ "version": "0.4.0", "license": "MIT" }, + "node_modules/node-pty": { + "version": "1.2.0-beta.11", + "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.2.0-beta.11.tgz", + "integrity": "sha512-THcUyu1WwdgoIyUvgXOZ70EOMXzheGa0q3tbEb5kUIfKgcpBJ+AJ9Q1kq0bKtYmQzr77usXiTORZTLmAUQlnoQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^7.1.0" + } + }, "node_modules/node-releases": { "version": "2.0.26", "license": "MIT" @@ -20335,7 +20362,7 @@ }, "packages/app": { "name": "@getpaseo/app", - "version": "0.1.4", + "version": "0.1.13", "dependencies": { "@boudra/expo-two-way-audio": "^0.1.3", "@dnd-kit/core": "^6.3.1", @@ -20343,7 +20370,7 @@ "@dnd-kit/utilities": "^3.2.2", "@expo/vector-icons": "^15.0.2", "@floating-ui/react-native": "^0.10.7", - "@getpaseo/server": "0.1.4", + "@getpaseo/server": "0.1.13", "@gorhom/bottom-sheet": "^5.2.6", "@gorhom/portal": "^1.0.14", "@lezer/common": "^1.5.0", @@ -20390,6 +20417,7 @@ "expo-system-ui": "~6.0.7", "expo-updates": "~29.0.12", "expo-web-browser": "~15.0.8", + "lezer-elixir": "^1.1.2", "lucide-react-native": "^0.546.0", "mnemonic-id": "^3.2.7", "react": "19.1.0", @@ -20446,11 +20474,11 @@ }, "packages/cli": { "name": "@getpaseo/cli", - "version": "0.1.4", + "version": "0.1.13", "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/relay": "0.1.4", - "@getpaseo/server": "0.1.4", + "@getpaseo/relay": "0.1.13", + "@getpaseo/server": "0.1.13", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", @@ -20500,14 +20528,14 @@ }, "packages/desktop": { "name": "@getpaseo/desktop", - "version": "0.1.4", + "version": "0.1.13", "devDependencies": { "@tauri-apps/cli": "^2.9.6" } }, "packages/relay": { "name": "@getpaseo/relay", - "version": "0.1.4", + "version": "0.1.13", "dependencies": { "base64-js": "^1.5.1", "tweetnacl": "^1.0.3", @@ -20523,12 +20551,12 @@ }, "packages/server": { "name": "@getpaseo/server", - "version": "0.1.4", + "version": "0.1.13", "dependencies": { "@ai-sdk/openai": "2.0.52", "@anthropic-ai/claude-agent-sdk": "^0.2.11", "@deepgram/sdk": "^3.4.0", - "@getpaseo/relay": "0.1.4", + "@getpaseo/relay": "0.1.13", "@lezer/common": "^1.5.0", "@lezer/css": "^1.3.0", "@lezer/highlight": "^1.2.3", @@ -20546,8 +20574,9 @@ "dotenv": "^17.2.3", "express": "^4.18.2", "express-basic-auth": "^1.2.1", + "lezer-elixir": "^1.1.2", "mnemonic-id": "^3.2.7", - "node-pty": "^1.0.0", + "node-pty": "1.2.0-beta.11", "onnxruntime-node": "^1.23.0", "openai": "^4.20.0", "pino": "^10.2.0", @@ -20791,14 +20820,6 @@ "node": ">= 0.6" } }, - "packages/server/node_modules/node-pty": { - "version": "1.0.0", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "nan": "^2.17.0" - } - }, "packages/server/node_modules/qs": { "version": "6.14.0", "license": "BSD-3-Clause", @@ -20879,7 +20900,7 @@ }, "packages/website": { "name": "@getpaseo/website", - "version": "0.1.4", + "version": "0.1.13", "dependencies": { "@cloudflare/vite-plugin": "^1.20.3", "@cloudflare/workers-types": "^4.20260114.0", diff --git a/package.json b/package.json index d3d43788d..d8d7356b2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "paseo", - "version": "0.1.4", + "version": "0.1.13", "private": true, "workspaces": [ "packages/server", @@ -12,7 +12,7 @@ ], "scripts": { "dev": "./scripts/dev.sh", - "dev:server": "NODE_ENV=development tsx packages/server/scripts/dev-runner.ts", + "dev:server": "NODE_ENV=development tsx packages/server/scripts/daemon-runner.ts --dev", "dev:app": "npm run start --workspace=@getpaseo/app", "dev:website": "npm run dev --workspace=@getpaseo/website", "postinstall": "node scripts/postinstall-patches.mjs", @@ -35,9 +35,9 @@ "version": "npm run version:sync-internal && npm run release:prepare && git add -A", "version:sync-internal": "node scripts/sync-workspace-versions.mjs", "release:prepare": "npm install --workspaces --include-workspace-root", - "version:all:patch": "npm version patch --workspaces --include-workspace-root --message \"chore(release): cut %s\"", - "version:all:minor": "npm version minor --workspaces --include-workspace-root --message \"chore(release): cut %s\"", - "version:all:major": "npm version major --workspaces --include-workspace-root --message \"chore(release): cut %s\"", + "version:all:patch": "npm version patch --include-workspace-root --message \"chore(release): cut %s\"", + "version:all:minor": "npm version minor --include-workspace-root --message \"chore(release): cut %s\"", + "version:all:major": "npm version major --include-workspace-root --message \"chore(release): cut %s\"", "release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli", "release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public", "release:publish": "npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public", diff --git a/packages/app/.eas/workflows/release-mobile.yml b/packages/app/.eas/workflows/release-mobile.yml new file mode 100644 index 000000000..130d268fa --- /dev/null +++ b/packages/app/.eas/workflows/release-mobile.yml @@ -0,0 +1,38 @@ +name: Release Mobile + +on: + push: + tags: + - "v*" + workflow_dispatch: {} + +jobs: + build_ios: + name: Build iOS + type: build + params: + platform: ios + profile: production + + build_android: + name: Build Android + type: build + params: + platform: android + profile: production + + submit_ios: + name: Submit iOS + needs: [build_ios] + type: submit + params: + build_id: ${{ needs.build_ios.outputs.build_id }} + profile: production + + submit_android: + name: Submit Android + needs: [build_android] + type: submit + params: + build_id: ${{ needs.build_android.outputs.build_id }} + profile: production diff --git a/packages/app/app.config.js b/packages/app/app.config.js index 95bb459a5..890b25bb3 100644 --- a/packages/app/app.config.js +++ b/packages/app/app.config.js @@ -1,14 +1,46 @@ +const fs = require("node:fs"); +const path = require("node:path"); const pkg = require("./package.json"); const appVariant = process.env.APP_VARIANT ?? "production"; +function resolveSecretFile(params) { + const fromEnv = process.env[params.envKey]; + if (typeof fromEnv === "string" && fromEnv.trim().length > 0) { + return fromEnv.trim(); + } + + const fallbackAbsolutePath = path.resolve(__dirname, params.fallbackRelativePath); + if (fs.existsSync(fallbackAbsolutePath)) { + return params.fallbackRelativePath; + } + + return undefined; +} + const variants = { production: { name: "Paseo", packageId: "sh.paseo", + googleServicesFile: resolveSecretFile({ + envKey: "GOOGLE_SERVICES_FILE_PROD", + fallbackRelativePath: "./.secrets/google-services.prod.json", + }), + googleServiceInfoPlist: resolveSecretFile({ + envKey: "GOOGLE_SERVICE_INFO_PLIST_PROD", + fallbackRelativePath: "./.secrets/GoogleService-Info.prod.plist", + }), }, development: { name: "Paseo Debug", packageId: "sh.paseo.debug", + googleServicesFile: resolveSecretFile({ + envKey: "GOOGLE_SERVICES_FILE_DEBUG", + fallbackRelativePath: "./.secrets/google-services.debug.json", + }), + googleServiceInfoPlist: resolveSecretFile({ + envKey: "GOOGLE_SERVICE_INFO_PLIST_DEBUG", + fallbackRelativePath: "./.secrets/GoogleService-Info.debug.plist", + }), }, }; @@ -38,6 +70,9 @@ export default { ITSAppUsesNonExemptEncryption: false, }, bundleIdentifier: variant.packageId, + ...(variant.googleServiceInfoPlist + ? { googleServicesFile: variant.googleServiceInfoPlist } + : {}), }, android: { adaptiveIcon: { @@ -57,6 +92,9 @@ export default { "android.permission.CAMERA", ], package: variant.packageId, + ...(variant.googleServicesFile + ? { googleServicesFile: variant.googleServicesFile } + : {}), }, web: { output: "single", diff --git a/packages/app/e2e/global-setup.ts b/packages/app/e2e/global-setup.ts index fba4a06a2..366d42267 100644 --- a/packages/app/e2e/global-setup.ts +++ b/packages/app/e2e/global-setup.ts @@ -156,7 +156,6 @@ export default async function globalSetup() { // Keep e2e bootstrap fast and deterministic; terminal/sidebar tests do not need speech. PASEO_DICTATION_ENABLED: "0", PASEO_VOICE_MODE_ENABLED: "0", - PASEO_LOCAL_AUTO_DOWNLOAD: "0", NODE_ENV: 'development', }, stdio: ['ignore', 'pipe', 'pipe'], diff --git a/packages/app/e2e/helpers/app.ts b/packages/app/e2e/helpers/app.ts index 938e2f51b..0a5e57a12 100644 --- a/packages/app/e2e/helpers/app.ts +++ b/packages/app/e2e/helpers/app.ts @@ -173,7 +173,8 @@ export const setWorkingDirectory = async (page: Page, directory: string) => { .first(); await expect(workingDirectorySelect).toBeVisible({ timeout: 30000 }); - const input = page.getByRole('textbox', { name: '/path/to/project' }); + const legacyInput = page.getByRole('textbox', { name: '/path/to/project' }).first(); + const directorySearchInput = page.getByRole('textbox', { name: /search directories/i }).first(); const worktreePicker = page.getByTestId('worktree-attach-picker'); const worktreeSheetTitle = page.getByText('Select worktree', { exact: true }); const closeBottomSheet = async () => { @@ -218,29 +219,54 @@ export const setWorkingDirectory = async (page: Page, directory: string) => { await closeWorktreeSheetIfOpen(); - if (!(await input.isVisible())) { + const pickerInputVisible = async () => + (await directorySearchInput.isVisible().catch(() => false)) || + (await legacyInput.isVisible().catch(() => false)); + + if (!(await pickerInputVisible())) { await closeBottomSheet(); await workingDirectorySelect.click({ force: true }); - if (!(await input.isVisible())) { + if (!(await pickerInputVisible())) { await closeBottomSheet(); await workingDirectorySelect.click({ force: true }); } - await expect(input).toBeVisible(); + await expect + .poll(async () => pickerInputVisible(), { timeout: 10000 }) + .toBe(true); } const trimmedDirectory = directory.replace(/\/+$/, ''); - await input.fill(trimmedDirectory); - await input.press('Enter'); + const activeInput = + (await directorySearchInput.isVisible().catch(() => false)) + ? directorySearchInput + : legacyInput; - // Desktop web supports selecting via Enter; mobile may require clicking the explicit option. - const useOption = page.getByTestId('working-directory-custom-option').first(); - if (await useOption.isVisible().catch(() => false)) { - await expect(useOption).toContainText(`Use "${trimmedDirectory}"`); - await useOption.click({ force: true }); + await activeInput.fill(trimmedDirectory); + + if (activeInput === directorySearchInput) { + // Combobox custom rows can be either plain path labels or prefixed labels. + const plainOption = page + .getByText(new RegExp(`^${escapeRegex(trimmedDirectory)}$`, 'i')) + .first(); + const prefixedUseOption = page + .getByText(new RegExp(`^Use "${escapeRegex(trimmedDirectory)}"$`, 'i')) + .first(); + + if (await plainOption.isVisible().catch(() => false)) { + await plainOption.click({ force: true }); + } else if (await prefixedUseOption.isVisible().catch(() => false)) { + await prefixedUseOption.click({ force: true }); + } else { + // Fallback: accept highlighted option (directory suggestion). + await activeInput.press('Enter'); + } + } else { + // Legacy path picker fallback. + await activeInput.press('Enter'); } - // Wait for the sheet to close after clicking "Use" - await expect(input).not.toBeVisible({ timeout: 10000 }); + // Wait for picker to close. + await expect(activeInput).not.toBeVisible({ timeout: 10000 }); const directoryCandidates = new Set([trimmedDirectory]); if (trimmedDirectory.startsWith('/var/')) { diff --git a/packages/app/e2e/sidebar-project-filter-flash.spec.ts b/packages/app/e2e/sidebar-project-filter-flash.spec.ts new file mode 100644 index 000000000..5d1e2c237 --- /dev/null +++ b/packages/app/e2e/sidebar-project-filter-flash.spec.ts @@ -0,0 +1,130 @@ +import { test, expect } from "./fixtures"; +import { gotoHome } from "./helpers/app"; + +test("project filter dropdown never appears visibly at 0,0 on open", async ({ page }) => { + await gotoHome(page); + + const trigger = page.getByText("Project", { exact: true }).first(); + await expect(trigger).toBeVisible(); + + await page.evaluate(() => { + (window as any).__projectFilterFlashProbe = new Promise<{ + targetFound: boolean; + visibleAtOrigin: boolean; + records: Array<{ left: number; top: number; opacity: number }>; + }>((resolve) => { + let target: HTMLElement | null = null; + const records: Array<{ left: number; top: number; opacity: number }> = []; + + const capture = () => { + if (!target) return; + const style = getComputedStyle(target); + records.push({ + left: Number.parseFloat(style.left || "0"), + top: Number.parseFloat(style.top || "0"), + opacity: Number.parseFloat(style.opacity || "1"), + }); + }; + + const getContainer = (node: HTMLElement): HTMLElement | null => { + let current: HTMLElement | null = node; + while (current && current !== document.body) { + const style = getComputedStyle(current); + if (style.position === "absolute" && style.backgroundColor !== "rgba(0, 0, 0, 0)") { + return current; + } + current = current.parentElement; + } + return null; + }; + + const tryResolveTarget = (root: HTMLElement) => { + const stack = [root, ...Array.from(root.querySelectorAll("*"))]; + for (const element of stack) { + if (!element.textContent?.includes("No projects")) continue; + const container = getContainer(element); + if (!container) continue; + target = container; + return true; + } + return false; + }; + + const finish = () => { + observer.disconnect(); + if (!target) { + resolve({ + targetFound: false, + visibleAtOrigin: false, + records: [], + }); + return; + } + + const visibleAtOrigin = records.some( + (entry) => entry.left <= 1 && entry.top <= 1 && entry.opacity > 0.01 + ); + + resolve({ + targetFound: true, + visibleAtOrigin, + records, + }); + }; + + const sampleFrames = () => { + capture(); + requestAnimationFrame(() => { + capture(); + requestAnimationFrame(() => { + capture(); + requestAnimationFrame(() => { + capture(); + requestAnimationFrame(() => { + capture(); + finish(); + }); + }); + }); + }); + }; + + const observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + for (const added of Array.from(mutation.addedNodes)) { + if (!(added instanceof HTMLElement)) continue; + if (tryResolveTarget(added)) { + sampleFrames(); + return; + } + } + + if ( + mutation.type === "attributes" && + mutation.target instanceof HTMLElement && + !target && + tryResolveTarget(mutation.target) + ) { + sampleFrames(); + return; + } + } + }); + + observer.observe(document.body, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ["style"], + }); + + setTimeout(() => finish(), 2500); + }); + }); + + await trigger.click(); + const probe = await page.evaluate(() => (window as any).__projectFilterFlashProbe); + + expect(probe.targetFound).toBe(true); + expect(probe.visibleAtOrigin).toBe(false); +}); diff --git a/packages/app/e2e/working-directory-combobox-positioning.spec.ts b/packages/app/e2e/working-directory-combobox-positioning.spec.ts new file mode 100644 index 000000000..cff83ce83 --- /dev/null +++ b/packages/app/e2e/working-directory-combobox-positioning.spec.ts @@ -0,0 +1,158 @@ +import { expect, test } from "./fixtures"; +import { gotoHome } from "./helpers/app"; + +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +test("working directory combobox stays visually stable while typing search", async ({ page }) => { + await gotoHome(page); + + const workingDirectorySelect = page + .locator('[data-testid="working-directory-select"]:visible') + .first(); + await expect(workingDirectorySelect).toBeVisible(); + await workingDirectorySelect.click({ force: true }); + + const searchInput = page.getByRole("textbox", { name: /search directories/i }).first(); + await expect(searchInput).toBeVisible(); + + await page.evaluate(() => { + const trigger = document.querySelector('[data-testid="working-directory-select"]'); + const searchInput = document.querySelector('input[placeholder="Search directories..."]'); + const container = document.querySelector('[data-testid="combobox-desktop-container"]'); + if (!(trigger instanceof HTMLElement)) { + throw new Error("Missing working-directory-select trigger."); + } + if (!(searchInput instanceof HTMLInputElement)) { + throw new Error("Missing working directory search input."); + } + if (!(container instanceof HTMLElement)) { + throw new Error("Missing combobox desktop container."); + } + + const state = { + samples: 0, + underTriggerSamples: 0, + emptyWhileSearchingSamples: 0, + minSearchDelta: Number.POSITIVE_INFINITY, + maxSearchDelta: Number.NEGATIVE_INFINITY, + minContainerDelta: Number.POSITIVE_INFINITY, + maxContainerDelta: Number.NEGATIVE_INFINITY, + logs: [] as Array<{ + reason: string; + query: string; + searchDelta: number; + containerDelta: number; + hasEmpty: boolean; + containerTop: number; + containerBottom: number; + triggerTop: number; + searchBottom: number; + }>, + }; + + const sample = (reason: string) => { + if (!document.body.contains(trigger) || !document.body.contains(searchInput) || !document.body.contains(container)) { + return; + } + const query = searchInput.value.trim(); + if (!query) { + return; + } + + const triggerRect = trigger.getBoundingClientRect(); + const searchRect = searchInput.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + const hasEmpty = Boolean(container.querySelector('[data-testid="combobox-empty-text"]')); + const searchDelta = searchRect.bottom - triggerRect.top; + const containerDelta = containerRect.bottom - triggerRect.top; + + state.samples += 1; + state.minSearchDelta = Math.min(state.minSearchDelta, searchDelta); + state.maxSearchDelta = Math.max(state.maxSearchDelta, searchDelta); + state.minContainerDelta = Math.min(state.minContainerDelta, containerDelta); + state.maxContainerDelta = Math.max(state.maxContainerDelta, containerDelta); + if (searchDelta > 2 || containerDelta > 2) { + state.underTriggerSamples += 1; + } + if (hasEmpty) { + state.emptyWhileSearchingSamples += 1; + } + if (state.logs.length < 80) { + state.logs.push({ + reason, + query, + searchDelta, + containerDelta, + hasEmpty, + containerTop: containerRect.top, + containerBottom: containerRect.bottom, + triggerTop: triggerRect.top, + searchBottom: searchRect.bottom, + }); + } + }; + + const mutationObserver = new MutationObserver(() => sample("mutation")); + mutationObserver.observe(container, { + childList: true, + subtree: true, + attributes: true, + characterData: true, + }); + + const resizeObserver = new ResizeObserver(() => sample("resize")); + resizeObserver.observe(container); + resizeObserver.observe(searchInput); + + let rafId = 0; + const loop = () => { + sample("raf"); + rafId = requestAnimationFrame(loop); + }; + rafId = requestAnimationFrame(loop); + + (window as any).__paseoComboboxObserver = { + stop: () => { + cancelAnimationFrame(rafId); + mutationObserver.disconnect(); + resizeObserver.disconnect(); + sample("stop"); + return { + ...state, + minSearchDelta: state.samples > 0 ? state.minSearchDelta : 0, + maxSearchDelta: state.samples > 0 ? state.maxSearchDelta : 0, + minContainerDelta: state.samples > 0 ? state.minContainerDelta : 0, + maxContainerDelta: state.samples > 0 ? state.maxContainerDelta : 0, + }; + }, + }; + }); + + const queries = [ + "/tmp/paseo-stability-a", + "/tmp/paseo-stability-ab", + "/tmp/paseo-stability-abc", + "/tmp/paseo-stability-longer-branch", + "/tmp/paseo-stability-z", + ]; + + for (const query of queries) { + await searchInput.fill(""); + await searchInput.type(query, { delay: 20 }); + const customOption = page.getByText(new RegExp(`^${escapeRegex(query)}$`)).first(); + await expect(customOption).toBeVisible(); + await page.waitForTimeout(100); + } + + const stats = await page.evaluate(() => (window as any).__paseoComboboxObserver.stop()); + const debug = JSON.stringify(stats.logs.slice(-10)); + + expect(stats.samples, debug).toBeGreaterThan(20); + expect(stats.underTriggerSamples, debug).toBe(0); + expect(stats.emptyWhileSearchingSamples, debug).toBe(0); + expect(stats.maxSearchDelta - stats.minSearchDelta, debug).toBeLessThanOrEqual(3); + expect(stats.maxContainerDelta - stats.minContainerDelta, debug).toBeLessThanOrEqual(3); + expect(stats.maxContainerDelta, debug).toBeLessThanOrEqual(2); +}); diff --git a/packages/app/eas.json b/packages/app/eas.json index 32cc8e130..7b8a19ab2 100644 --- a/packages/app/eas.json +++ b/packages/app/eas.json @@ -1,6 +1,7 @@ { "cli": { - "version": ">= 14.0.0" + "version": ">= 14.0.0", + "appVersionSource": "remote" }, "build": { "development": { @@ -18,6 +19,9 @@ "channel": "production", "env": { "APP_VARIANT": "production" + }, + "android": { + "autoIncrement": "versionCode" } }, "production-apk": { @@ -33,6 +37,9 @@ "production": { "ios": { "ascAppId": "6758887924" + }, + "android": { + "releaseStatus": "draft" } } } diff --git a/packages/app/package.json b/packages/app/package.json index 1fd5496a4..bfd5faf30 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,7 +1,7 @@ { "name": "@getpaseo/app", "main": "index.ts", - "version": "0.1.4", + "version": "0.1.13", "private": true, "scripts": { "start": "expo start", @@ -30,7 +30,7 @@ "@dnd-kit/utilities": "^3.2.2", "@expo/vector-icons": "^15.0.2", "@floating-ui/react-native": "^0.10.7", - "@getpaseo/server": "0.1.4", + "@getpaseo/server": "0.1.13", "@gorhom/bottom-sheet": "^5.2.6", "@gorhom/portal": "^1.0.14", "@lezer/common": "^1.5.0", @@ -77,6 +77,7 @@ "expo-system-ui": "~6.0.7", "expo-updates": "~29.0.12", "expo-web-browser": "~15.0.8", + "lezer-elixir": "^1.1.2", "lucide-react-native": "^0.546.0", "mnemonic-id": "^3.2.7", "react": "19.1.0", diff --git a/packages/app/src/components/agent-form/agent-form-dropdowns.tsx b/packages/app/src/components/agent-form/agent-form-dropdowns.tsx index 9b2146f0d..efb949666 100644 --- a/packages/app/src/components/agent-form/agent-form-dropdowns.tsx +++ b/packages/app/src/components/agent-form/agent-form-dropdowns.tsx @@ -883,11 +883,12 @@ export function WorkingDirectoryDropdown({ options={options} value={workingDir} onSelect={onSelectPath} - searchPlaceholder="/path/to/project" + searchPlaceholder="Search directories..." emptyText={emptyText} allowCustomValue - customValuePrefix="Use" - customValueDescription="Launch the agent in this directory" + customValuePrefix="" + customValueKind="directory" + optionsPosition="above-search" title="Working directory" open={isOpen} onOpenChange={handleOpenChange} diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index 6b89104a7..0c8452f21 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -26,7 +26,10 @@ import { } from "./message-input"; import { Theme } from "@/styles/theme"; import { CommandAutocomplete } from "./command-autocomplete"; -import { useAgentCommandsQuery } from "@/hooks/use-agent-commands-query"; +import { + useAgentCommandsQuery, + type DraftCommandConfig, +} from "@/hooks/use-agent-commands-query"; import { encodeImages } from "@/utils/encode-images"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { focusWithRetries } from "@/utils/web-focus"; @@ -55,6 +58,8 @@ interface AgentInputAreaProps { autoFocus?: boolean; /** Callback to expose the addImages function to parent components */ onAddImages?: (addImages: (images: ImageAttachment[]) => void) => void; + /** Optional draft context for listing commands before an agent exists. */ + commandDraftConfig?: DraftCommandConfig; } const EMPTY_ARRAY: readonly QueuedMessage[] = []; @@ -69,6 +74,7 @@ export function AgentInputArea({ onChangeText, autoFocus = false, onAddImages, + commandDraftConfig, }: AgentInputAreaProps) { const { theme } = useUnistyles(); const insets = useSafeAreaInsets(); @@ -117,13 +123,39 @@ export function AgentInputArea({ // Command autocomplete logic const showCommandAutocomplete = userInput.startsWith("/") && !userInput.includes(" "); const commandFilter = showCommandAutocomplete ? userInput.slice(1) : ""; + const normalizedCommandDraftConfig = useMemo(() => { + if (!commandDraftConfig) { + return undefined; + } - // Prefetch commands when agent is available (not on new agent screen) + const cwd = commandDraftConfig.cwd.trim(); + if (!cwd) { + return undefined; + } + + const modeId = commandDraftConfig.modeId?.trim() ?? ""; + const model = commandDraftConfig.model?.trim() ?? ""; + const thinkingOptionId = commandDraftConfig.thinkingOptionId?.trim() ?? ""; + return { + provider: commandDraftConfig.provider, + cwd, + ...(modeId ? { modeId } : {}), + ...(model ? { model } : {}), + ...(thinkingOptionId ? { thinkingOptionId } : {}), + }; + }, [commandDraftConfig]); + + // Prefetch commands for real agents and for draft screens when context is available. const isRealAgent = agentId && !agentId.startsWith("__"); + const commandQueryDraftConfig = isRealAgent + ? undefined + : normalizedCommandDraftConfig; + const canLoadCommands = Boolean(serverId) && (isRealAgent || !!commandQueryDraftConfig); const { commands } = useAgentCommandsQuery({ serverId, agentId, - enabled: !!isRealAgent && !!serverId, + enabled: canLoadCommands, + draftConfig: commandQueryDraftConfig, }); // Filter commands for keyboard navigation @@ -234,15 +266,38 @@ export function AgentInputArea({ }, [onSubmitMessage]); const isAgentRunning = agent?.status === "running"; + const agentUpdatedAtMs = agent?.updatedAt?.getTime() ?? 0; const prevIsAgentRunningRef = useRef(isAgentRunning); + const latestAgentUpdatedAtRef = useRef(agentUpdatedAtMs); useEffect(() => { + const previousUpdatedAt = latestAgentUpdatedAtRef.current; + if (agentUpdatedAtMs < previousUpdatedAt) { + return; + } + const wasRunning = prevIsAgentRunningRef.current; + let shouldClearProcessing = false; + + if (isProcessing) { + const hasEnteredRunning = !wasRunning && isAgentRunning; + const hasFreshRunningUpdateWhileRunning = + wasRunning && isAgentRunning && agentUpdatedAtMs > previousUpdatedAt; + const hasStoppedRunning = wasRunning && !isAgentRunning; + + shouldClearProcessing = + hasEnteredRunning || + hasFreshRunningUpdateWhileRunning || + hasStoppedRunning; + } + prevIsAgentRunningRef.current = isAgentRunning; - if (!wasRunning && isAgentRunning && isProcessing) { + latestAgentUpdatedAtRef.current = agentUpdatedAtMs; + + if (shouldClearProcessing) { setIsProcessing(false); } - }, [isAgentRunning, isProcessing]); + }, [agentUpdatedAtMs, isAgentRunning, isProcessing]); const updateQueue = useCallback( (updater: (current: QueuedMessage[]) => QueuedMessage[]) => { @@ -612,7 +667,7 @@ export function AgentInputArea({ ] ); - const cancelButton = isAgentRunning && !hasSendableContent ? ( + const cancelButton = isAgentRunning && !hasSendableContent && !isProcessing ? ( )} @@ -756,6 +812,7 @@ export function AgentInputArea({ voiceAgentId={agentId} isAgentRunning={isAgentRunning} onQueue={handleQueue} + onSubmitLoadingPress={isAgentRunning ? handleCancelAgent : undefined} onKeyPress={handleCommandKeyPress} /> diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx index 7a8757910..cf10fd742 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -105,12 +105,6 @@ export function AgentList({ return; } - // Clear attention flag when opening agent - const session = useSessionStore.getState().sessions[serverId]; - if (session?.client) { - session.client.clearAgentAttention(agentId); - } - const navigationKey = buildAgentNavigationKey(serverId, agentId); startNavigationTiming(navigationKey, { from: "home", diff --git a/packages/app/src/components/command-autocomplete.tsx b/packages/app/src/components/command-autocomplete.tsx index fb83afafe..29e03f5e4 100644 --- a/packages/app/src/components/command-autocomplete.tsx +++ b/packages/app/src/components/command-autocomplete.tsx @@ -1,6 +1,9 @@ import { View, Text, Pressable, ScrollView } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { useAgentCommandsQuery } from "@/hooks/use-agent-commands-query"; +import { + useAgentCommandsQuery, + type DraftCommandConfig, +} from "@/hooks/use-agent-commands-query"; import { Fonts } from "@/constants/theme"; import { Theme } from "@/styles/theme"; @@ -16,6 +19,7 @@ interface CommandAutocompleteProps { filter: string; selectedIndex: number; onSelect: (command: AgentSlashCommand) => void; + draftConfig?: DraftCommandConfig; } export function CommandAutocomplete({ @@ -24,12 +28,14 @@ export function CommandAutocomplete({ filter, selectedIndex, onSelect, + draftConfig, }: CommandAutocompleteProps) { const { theme } = useUnistyles(); const { commands, isLoading, isError, error } = useAgentCommandsQuery({ serverId, agentId, enabled: true, + draftConfig, }); // Filter commands based on input after / diff --git a/packages/app/src/components/diff-viewer.tsx b/packages/app/src/components/diff-viewer.tsx index d3308c411..fc413ca34 100644 --- a/packages/app/src/components/diff-viewer.tsx +++ b/packages/app/src/components/diff-viewer.tsx @@ -129,6 +129,12 @@ const styles = StyleSheet.create((theme) => { fontFamily: Fonts.mono, fontSize: theme.fontSize.xs, color: theme.colors.foreground, + ...(Platform.OS === "web" + ? { + whiteSpace: "pre", + overflowWrap: "normal", + } + : null), }, headerLine: { backgroundColor: theme.colors.surface1, diff --git a/packages/app/src/components/explorer-sidebar.tsx b/packages/app/src/components/explorer-sidebar.tsx index 9f9603d97..4b8c44914 100644 --- a/packages/app/src/components/explorer-sidebar.tsx +++ b/packages/app/src/components/explorer-sidebar.tsx @@ -240,8 +240,9 @@ export function ExplorerSidebar({ serverId, agentId, cwd, isGit }: ExplorerSideb width: resizeWidth.value, })); - // Mobile: full-screen overlay with gesture - const overlayPointerEvents = Platform.OS === "web" ? "auto" : "box-none"; + // Mobile: full-screen overlay with gesture. + // On web, keep it interactive only while open so closed sidebars don't eat taps. + const overlayPointerEvents = Platform.OS === "web" ? (isOpen ? "auto" : "none") : "box-none"; if (isMobile) { return ( diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx index dabb003b0..0043e1747 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -14,7 +14,6 @@ import { import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gesture-handler"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import AsyncStorage from "@react-native-async-storage/async-storage"; -import * as Linking from "expo-linking"; import { Archive, ChevronDown, @@ -52,6 +51,7 @@ import { } from "@/components/ui/dropdown-menu"; import { GitHubIcon } from "@/components/icons/github-icon"; import { buildHostAgentDraftRoute } from "@/utils/host-routes"; +import { openExternalUrl } from "@/utils/open-external-url"; // ============================================================================= // Git Actions Data Structure @@ -85,11 +85,7 @@ interface GitActions { } function openURLInNewTab(url: string): void { - if (Platform.OS === "web") { - window.open(url, "_blank", "noopener"); - } else { - void Linking.openURL(url); - } + void openExternalUrl(url); } const DIFF_PANE_LOG_TAG = "[GitDiffPane]"; diff --git a/packages/app/src/components/headers/back-header.tsx b/packages/app/src/components/headers/back-header.tsx index b69d9481c..adec268d6 100644 --- a/packages/app/src/components/headers/back-header.tsx +++ b/packages/app/src/components/headers/back-header.tsx @@ -11,7 +11,11 @@ interface BackHeaderProps { onBack?: () => void; } -export function BackHeader({ title, rightContent, onBack }: BackHeaderProps) { +export function BackHeader({ + title, + rightContent, + onBack, +}: BackHeaderProps) { const { theme } = useUnistyles(); return ( diff --git a/packages/app/src/components/headers/menu-header.tsx b/packages/app/src/components/headers/menu-header.tsx index c68d28d4d..9e21fa37a 100644 --- a/packages/app/src/components/headers/menu-header.tsx +++ b/packages/app/src/components/headers/menu-header.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from "react"; -import { Text } from "react-native"; +import { Text, View, type StyleProp, type ViewStyle } from "react-native"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; -import { Menu, PanelLeft } from "lucide-react-native"; +import { PanelLeft } from "lucide-react-native"; import { ScreenHeader } from "./screen-header"; import { HeaderToggleButton } from "./header-toggle-button"; import { usePanelStore } from "@/stores/panel-store"; @@ -12,7 +12,39 @@ interface MenuHeaderProps { rightContent?: ReactNode; } -export function MenuHeader({ title, rightContent }: MenuHeaderProps) { +interface SidebarMenuToggleProps { + style?: StyleProp; + tooltipSide?: "left" | "right" | "top" | "bottom"; + testID?: string; + nativeID?: string; +} + +const MOBILE_MENU_LINE_WIDTH = 16; +const MOBILE_MENU_LINE_SHORT_WIDTH = 8; +const MOBILE_MENU_LINE_HEIGHT = 2; + +function MobileMenuIcon({ color }: { color: string }) { + return ( + + + + + + ); +} + +export function SidebarMenuToggle({ + style, + tooltipSide = "right", + testID = "menu-button", + nativeID = "menu-button", +}: SidebarMenuToggleProps = {}) { const { theme } = useUnistyles(); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; @@ -22,29 +54,42 @@ export function MenuHeader({ title, rightContent }: MenuHeaderProps) { const toggleShortcutKeys = getShortcutOs() === "mac" ? ["mod", "B"] : ["mod", "."]; const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen; - const MenuIcon = isMobile ? Menu : PanelLeft; const menuIconColor = !isMobile && isOpen ? theme.colors.foreground : theme.colors.foregroundMuted; + return ( + + {isMobile ? ( + + ) : ( + + )} + + ); +} + +export function MenuHeader({ + title, + rightContent, +}: MenuHeaderProps) { return ( - - - + {title && ( {title} @@ -71,4 +116,18 @@ const styles = StyleSheet.create((theme) => ({ }, color: theme.colors.foreground, }, + mobileMenuIcon: { + width: MOBILE_MENU_LINE_WIDTH, + height: 12, + justifyContent: "space-between", + alignItems: "flex-start", + }, + mobileMenuLine: { + width: MOBILE_MENU_LINE_WIDTH, + height: MOBILE_MENU_LINE_HEIGHT, + borderRadius: theme.borderRadius.full, + }, + mobileMenuLineShort: { + width: MOBILE_MENU_LINE_SHORT_WIDTH, + }, })); diff --git a/packages/app/src/components/headers/screen-header.tsx b/packages/app/src/components/headers/screen-header.tsx index 1f8c86830..80845e5ad 100644 --- a/packages/app/src/components/headers/screen-header.tsx +++ b/packages/app/src/components/headers/screen-header.tsx @@ -16,7 +16,12 @@ interface ScreenHeaderProps { * Shared frame for the home/back headers so we only maintain padding, border, * and safe-area logic in one place. */ -export function ScreenHeader({ left, right, leftStyle, rightStyle }: ScreenHeaderProps) { +export function ScreenHeader({ + left, + right, + leftStyle, + rightStyle, +}: ScreenHeaderProps) { const insets = useSafeAreaInsets(); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; // Only add extra padding on mobile for better touch targets; on desktop, only use safe area insets diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx index 26926223d..66fd4fcba 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -75,6 +75,8 @@ export interface MessageInputProps { isAgentRunning?: boolean; /** Callback for queue button when agent is running */ onQueue?: (payload: MessagePayload) => void; + /** Optional handler used when submit button is in loading state. */ + onSubmitLoadingPress?: () => void; /** Intercept key press events before default handling. Return true to prevent default. */ onKeyPress?: (event: { key: string; preventDefault: () => void }) => boolean; } @@ -132,6 +134,7 @@ export const MessageInput = forwardRef( voiceAgentId, isAgentRunning = false, onQueue, + onSubmitLoadingPress, onKeyPress: onKeyPressCallback, }, ref @@ -576,6 +579,16 @@ export const MessageInput = forwardRef( const hasImages = images.length > 0; const hasSendableContent = value.trim().length > 0 || hasImages; const shouldShowSendButton = hasSendableContent || isSubmitLoading; + const canPressLoadingButton = + isSubmitLoading && typeof onSubmitLoadingPress === "function"; + const isSendButtonDisabled = + disabled || + (!canPressLoadingButton && (isSubmitDisabled || isSubmitLoading)); + const submitAccessibilityLabel = canPressLoadingButton + ? "Interrupt agent" + : isAgentRunning + ? "Send and interrupt" + : "Send message"; return ( @@ -724,7 +737,7 @@ export const MessageInput = forwardRef( {rightContent} - {shouldShowSendButton && isAgentRunning && onQueue && ( + {hasSendableContent && isAgentRunning && onQueue && ( ( )} {shouldShowSendButton && ( - {isSubmitLoading ? ( diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 2a843470d..b9154c3bd 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -21,7 +21,6 @@ import { } from "react"; import type { ReactNode, ComponentType } from "react"; import Markdown, { MarkdownIt } from "react-native-markdown-display"; -import * as Linking from "expo-linking"; import MaskedView from "@react-native-masked-view/masked-view"; import { Circle, @@ -72,6 +71,7 @@ import { resolveToolCallIcon } from "@/utils/tool-call-icon"; import { getNowMs, isPerfLoggingEnabled, perfLog } from "@/utils/perf"; import { parseInlinePathToken, type InlinePathTarget } from "@/utils/inline-path"; import { getMarkdownListMarker } from "@/utils/markdown-list"; +import { openExternalUrl } from "@/utils/open-external-url"; export type { InlinePathTarget } from "@/utils/inline-path"; import { useToolCallSheet } from "./tool-call-sheet"; import { ToolCallDetailsContent } from "./tool-call-details"; @@ -119,6 +119,8 @@ const WEB_TOOLCALL_SHIMMER_KEYFRAME_CSS = ` } `; let webToolCallShimmerRegistered = false; +const SCROLL_EDGE_EPSILON = 0.5; +type ScrollAxis = "x" | "y"; function ensureWebToolCallShimmerKeyframes() { if (Platform.OS !== "web") { @@ -145,6 +147,113 @@ function ensureWebToolCallShimmerKeyframes() { webToolCallShimmerRegistered = true; } +function getWheelEventElementTarget( + event: WheelEvent, + fallback: HTMLElement +): HTMLElement { + const { target } = event; + if (target instanceof HTMLElement) { + return target; + } + if (target instanceof Node && target.parentElement) { + return target.parentElement; + } + return fallback; +} + +function canElementScrollInDirection( + element: HTMLElement, + axis: ScrollAxis, + delta: number +): boolean { + if (delta === 0) { + return false; + } + + const computedStyle = window.getComputedStyle(element); + const overflow = axis === "x" ? computedStyle.overflowX : computedStyle.overflowY; + const isScrollableOverflow = + overflow === "auto" || overflow === "scroll" || overflow === "overlay"; + if (!isScrollableOverflow) { + return false; + } + + const scrollPosition = axis === "x" ? element.scrollLeft : element.scrollTop; + const scrollSize = + axis === "x" ? element.scrollWidth - element.clientWidth : element.scrollHeight - element.clientHeight; + if (scrollSize <= SCROLL_EDGE_EPSILON) { + return false; + } + + if (delta > 0) { + return scrollPosition < scrollSize - SCROLL_EDGE_EPSILON; + } + return scrollPosition > SCROLL_EDGE_EPSILON; +} + +function canScrollInsideDetailFromTarget( + detailRoot: HTMLElement, + startElement: HTMLElement, + axis: ScrollAxis, + delta: number +): boolean { + if (delta === 0) { + return false; + } + + let current: HTMLElement | null = startElement; + while (current) { + if (canElementScrollInDirection(current, axis, delta)) { + return true; + } + if (current === detailRoot) { + break; + } + current = current.parentElement; + } + return false; +} + +function shouldStopDetailWheelPropagation( + detailRoot: HTMLElement, + event: WheelEvent +): boolean { + const startElement = getWheelEventElementTarget(event, detailRoot); + const verticalDelta = event.deltaY; + const horizontalDelta = + event.deltaX !== 0 ? event.deltaX : (event.shiftKey ? event.deltaY : 0); + + const hasVerticalIntent = Math.abs(verticalDelta) > SCROLL_EDGE_EPSILON; + const hasHorizontalIntent = Math.abs(horizontalDelta) > SCROLL_EDGE_EPSILON; + if (!hasVerticalIntent && !hasHorizontalIntent) { + return false; + } + + const canScrollVertically = hasVerticalIntent + ? canScrollInsideDetailFromTarget(detailRoot, startElement, "y", verticalDelta) + : false; + const canScrollHorizontally = hasHorizontalIntent + ? canScrollInsideDetailFromTarget( + detailRoot, + startElement, + "x", + horizontalDelta + ) + : false; + + if (hasVerticalIntent && hasHorizontalIntent) { + const isVerticalDominant = Math.abs(verticalDelta) >= Math.abs(horizontalDelta); + return isVerticalDominant + ? canScrollVertically || canScrollHorizontally + : canScrollHorizontally || canScrollVertically; + } + + if (hasVerticalIntent) { + return canScrollVertically; + } + return canScrollHorizontally; +} + const userMessageStylesheet = StyleSheet.create((theme) => ({ container: { flexDirection: "row", @@ -587,12 +696,10 @@ export const AssistantMessage = memo(function AssistantMessage({ ); const handleLinkPress = useCallback((url: string) => { - if (Platform.OS === "web") { - window.open(url, "_blank", "noopener,noreferrer"); - } else { - void Linking.openURL(url); - } - return true; + void openExternalUrl(url); + // react-native-markdown-display opens the link itself when this returns true. + // We already handled it above, so return false to avoid duplicate opens. + return false; }, []); const markdownRules = useMemo(() => { @@ -1170,6 +1277,7 @@ const ExpandableBadge = memo(function ExpandableBadge({ const hasDetailContent = Boolean(renderDetails); const detailContent = hasDetailContent && isExpanded ? renderDetails?.() : null; + const detailWrapperRef = useRef(null); const nativeGradientIdRef = useRef( `shimmer-gradient-${Math.random().toString(36).substring(2, 9)}` @@ -1289,6 +1397,28 @@ const ExpandableBadge = memo(function ExpandableBadge({ shimmerTranslateX, ]); + useEffect(() => { + if (Platform.OS !== "web" || !isExpanded || !hasDetailContent) { + return; + } + + const node = detailWrapperRef.current as unknown as HTMLElement | null; + if (!node || typeof node.addEventListener !== "function") { + return; + } + + const stopWheelPropagation = (event: WheelEvent) => { + if (shouldStopDetailWheelPropagation(node, event)) { + event.stopPropagation(); + } + }; + + node.addEventListener("wheel", stopWheelPropagation, { passive: true }); + return () => { + node.removeEventListener("wheel", stopWheelPropagation); + }; + }, [isExpanded, hasDetailContent]); + const nativeShimmerPeakStyle = useAnimatedStyle(() => ({ transform: [{ translateX: shimmerTranslateX.value }], })); @@ -1525,6 +1655,7 @@ const ExpandableBadge = memo(function ExpandableBadge({ {detailContent ? ( onDetailHoverChange?.(true)} onHoverOut={() => onDetailHoverChange?.(false)} diff --git a/packages/app/src/components/sidebar-agent-list.tsx b/packages/app/src/components/sidebar-agent-list.tsx index 29e2d25b8..1d5d394e0 100644 --- a/packages/app/src/components/sidebar-agent-list.tsx +++ b/packages/app/src/components/sidebar-agent-list.tsx @@ -592,11 +592,6 @@ export function SidebarAgentList({ return; } - const session = useSessionStore.getState().sessions[entry.agent.serverId]; - if (session?.client) { - session.client.clearAgentAttention(entry.agent.id); - } - const navigationKey = buildAgentNavigationKey(entry.agent.serverId, entry.agent.id); startNavigationTiming(navigationKey, { from: "home", @@ -1032,17 +1027,19 @@ const styles = StyleSheet.create((theme) => ({ agentTitle: { flex: 1, fontSize: theme.fontSize.base, + fontWeight: theme.fontWeight.normal, color: theme.colors.foreground, - opacity: 0.85, + opacity: 1, }, agentTitleInactive: { - color: theme.colors.foregroundMuted, + opacity: 0.8, }, agentTitleSelected: { opacity: 1, }, relativeTimeText: { color: theme.colors.foregroundMuted, + opacity: 0.78, fontSize: theme.fontSize.xs, }, shortcutBadge: { @@ -1084,10 +1081,10 @@ const styles = StyleSheet.create((theme) => ({ opacity: 0.5, }, agentMetaProjectName: { - color: theme.colors.foregroundMuted, + color: theme.colors.foreground, + opacity: 0.78, fontSize: theme.fontSize.xs, - flexShrink: 1, - minWidth: 0, + flexShrink: 0, }, agentMetaBranchBadge: { borderRadius: theme.borderRadius.full, @@ -1095,14 +1092,18 @@ const styles = StyleSheet.create((theme) => ({ borderColor: theme.colors.border, paddingHorizontal: theme.spacing[1], paddingVertical: 1, - flexShrink: 0, + flexShrink: 1, + minWidth: 0, }, agentMetaBranchBadgeSelected: { borderColor: theme.colors.borderAccent, }, agentMetaBranchText: { - color: theme.colors.foregroundMuted, + color: theme.colors.foreground, + opacity: 0.76, fontSize: theme.fontSize.xs, + flexShrink: 1, + minWidth: 0, }, selectionBar: { position: "absolute", diff --git a/packages/app/src/components/sliding-sidebar.tsx b/packages/app/src/components/sliding-sidebar.tsx index 4d9f679dc..b12213ab8 100644 --- a/packages/app/src/components/sliding-sidebar.tsx +++ b/packages/app/src/components/sliding-sidebar.tsx @@ -16,7 +16,7 @@ import { SidebarAgentList } from "./sidebar-agent-list"; import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton"; import { useSidebarAgentsGrouped } from "@/hooks/use-sidebar-agents-grouped"; import { useSidebarAnimation } from "@/contexts/sidebar-animation-context"; -import { useTauriDragHandlers } from "@/utils/tauri-window"; +import { useTauriDragHandlers, useTrafficLightPadding } from "@/utils/tauri-window"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { Combobox } from "@/components/ui/combobox"; import { useDaemonRegistry } from "@/contexts/daemon-registry-context"; @@ -121,6 +121,7 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) { closeGestureRef, } = useSidebarAnimation(); const dragHandlers = useTauriDragHandlers(); + const trafficLightPadding = useTrafficLightPadding(); // Track user-initiated refresh to avoid showing spinner on background revalidation const [isManualRefresh, setIsManualRefresh] = useState(false); @@ -324,8 +325,9 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) { // Render mobile sidebar - // On web, use "auto" instead of "box-none" because web's pointer-events: none blocks scroll - const overlayPointerEvents = Platform.OS === "web" ? "auto" : "box-none"; + // On web, keep the overlay interactive only while the sidebar is open. + // This preserves swipe/scroll behavior without blocking taps when closed. + const overlayPointerEvents = Platform.OS === "web" ? (isOpen ? "auto" : "none") : "box-none"; if (isMobile) { return ( @@ -465,7 +467,10 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) { return ( - + Promise | void; onPendingModifiersConsumed?: () => Promise | void; + onOutputChunkConsumed?: (sequence: number) => Promise | void; pendingModifiers?: PendingTerminalModifiers; focusRequestToken?: number; + resizeRequestToken?: number; } declare global { @@ -47,8 +49,10 @@ export default function TerminalEmulator({ onResize, onTerminalKey, onPendingModifiersConsumed, + onOutputChunkConsumed, pendingModifiers = { ctrl: false, shift: false, alt: false }, focusRequestToken = 0, + resizeRequestToken = 0, }: TerminalEmulatorProps) { const rootRef = useRef(null); const hostRef = useRef(null); @@ -108,19 +112,30 @@ export default function TerminalEmulator({ useEffect(() => { const runtime = runtimeRef.current; - if (!runtime) { - return; - } - if (outputChunkSequence <= 0) { return; } - if (outputChunkText.length === 0) { - runtime.clear(); + + if (!runtime) { + onOutputChunkConsumed?.(outputChunkSequence); return; } - runtime.write({ text: outputChunkText }); - }, [outputChunkSequence, outputChunkText]); + + if (outputChunkText.length === 0) { + runtime.clear({ + onCommitted: () => { + onOutputChunkConsumed?.(outputChunkSequence); + }, + }); + return; + } + runtime.write({ + text: outputChunkText, + onCommitted: () => { + onOutputChunkConsumed?.(outputChunkSequence); + }, + }); + }, [onOutputChunkConsumed, outputChunkSequence, outputChunkText]); useEffect(() => { if (focusRequestToken <= 0) { @@ -129,6 +144,13 @@ export default function TerminalEmulator({ runtimeRef.current?.focus(); }, [focusRequestToken]); + useEffect(() => { + if (resizeRequestToken <= 0) { + return; + } + runtimeRef.current?.resize({ force: true }); + }, [resizeRequestToken]); + return (
terminalScopeKey({ serverId, cwd }), [serverId, cwd]); + const terminalsQueryKey = useMemo(() => ["terminals", serverId, cwd] as const, [cwd, serverId]); const selectedTerminalByScopeRef = useRef>(new Map()); const lastReportedSizeRef = useRef<{ rows: number; cols: number } | null>(null); const streamControllerRef = useRef(null); const outputPumpRef = useRef(null); + const outputDeliveryQueueRef = useRef(null); const [selectedTerminalId, setSelectedTerminalId] = useState(null); const [selectedOutputChunk, setSelectedOutputChunk] = useState({ @@ -136,22 +161,40 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { const [streamError, setStreamError] = useState(null); const [modifiers, setModifiers] = useState(EMPTY_MODIFIERS); const [focusRequestToken, setFocusRequestToken] = useState(0); + const [resizeRequestToken, setResizeRequestToken] = useState(0); const [hoveredTerminalId, setHoveredTerminalId] = useState(null); const [hoveredCloseTerminalId, setHoveredCloseTerminalId] = useState( null ); const hoverOutTimeoutRef = useRef | null>(null); const selectedTerminalIdRef = useRef(selectedTerminalId); + const pendingTerminalInputRef = useRef([]); useEffect(() => { selectedTerminalIdRef.current = selectedTerminalId; }, [selectedTerminalId]); + useEffect(() => { + const outputDeliveryQueue = new TerminalOutputDeliveryQueue({ + onDeliver: (chunk) => { + setSelectedOutputChunk(chunk); + }, + }); + outputDeliveryQueueRef.current = outputDeliveryQueue; + + return () => { + if (outputDeliveryQueueRef.current === outputDeliveryQueue) { + outputDeliveryQueueRef.current = null; + } + outputDeliveryQueue.reset(); + }; + }, []); + useEffect(() => { const outputPump = new TerminalOutputPump({ maxOutputChars: MAX_OUTPUT_CHARS, onSelectedOutputChunk: (chunk: TerminalOutputChunk) => { - setSelectedOutputChunk(chunk); + outputDeliveryQueueRef.current?.enqueue(chunk); }, }); outputPumpRef.current = outputPump; @@ -210,8 +253,36 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { return () => clearHoverOutTimeout(); }, [clearHoverOutTimeout]); + const requestTerminalFocus = useCallback(() => { + setFocusRequestToken((current) => current + 1); + }, []); + const requestTerminalReflow = useCallback(() => { + setResizeRequestToken((current) => current + 1); + }, []); + + useFocusEffect( + useCallback(() => { + if (!selectedTerminalId) { + return; + } + // Navigation transitions can temporarily report stale dimensions. + // Pulse forced refits so xterm fills the pane when returning to an agent. + const timeoutHandles = TERMINAL_REFIT_DELAYS_MS.map((delayMs) => + setTimeout(() => { + requestTerminalReflow(); + }, delayMs) + ); + + return () => { + for (const handle of timeoutHandles) { + clearTimeout(handle); + } + }; + }, [requestTerminalReflow, selectedTerminalId]) + ); + const terminalsQuery = useQuery({ - queryKey: ["terminals", serverId, cwd] as const, + queryKey: terminalsQueryKey, enabled: Boolean(client && isConnected && cwd.startsWith("/")), queryFn: async () => { if (!client) { @@ -246,14 +317,43 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { setModifiers({ ...EMPTY_MODIFIERS }); void queryClient.invalidateQueries({ - queryKey: ["terminals", serverId, cwd], + queryKey: terminalsQueryKey, }); void queryClient.refetchQueries({ - queryKey: ["terminals", serverId, cwd], + queryKey: terminalsQueryKey, type: "active", }); }); - }, [client, cwd, isConnected, queryClient, serverId]); + }, [client, isConnected, queryClient, terminalsQueryKey]); + + useEffect(() => { + if (!client || !isConnected || !cwd.startsWith("/")) { + return; + } + + const unsubscribe = client.on("terminals_changed", (message) => { + if (message.type !== "terminals_changed") { + return; + } + if (message.payload.cwd !== cwd) { + return; + } + void queryClient.invalidateQueries({ + queryKey: terminalsQueryKey, + }); + void queryClient.refetchQueries({ + queryKey: terminalsQueryKey, + type: "active", + }); + }); + + client.subscribeTerminals({ cwd }); + + return () => { + unsubscribe(); + client.unsubscribeTerminals({ cwd }); + }; + }, [client, cwd, isConnected, queryClient, terminalsQueryKey]); const createTerminalMutation = useMutation({ mutationFn: async () => { @@ -263,12 +363,26 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { return await client.createTerminal(cwd); }, onSuccess: (payload) => { - if (payload.terminal) { - selectedTerminalByScopeRef.current.set(scopeKey, payload.terminal.id); - setSelectedTerminalId(payload.terminal.id); + const createdTerminal = payload.terminal; + if (createdTerminal) { + queryClient.setQueryData(terminalsQueryKey, (current) => { + const nextTerminals = upsertTerminalListEntry({ + terminals: current?.terminals ?? [], + terminal: createdTerminal, + }); + + return { + cwd: current?.cwd ?? cwd, + terminals: nextTerminals, + requestId: current?.requestId ?? `terminal-create-${createdTerminal.id}`, + }; + }); + selectedTerminalByScopeRef.current.set(scopeKey, createdTerminal.id); + setSelectedTerminalId(createdTerminal.id); + requestTerminalFocus(); } void queryClient.invalidateQueries({ - queryKey: ["terminals", serverId, cwd], + queryKey: terminalsQueryKey, }); }, }); @@ -294,10 +408,10 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { setModifiers({ ...EMPTY_MODIFIERS }); } void queryClient.invalidateQueries({ - queryKey: ["terminals", serverId, cwd], + queryKey: terminalsQueryKey, }); void queryClient.refetchQueries({ - queryKey: ["terminals", serverId, cwd], + queryKey: terminalsQueryKey, type: "active", }); }, @@ -405,6 +519,9 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { }, [client, handleStreamControllerStatus, isConnected]); useEffect(() => { + outputDeliveryQueueRef.current?.reset(); + pendingTerminalInputRef.current = []; + setSelectedOutputChunk({ sequence: 0, text: "" }); outputPumpRef.current?.setSelectedTerminal({ terminalId: selectedTerminalId, }); @@ -422,6 +539,9 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { activeStream && activeStream.terminalId === selectedTerminalId ? activeStream.streamId : null; + const getCurrentActiveStreamId = useCallback(() => { + return streamControllerRef.current?.getActiveStreamId() ?? null; + }, []); const selectedTerminal = useMemo( () => terminals.find((terminal) => terminal.id === selectedTerminalId) ?? null, @@ -431,6 +551,42 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { createTerminalMutation.mutate(); }, [createTerminalMutation]); + const enqueuePendingTerminalInput = useCallback((entry: PendingTerminalInput) => { + const queue = pendingTerminalInputRef.current; + queue.push(entry); + if (queue.length > 512) { + queue.splice(0, queue.length - 512); + } + }, []); + + const flushPendingTerminalInput = useCallback(() => { + if (!client) { + return; + } + const currentStreamId = getCurrentActiveStreamId(); + if (currentStreamId === null) { + return; + } + const queue = pendingTerminalInputRef.current; + if (queue.length === 0) { + return; + } + + const pending = queue.splice(0, queue.length); + + for (const entry of pending) { + if (entry.type === "data") { + client.sendTerminalStreamInput(currentStreamId, entry.data); + continue; + } + client.sendTerminalStreamKey(currentStreamId, entry.input); + } + }, [client, getCurrentActiveStreamId]); + + useEffect(() => { + flushPendingTerminalInput(); + }, [activeStreamId, flushPendingTerminalInput]); + const handleCloseTerminal = useCallback( (terminalId: string) => { if ( @@ -444,10 +600,6 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { [killTerminalMutation] ); - const requestTerminalFocus = useCallback(() => { - setFocusRequestToken((current) => current + 1); - }, []); - const clearPendingModifiers = useCallback(() => { setModifiers({ ...EMPTY_MODIFIERS }); }, []); @@ -462,19 +614,19 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { meta?: boolean; } ): boolean => { - if (!client || activeStreamId === null) { - terminalDebugLog({ - scope: "terminal-pane", - event: "input:key:drop-no-stream", - details: { - key: input.key, + const currentStreamId = getCurrentActiveStreamId(); + if (!client || currentStreamId === null) { + enqueuePendingTerminalInput({ + type: "key", + input: { + key: normalizeTerminalTransportKey(input.key), ctrl: input.ctrl, shift: input.shift, alt: input.alt, - activeStreamId, + meta: input.meta, }, }); - return false; + return true; } const normalizedKey = normalizeTerminalTransportKey(input.key); @@ -486,10 +638,10 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { ctrl: input.ctrl, shift: input.shift, alt: input.alt, - activeStreamId, + activeStreamId: currentStreamId, }, }); - client.sendTerminalStreamKey(activeStreamId, { + client.sendTerminalStreamKey(currentStreamId, { key: normalizedKey, ctrl: input.ctrl, shift: input.shift, @@ -498,7 +650,7 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { }); return true; }, - [client, activeStreamId] + [client, enqueuePendingTerminalInput, getCurrentActiveStreamId] ); const handleTerminalData = useCallback( @@ -506,13 +658,14 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { if (data.length === 0) { return; } + const currentStreamId = getCurrentActiveStreamId(); terminalDebugLog({ scope: "terminal-pane", event: "input:data:received", details: { length: data.length, preview: summarizeTerminalText({ text: data, maxChars: 80 }), - activeStreamId, + activeStreamId: currentStreamId, }, }); @@ -541,14 +694,10 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { } } - if (!client || activeStreamId === null) { - terminalDebugLog({ - scope: "terminal-pane", - event: "input:data:drop-no-stream", - details: { - length: data.length, - preview: summarizeTerminalText({ text: data, maxChars: 80 }), - }, + if (!client || currentStreamId === null) { + enqueuePendingTerminalInput({ + type: "data", + data, }); return; } @@ -558,19 +707,20 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { details: { length: data.length, preview: summarizeTerminalText({ text: data, maxChars: 80 }), - activeStreamId, + activeStreamId: currentStreamId, }, }); - client.sendTerminalStreamInput(activeStreamId, data); + client.sendTerminalStreamInput(currentStreamId, data); }, [ - activeStreamId, clearPendingModifiers, client, + getCurrentActiveStreamId, modifiers.alt, modifiers.ctrl, modifiers.shift, sendTerminalKey, + enqueuePendingTerminalInput, ] ); @@ -626,6 +776,10 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { clearPendingModifiers(); }, [clearPendingModifiers]); + const handleOutputChunkConsumed = useCallback((sequence: number) => { + outputDeliveryQueueRef.current?.consume({ sequence }); + }, []); + const toggleModifier = useCallback( (modifier: keyof ModifierState) => { setModifiers((current) => ({ ...current, [modifier]: !current[modifier] })); @@ -808,8 +962,10 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { onResize={handleTerminalResize} onTerminalKey={handleTerminalKey} onPendingModifiersConsumed={handlePendingModifiersConsumed} + onOutputChunkConsumed={handleOutputChunkConsumed} pendingModifiers={modifiers} focusRequestToken={focusRequestToken} + resizeRequestToken={resizeRequestToken} /> ) : ( @@ -819,7 +975,11 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { )} {isAttaching ? ( - + ) : null} diff --git a/packages/app/src/components/tool-call-details.tsx b/packages/app/src/components/tool-call-details.tsx index a7098530e..49a1a7a37 100644 --- a/packages/app/src/components/tool-call-details.tsx +++ b/packages/app/src/components/tool-call-details.tsx @@ -89,6 +89,43 @@ export function ToolCallDetailsContent({ ); + } else if (detail?.type === "worktree_setup") { + const setupLog = detail.log.replace(/^\n+/, ""); + const hasLog = setupLog.length > 0; + sections.push( + + + + + + + {hasLog + ? setupLog + : `Preparing worktree ${detail.branchName} at ${detail.worktreePath}`} + + + + + + + ); } else if (detail?.type === "edit") { sections.push( { fontSize: theme.fontSize.xs, color: theme.colors.foreground, lineHeight: 18, + ...(Platform.OS === "web" + ? { + whiteSpace: "pre", + overflowWrap: "normal", + } + : null), }, shellPrompt: { color: theme.colors.foregroundMuted, diff --git a/packages/app/src/components/ui/combobox-options.test.ts b/packages/app/src/components/ui/combobox-options.test.ts new file mode 100644 index 000000000..3c6a9addf --- /dev/null +++ b/packages/app/src/components/ui/combobox-options.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; + +import { + buildVisibleComboboxOptions, + getComboboxFallbackIndex, + orderVisibleComboboxOptions, +} from "./combobox-options"; + +describe("buildVisibleComboboxOptions", () => { + const options = [ + { id: "/Users/me/project-a", label: "/Users/me/project-a", kind: "directory" as const }, + { id: "/Users/me/project-b", label: "/Users/me/project-b", kind: "directory" as const }, + ]; + + it("keeps a custom row visible while searching with no matches", () => { + const visible = buildVisibleComboboxOptions({ + options, + searchQuery: "/tmp/new-project", + searchable: true, + allowCustomValue: true, + customValuePrefix: "", + customValueKind: "directory", + }); + + expect(visible).toHaveLength(1); + expect(visible[0]).toEqual({ + id: "/tmp/new-project", + label: "/tmp/new-project", + description: undefined, + kind: "directory", + }); + }); + + it("does not duplicate a row when search exactly matches an existing option", () => { + const visible = buildVisibleComboboxOptions({ + options, + searchQuery: "/Users/me/project-a", + searchable: true, + allowCustomValue: true, + customValuePrefix: "", + customValueKind: "directory", + }); + + expect(visible).toEqual([ + { id: "/Users/me/project-a", label: "/Users/me/project-a", kind: "directory" }, + ]); + }); +}); + +describe("combobox above-search ordering", () => { + const visible = [ + { id: "/tmp/new-project", label: "/tmp/new-project", kind: "directory" as const }, + { id: "/Users/me/project-a", label: "/Users/me/project-a", kind: "directory" as const }, + { id: "/Users/me/project-b", label: "/Users/me/project-b", kind: "directory" as const }, + ]; + + it("renders first logical option closest to the search box in above-search mode", () => { + const ordered = orderVisibleComboboxOptions(visible, "above-search"); + expect(ordered.map((option) => option.id)).toEqual([ + "/Users/me/project-b", + "/Users/me/project-a", + "/tmp/new-project", + ]); + expect(getComboboxFallbackIndex(ordered.length, "above-search")).toBe(2); + }); + + it("keeps normal top-down order in below-search mode", () => { + const ordered = orderVisibleComboboxOptions(visible, "below-search"); + expect(ordered.map((option) => option.id)).toEqual([ + "/tmp/new-project", + "/Users/me/project-a", + "/Users/me/project-b", + ]); + expect(getComboboxFallbackIndex(ordered.length, "below-search")).toBe(0); + }); +}); diff --git a/packages/app/src/components/ui/combobox-options.ts b/packages/app/src/components/ui/combobox-options.ts new file mode 100644 index 000000000..b58f0b421 --- /dev/null +++ b/packages/app/src/components/ui/combobox-options.ts @@ -0,0 +1,95 @@ +export interface ComboboxOptionModel { + id: string; + label: string; + description?: string; + kind?: "directory"; +} + +export interface BuildVisibleComboboxOptionsInput { + options: ComboboxOptionModel[]; + searchQuery: string; + searchable: boolean; + allowCustomValue: boolean; + customValuePrefix: string; + customValueDescription?: string; + customValueKind?: "directory"; +} + +export function shouldShowCustomComboboxOption(input: { + options: ComboboxOptionModel[]; + searchQuery: string; + searchable: boolean; + allowCustomValue: boolean; +}): boolean { + const sanitizedSearchValue = input.searchQuery.trim(); + if (!input.searchable || !input.allowCustomValue || sanitizedSearchValue.length === 0) { + return false; + } + + return !input.options.some( + (opt) => + opt.id.toLowerCase() === sanitizedSearchValue.toLowerCase() || + opt.label.toLowerCase() === sanitizedSearchValue.toLowerCase() + ); +} + +export function buildVisibleComboboxOptions( + input: BuildVisibleComboboxOptionsInput +): ComboboxOptionModel[] { + const normalizedSearch = input.searchable ? input.searchQuery.trim().toLowerCase() : ""; + const filteredOptions = normalizedSearch + ? input.options.filter( + (opt) => + opt.label.toLowerCase().includes(normalizedSearch) || + opt.id.toLowerCase().includes(normalizedSearch) || + opt.description?.toLowerCase().includes(normalizedSearch) + ) + : input.options; + + const sanitizedSearchValue = input.searchQuery.trim(); + const showCustomOption = shouldShowCustomComboboxOption({ + options: input.options, + searchQuery: input.searchQuery, + searchable: input.searchable, + allowCustomValue: input.allowCustomValue, + }); + + const visibleOptions: ComboboxOptionModel[] = []; + + if (showCustomOption) { + const trimmedPrefix = input.customValuePrefix.trim(); + const customLabel = + trimmedPrefix.length > 0 + ? `${trimmedPrefix} "${sanitizedSearchValue}"` + : sanitizedSearchValue; + visibleOptions.push({ + id: sanitizedSearchValue, + label: customLabel, + description: input.customValueDescription, + kind: input.customValueKind, + }); + } + + visibleOptions.push(...filteredOptions); + return visibleOptions; +} + +export function orderVisibleComboboxOptions( + visibleOptions: ComboboxOptionModel[], + optionsPosition: "below-search" | "above-search" +): ComboboxOptionModel[] { + if (optionsPosition !== "above-search") { + return visibleOptions; + } + return [...visibleOptions].reverse(); +} + +export function getComboboxFallbackIndex( + itemCount: number, + optionsPosition: "below-search" | "above-search" +): number { + if (itemCount <= 0) { + return -1; + } + return optionsPosition === "above-search" ? itemCount - 1 : 0; +} diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx index 2d4735466..1f48e28bb 100644 --- a/packages/app/src/components/ui/combobox.tsx +++ b/packages/app/src/components/ui/combobox.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import type { ReactElement, ReactNode } from "react"; import { View, @@ -9,6 +9,7 @@ import { ScrollView, Platform, StatusBar, + useWindowDimensions, } from "react-native"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import { @@ -19,17 +20,20 @@ import { BottomSheetBackgroundProps, } from "@gorhom/bottom-sheet"; import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; -import { Check, Search } from "lucide-react-native"; +import { Check, Folder, Search } from "lucide-react-native"; import { flip, offset as floatingOffset, shift, size as floatingSize, useFloating } from "@floating-ui/react-native"; import { getNextActiveIndex } from "./combobox-keyboard"; +import { + buildVisibleComboboxOptions, + getComboboxFallbackIndex, + orderVisibleComboboxOptions, + shouldShowCustomComboboxOption, +} from "./combobox-options"; +import type { ComboboxOptionModel } from "./combobox-options"; const IS_WEB = Platform.OS === "web"; -export interface ComboboxOption { - id: string; - label: string; - description?: string; -} +export type ComboboxOption = ComboboxOptionModel; export interface ComboboxProps { options: ComboboxOption[]; @@ -43,14 +47,35 @@ export interface ComboboxProps { allowCustomValue?: boolean; customValuePrefix?: string; customValueDescription?: string; + customValueKind?: "directory"; + optionsPosition?: "below-search" | "above-search"; title?: string; open?: boolean; onOpenChange?: (open: boolean) => void; desktopPlacement?: "top-start" | "bottom-start"; + /** + * Prevents an initial frame at 0,0 by hiding desktop content until floating + * coordinates resolve. This intentionally disables fade enter/exit animation + * for that combobox instance to avoid animation overriding hidden opacity. + */ + desktopPreventInitialFlash?: boolean; anchorRef: React.RefObject; children?: ReactNode; } +function toNumericStyleValue(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value === "string") { + const parsed = Number.parseFloat(value); + if (Number.isFinite(parsed)) { + return parsed; + } + } + return null; +} + function ComboboxSheetBackground({ style }: BottomSheetBackgroundProps) { return ( void; @@ -119,6 +145,7 @@ export interface ComboboxItemProps { export function ComboboxItem({ label, description, + kind, selected, active, onPress, @@ -136,6 +163,11 @@ export function ComboboxItem({ active && styles.comboboxItemActive, ]} > + {kind === "directory" ? ( + + + + ) : null} {label} {description ? ( @@ -152,7 +184,7 @@ export function ComboboxItem({ } export function ComboboxEmpty({ children }: { children: ReactNode }): ReactElement { - return {children}; + return {children}; } export function Combobox({ @@ -167,21 +199,32 @@ export function Combobox({ allowCustomValue = false, customValuePrefix = "Use", customValueDescription, + customValueKind, + optionsPosition = "below-search", title = "Select", open, onOpenChange, desktopPlacement = "top-start", + desktopPreventInitialFlash = true, anchorRef, children, }: ComboboxProps): ReactElement { const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const effectiveOptionsPosition = + isMobile ? "below-search" : optionsPosition; + const isDesktopAboveSearch = + !isMobile && Platform.OS === "web" && effectiveOptionsPosition === "above-search"; + const { height: windowHeight } = useWindowDimensions(); const bottomSheetRef = useRef(null); const snapPoints = useMemo(() => ["60%", "90%"], []); const [availableSize, setAvailableSize] = useState<{ width?: number; height?: number } | null>(null); const [referenceWidth, setReferenceWidth] = useState(null); + const [referenceTop, setReferenceTop] = useState(null); + const [referenceAtOrigin, setReferenceAtOrigin] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [activeIndex, setActiveIndex] = useState(-1); + const desktopOptionsScrollRef = useRef(null); const isControlled = typeof open === "boolean"; const [internalOpen, setInternalOpen] = useState(false); @@ -227,7 +270,7 @@ export function Combobox({ () => [ floatingOffset(Platform.OS === "web" ? 0 : 4), ...(Platform.OS === "web" ? [] : [flip({ padding: collisionPadding })]), - shift({ padding: collisionPadding }), + ...(isDesktopAboveSearch ? [] : [shift({ padding: collisionPadding })]), floatingSize({ padding: collisionPadding, apply({ availableWidth, availableHeight, rects }) { @@ -245,7 +288,7 @@ export function Combobox({ }, }), ], - [collisionPadding] + [collisionPadding, isDesktopAboveSearch] ); const { refs, floatingStyles, update } = useFloating({ @@ -263,10 +306,57 @@ export function Combobox({ setReferenceWidth(null); return; } - const raf = requestAnimationFrame(() => update()); + const raf = requestAnimationFrame(() => void update()); return () => cancelAnimationFrame(raf); }, [desktopPlacement, isMobile, update, isOpen]); + useEffect(() => { + if (!isOpen || isMobile) { + setReferenceAtOrigin(false); + setReferenceTop(null); + return; + } + + const referenceEl = anchorRef.current; + if (!referenceEl) { + setReferenceAtOrigin(false); + setReferenceTop(null); + return; + } + + const measure = () => { + referenceEl.measureInWindow((x, y) => { + setReferenceAtOrigin(Math.abs(x) <= 1 && Math.abs(y) <= 1); + setReferenceTop((prev) => (prev === y ? prev : y)); + }); + }; + + measure(); + const raf = requestAnimationFrame(measure); + return () => cancelAnimationFrame(raf); + }, [anchorRef, isMobile, isOpen, searchQuery, windowHeight]); + + const floatingTop = toNumericStyleValue(floatingStyles.top); + const floatingLeft = toNumericStyleValue(floatingStyles.left); + const desktopAboveSearchBottom = + isDesktopAboveSearch && referenceTop !== null + ? Math.max(windowHeight - referenceTop, collisionPadding) + : null; + const hasResolvedDesktopPosition = + referenceWidth !== null && + floatingLeft !== null && + (isDesktopAboveSearch ? desktopAboveSearchBottom !== null : floatingTop !== null) && + ((floatingTop ?? 0) !== 0 || floatingLeft !== 0 || referenceAtOrigin); + const shouldHideDesktopContent = + desktopPreventInitialFlash && !hasResolvedDesktopPosition; + const shouldUseDesktopFade = !desktopPreventInitialFlash; + const desktopPositionStyle = isDesktopAboveSearch + ? { + left: floatingLeft ?? 0, + bottom: desktopAboveSearchBottom ?? 0, + } + : floatingStyles; + useEffect(() => { if (!isMobile) return; if (isOpen) { @@ -298,78 +388,105 @@ export function Combobox({ ); const normalizedSearch = searchable ? searchQuery.trim().toLowerCase() : ""; - const filteredOptions = useMemo(() => { - if (!normalizedSearch) { - return options; - } - return options.filter( - (opt) => - opt.label.toLowerCase().includes(normalizedSearch) || - opt.id.toLowerCase().includes(normalizedSearch) || - opt.description?.toLowerCase().includes(normalizedSearch) - ); - }, [options, normalizedSearch]); - const sanitizedSearchValue = searchQuery.trim(); - const showCustomOption = - searchable && - allowCustomValue && - sanitizedSearchValue.length > 0 && - !options.some( - (opt) => - opt.id.toLowerCase() === sanitizedSearchValue.toLowerCase() || - opt.label.toLowerCase() === sanitizedSearchValue.toLowerCase() - ); + const showCustomOption = useMemo( + () => + shouldShowCustomComboboxOption({ + options, + searchQuery, + searchable, + allowCustomValue, + }), + [allowCustomValue, options, searchQuery, searchable] + ); - const visibleOptions = useMemo(() => { - const next: Array<{ - id: string; - label: string; - description?: string; - }> = []; + const visibleOptions = useMemo( + () => + buildVisibleComboboxOptions({ + options, + searchQuery, + searchable, + allowCustomValue, + customValuePrefix, + customValueDescription, + customValueKind, + }), + [ + allowCustomValue, + customValueDescription, + customValueKind, + customValuePrefix, + options, + searchQuery, + searchable, + ] + ); - if (showCustomOption) { - next.push({ - id: sanitizedSearchValue, - label: `${customValuePrefix} "${sanitizedSearchValue}"`, - description: customValueDescription, - }); + const orderedVisibleOptions = useMemo( + () => orderVisibleComboboxOptions(visibleOptions, effectiveOptionsPosition), + [effectiveOptionsPosition, visibleOptions] + ); + + const pinDesktopOptionsToBottom = useCallback(() => { + if (isMobile || effectiveOptionsPosition !== "above-search") { + return; } + desktopOptionsScrollRef.current?.scrollToEnd({ animated: false }); + requestAnimationFrame(() => { + desktopOptionsScrollRef.current?.scrollToEnd({ animated: false }); + }); + }, [effectiveOptionsPosition, isMobile]); - for (const opt of filteredOptions) { - next.push({ - id: opt.id, - label: opt.label, - description: opt.description, - }); + const handleDesktopOptionsContentSizeChange = useCallback(() => { + if (!isOpen) { + return; } + pinDesktopOptionsToBottom(); + }, [isOpen, pinDesktopOptionsToBottom]); - return next; - }, [ - customValueDescription, - customValuePrefix, - filteredOptions, - sanitizedSearchValue, - showCustomOption, - ]); + useEffect(() => { + if (!isOpen) { + return; + } + pinDesktopOptionsToBottom(); + }, [isOpen, orderedVisibleOptions, pinDesktopOptionsToBottom]); + + useLayoutEffect(() => { + if (!isOpen || isMobile) { + return; + } + void update(); + }, [isOpen, isMobile, orderedVisibleOptions.length, searchQuery, update]); useEffect(() => { if (!isOpen) return; if (!IS_WEB && isMobile) return; - if (visibleOptions.length === 0) { + if (orderedVisibleOptions.length === 0) { setActiveIndex(-1); return; } + const fallbackIndex = getComboboxFallbackIndex( + orderedVisibleOptions.length, + effectiveOptionsPosition + ); + if (normalizedSearch) { - setActiveIndex(0); + setActiveIndex(fallbackIndex); return; } - const selectedIndex = visibleOptions.findIndex((opt) => opt.id === value); - setActiveIndex(selectedIndex >= 0 ? selectedIndex : 0); - }, [isMobile, isOpen, normalizedSearch, value, visibleOptions]); + const selectedIndex = orderedVisibleOptions.findIndex((opt) => opt.id === value); + setActiveIndex(selectedIndex >= 0 ? selectedIndex : fallbackIndex); + }, [ + effectiveOptionsPosition, + isMobile, + isOpen, + normalizedSearch, + value, + orderedVisibleOptions, + ]); const handleSelect = useCallback( (id: string) => { @@ -395,7 +512,7 @@ export function Combobox({ setActiveIndex((currentIndex) => getNextActiveIndex({ currentIndex, - itemCount: visibleOptions.length, + itemCount: orderedVisibleOptions.length, key, }) ); @@ -403,11 +520,11 @@ export function Combobox({ } if (key === "Enter") { - if (visibleOptions.length === 0) return; + if (orderedVisibleOptions.length === 0) return; event?.preventDefault(); const index = - activeIndex >= 0 && activeIndex < visibleOptions.length ? activeIndex : 0; - handleSelect(visibleOptions[index]!.id); + activeIndex >= 0 && activeIndex < orderedVisibleOptions.length ? activeIndex : 0; + handleSelect(orderedVisibleOptions[index]!.id); return; } @@ -416,7 +533,7 @@ export function Combobox({ handleClose(); } }, - [activeIndex, handleClose, handleSelect, isMobile, isOpen, visibleOptions] + [activeIndex, handleClose, handleSelect, isMobile, isOpen, orderedVisibleOptions] ); useEffect(() => { @@ -449,12 +566,13 @@ export function Combobox({ const optionsList = ( <> - {visibleOptions.length > 0 ? ( - visibleOptions.map((opt, index) => ( + {orderedVisibleOptions.length > 0 ? ( + orderedVisibleOptions.map((opt, index) => ( handleSelect(opt.id)} @@ -466,13 +584,16 @@ export function Combobox({ ); - const content = children ?? ( + const defaultContent = ( <> + {effectiveOptionsPosition === "above-search" ? optionsList : null} {searchable ? searchInput : null} - {optionsList} + {effectiveOptionsPosition === "below-search" ? optionsList : null} ); + const content = children ?? defaultContent; + if (isMobile) { return ( ) : ( <> + {effectiveOptionsPosition === "above-search" ? ( + + {optionsList} + + ) : null} {searchable ? searchInput : null} - - {optionsList} - + {effectiveOptionsPosition === "below-search" ? ( + + {optionsList} + + ) : null} )} @@ -613,6 +752,11 @@ const styles = StyleSheet.create((theme) => ({ flex: 1, flexShrink: 1, }, + comboboxItemLeadingSlot: { + width: 16, + alignItems: "center", + justifyContent: "center", + }, comboboxItemLabel: { fontSize: theme.fontSize.sm, color: theme.colors.foreground, @@ -675,9 +819,14 @@ const styles = StyleSheet.create((theme) => ({ overflow: "hidden", }, desktopScroll: { - maxHeight: 400, + flexShrink: 1, + minHeight: 0, }, desktopScrollContent: { paddingVertical: theme.spacing[1], }, + desktopScrollContentAboveSearch: { + flexGrow: 1, + justifyContent: "flex-end", + }, })); diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 3136c7122..7e82f3983 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -27,6 +27,7 @@ import { useDaemonConnections } from "./daemon-connections-context"; import type { ActiveConnection } from "./daemon-connections-context"; import { useSessionStore, + type Agent, type SessionState, type DaemonConnectionSnapshot, } from "@/stores/session-store"; @@ -850,14 +851,7 @@ export function SessionProvider({ useEffect(() => { if (!connectionSnapshot.isConnected) { hasBootstrappedAgentUpdatesRef.current = false; - const subscriptionId = agentUpdatesSubscriptionIdRef.current; - if (subscriptionId && client) { - try { - client.unsubscribeAgentUpdates(subscriptionId); - } catch { - // no-op - } - } + pendingAgentUpdatesRef.current.clear(); agentUpdatesSubscriptionIdRef.current = null; return; } @@ -866,26 +860,84 @@ export function SessionProvider({ } hasBootstrappedAgentUpdatesRef.current = true; - try { - if (!agentUpdatesSubscriptionIdRef.current) { - agentUpdatesSubscriptionIdRef.current = client.subscribeAgentUpdates({ - subscriptionId: `app:${serverId}`, - filter: { labels: { ui: "true" } }, - }); - } - } catch (err) { - console.error("[Session] subscribeAgentUpdates failed", { serverId, err }); - } + let cancelled = false; + const requestedSubscriptionId = `app:${serverId}`; - // Session bootstrap is now fully event-driven for agent lists. - setInitializingAgents(serverId, new Map()); - setHasHydratedAgents(serverId, true); - updateConnectionStatus(serverId, { - status: "online", - lastOnlineAt: new Date().toISOString(), - agentListReady: true, - }); - }, [connectionSnapshot.isConnected, client, serverId, setHasHydratedAgents, updateConnectionStatus]); + const bootstrapAgentDirectory = async () => { + try { + const payload = await client.fetchAgents({ + filter: { labels: { ui: "true" } }, + subscribe: { subscriptionId: requestedSubscriptionId }, + }); + if (cancelled) { + return; + } + + agentUpdatesSubscriptionIdRef.current = + payload.subscriptionId ?? requestedSubscriptionId; + + const nextAgents = new Map(); + const nextPendingPermissions = new Map< + string, + { key: string; agentId: string; request: AgentPermissionRequest } + >(); + const nextStatuses = new Map(); + + for (const entry of payload.entries) { + const agent = { + ...normalizeAgentSnapshot(entry.agent, serverId), + projectPlacement: entry.project, + }; + nextAgents.set(agent.id, agent); + nextStatuses.set(agent.id, agent.status); + + for (const request of agent.pendingPermissions) { + const key = derivePendingPermissionKey(agent.id, request); + nextPendingPermissions.set(key, { key, agentId: agent.id, request }); + } + } + + previousAgentStatusRef.current = nextStatuses; + pendingAgentUpdatesRef.current.clear(); + setAgents(serverId, nextAgents); + for (const agent of nextAgents.values()) { + setAgentLastActivity(agent.id, agent.lastActivityAt); + } + setPendingPermissions(serverId, nextPendingPermissions); + setInitializingAgents(serverId, new Map()); + setHasHydratedAgents(serverId, true); + updateConnectionStatus(serverId, { + status: "online", + lastOnlineAt: new Date().toISOString(), + agentListReady: true, + }); + } catch (err) { + if (cancelled) { + return; + } + hasBootstrappedAgentUpdatesRef.current = false; + pendingAgentUpdatesRef.current.clear(); + agentUpdatesSubscriptionIdRef.current = null; + console.error("[Session] fetchAgents bootstrap failed", { serverId, err }); + } + }; + + void bootstrapAgentDirectory(); + + return () => { + cancelled = true; + }; + }, [ + connectionSnapshot.isConnected, + client, + serverId, + setAgentLastActivity, + setAgents, + setHasHydratedAgents, + setInitializingAgents, + setPendingPermissions, + updateConnectionStatus, + ]); // Daemon message handlers - directly update Zustand store useEffect(() => { diff --git a/packages/app/src/hooks/use-agent-commands-query.ts b/packages/app/src/hooks/use-agent-commands-query.ts index 3180ae15a..1d7e05708 100644 --- a/packages/app/src/hooks/use-agent-commands-query.ts +++ b/packages/app/src/hooks/use-agent-commands-query.ts @@ -1,5 +1,6 @@ import { useQuery } from "@tanstack/react-query"; import { useSessionStore } from "@/stores/session-store"; +import type { AgentProvider } from "@server/server/agent/agent-sdk-types"; const COMMANDS_STALE_TIME = 60_000; // Commands rarely change, cache for 1 minute @@ -9,20 +10,43 @@ interface AgentSlashCommand { argumentHint: string; } -function commandsQueryKey(serverId: string, agentId: string) { - return ["agentCommands", serverId, agentId] as const; +export interface DraftCommandConfig { + provider: AgentProvider; + cwd: string; + modeId?: string; + model?: string; + thinkingOptionId?: string; +} + +function commandsQueryKey( + serverId: string, + agentId: string, + draftConfig?: DraftCommandConfig +) { + return [ + "agentCommands", + serverId, + agentId, + draftConfig?.provider ?? null, + draftConfig?.cwd ?? null, + draftConfig?.modeId ?? null, + draftConfig?.model ?? null, + draftConfig?.thinkingOptionId ?? null, + ] as const; } interface UseAgentCommandsQueryOptions { serverId: string; agentId: string; enabled?: boolean; + draftConfig?: DraftCommandConfig; } export function useAgentCommandsQuery({ serverId, agentId, enabled = true, + draftConfig, }: UseAgentCommandsQueryOptions) { const client = useSessionStore( (state) => state.sessions[serverId]?.client ?? null @@ -32,12 +56,12 @@ export function useAgentCommandsQuery({ ); const query = useQuery({ - queryKey: commandsQueryKey(serverId, agentId), + queryKey: commandsQueryKey(serverId, agentId, draftConfig), queryFn: async () => { if (!client) { throw new Error("Daemon client not available"); } - const response = await client.listCommands(agentId); + const response = await client.listCommands(agentId, { draftConfig }); return response.commands as AgentSlashCommand[]; }, enabled: enabled && !!client && isConnected && !!agentId, diff --git a/packages/app/src/hooks/use-command-center.ts b/packages/app/src/hooks/use-command-center.ts index 93d70974b..be4ab751c 100644 --- a/packages/app/src/hooks/use-command-center.ts +++ b/packages/app/src/hooks/use-command-center.ts @@ -216,9 +216,6 @@ export function useCommandCenter() { const handleSelectAgent = useCallback( (agent: AggregatedAgent) => { didNavigateRef.current = true; - const session = useSessionStore.getState().sessions[agent.serverId]; - session?.client?.clearAgentAttention(agent.id); - const shouldReplace = Boolean(parseHostAgentRouteFromPathname(pathname)); const navigate = shouldReplace ? router.replace : router.push; diff --git a/packages/app/src/hooks/use-favicon-status.ts b/packages/app/src/hooks/use-favicon-status.ts index cb7497fe7..52431df35 100644 --- a/packages/app/src/hooks/use-favicon-status.ts +++ b/packages/app/src/hooks/use-favicon-status.ts @@ -1,5 +1,6 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Platform } from "react-native"; +import { getIsTauriMac } from "@/constants/layout"; import { useAggregatedAgents } from "./use-aggregated-agents"; type FaviconStatus = "none" | "running" | "attention"; @@ -34,6 +35,21 @@ function deriveFaviconStatus( return "none"; } +function deriveMacDockBadgeCount( + agents: ReturnType["agents"] +): number | undefined { + const attentionCount = agents.filter( + (agent) => + agent.requiresAttention && + (agent.attentionReason === "permission" || agent.attentionReason === "finished") + ).length; + if (attentionCount > 0) { + return attentionCount; + } + + return undefined; +} + function getFaviconUri(status: FaviconStatus, colorScheme: ColorScheme): string { const image = FAVICON_IMAGES[colorScheme][status]; if (typeof image === "object" && "uri" in image) { @@ -73,9 +89,25 @@ function getSystemColorScheme(): ColorScheme { return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; } +async function updateMacDockBadge(count?: number) { + if (Platform.OS !== "web" || typeof window === "undefined" || !getIsTauriMac()) return; + + const tauriWindow = (window as any).__TAURI__?.window?.getCurrentWindow?.(); + if (!tauriWindow || typeof tauriWindow.setBadgeCount !== "function") { + return; + } + + try { + await tauriWindow.setBadgeCount(count); + } catch (error) { + console.warn("[useFaviconStatus] Failed to update macOS dock badge", error); + } +} + export function useFaviconStatus() { const { agents } = useAggregatedAgents(); const [colorScheme, setColorScheme] = useState(getSystemColorScheme); + const lastDockBadgeCountRef = useRef(undefined); // Listen for system color scheme changes useEffect(() => { @@ -96,5 +128,11 @@ export function useFaviconStatus() { const status = deriveFaviconStatus(agents); updateFavicon(status, colorScheme); + + const dockBadgeCount = deriveMacDockBadgeCount(agents); + if (dockBadgeCount !== lastDockBadgeCountRef.current) { + lastDockBadgeCountRef.current = dockBadgeCount; + void updateMacDockBadge(dockBadgeCount); + } }, [agents, colorScheme]); } diff --git a/packages/app/src/hooks/use-file-drop-zone.ts b/packages/app/src/hooks/use-file-drop-zone.ts index dbfd68b3b..b58e93e0a 100644 --- a/packages/app/src/hooks/use-file-drop-zone.ts +++ b/packages/app/src/hooks/use-file-drop-zone.ts @@ -1,4 +1,4 @@ -import { useState, useCallback, useRef, useEffect } from "react"; +import { useState, useRef, useEffect } from "react"; import { Platform } from "react-native"; import type { ImageAttachment } from "@/components/message-input"; @@ -13,11 +13,75 @@ interface UseFileDropZoneReturn { } const IS_WEB = Platform.OS === "web"; +const IMAGE_MIME_BY_EXTENSION: Record = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".bmp": "image/bmp", + ".svg": "image/svg+xml", + ".heic": "image/heic", + ".heif": "image/heif", + ".avif": "image/avif", + ".tif": "image/tiff", + ".tiff": "image/tiff", +}; + +type TauriDragDropPayload = + | { + type: "enter"; + paths: string[]; + } + | { + type: "over"; + } + | { + type: "drop"; + paths: string[]; + } + | { + type: "leave"; + }; + +type TauriDragDropEvent = { + payload: TauriDragDropPayload; +}; function isImageFile(file: File): boolean { return file.type.startsWith("image/"); } +function isTauriEnvironment(): boolean { + return typeof window !== "undefined" && (window as any).__TAURI__ !== undefined; +} + +function getFileExtension(path: string): string { + const normalizedPath = path.split("#", 1)[0]?.split("?", 1)[0] ?? path; + const extensionIndex = normalizedPath.lastIndexOf("."); + if (extensionIndex < 0) { + return ""; + } + return normalizedPath.slice(extensionIndex).toLowerCase(); +} + +function isImagePath(path: string): boolean { + return getFileExtension(path) in IMAGE_MIME_BY_EXTENSION; +} + +function filePathToImageAttachment(path: string): ImageAttachment { + const extension = getFileExtension(path); + const mimeType = IMAGE_MIME_BY_EXTENSION[extension] ?? "image/jpeg"; + const convertFileSrc = (window as any).__TAURI__?.core?.convertFileSrc; + const uri = + typeof convertFileSrc === "function" ? convertFileSrc(path) : path; + + return { + uri, + mimeType, + }; +} + async function fileToImageAttachment(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); @@ -62,78 +126,167 @@ export function useFileDropZone({ useEffect(() => { if (!IS_WEB) return; - const element = containerRef.current; - if (!element) return; + let disposed = false; + let cleanup: (() => void) | undefined; + let didCleanup = false; - function handleDragEnter(e: DragEvent) { - e.preventDefault(); - e.stopPropagation(); - - if (disabled) return; - - dragCounterRef.current++; - if (e.dataTransfer?.types.includes("Files")) { - setIsDragging(true); + function runCleanup(unlisten?: () => void | Promise) { + if (didCleanup) return; + const cleanupFn = unlisten ?? cleanup; + if (!cleanupFn) return; + didCleanup = true; + try { + void Promise.resolve(cleanupFn()).catch((error) => { + console.warn("[useFileDropZone] Failed to remove Tauri drag-drop listener:", error); + }); + } catch (error) { + console.warn("[useFileDropZone] Failed to remove Tauri drag-drop listener:", error); } } - function handleDragOver(e: DragEvent) { - e.preventDefault(); - e.stopPropagation(); - - if (disabled) return; - - if (e.dataTransfer) { - e.dataTransfer.dropEffect = "copy"; + async function setupTauriDragDrop(): Promise { + if (!isTauriEnvironment()) { + return false; } - } - function handleDragLeave(e: DragEvent) { - e.preventDefault(); - e.stopPropagation(); - - if (disabled) return; - - dragCounterRef.current--; - if (dragCounterRef.current === 0) { - setIsDragging(false); + const tauriWindow = (window as any).__TAURI__?.window?.getCurrentWindow?.(); + if (!tauriWindow || typeof tauriWindow.onDragDropEvent !== "function") { + return false; } - } - - async function handleDrop(e: DragEvent) { - e.preventDefault(); - e.stopPropagation(); - - setIsDragging(false); - dragCounterRef.current = 0; - - if (disabled) return; - - const files = Array.from(e.dataTransfer?.files ?? []); - const imageFiles = files.filter(isImageFile); - - if (imageFiles.length === 0) return; try { - const attachments = await Promise.all( - imageFiles.map(fileToImageAttachment) + const unlisten = await tauriWindow.onDragDropEvent( + (event: TauriDragDropEvent) => { + const payload = event.payload; + if (payload.type === "leave") { + setIsDragging(false); + return; + } + + if (payload.type === "enter" || payload.type === "over") { + if (!disabled) { + setIsDragging(true); + } + return; + } + + // Drop always ends the current drag operation. + setIsDragging(false); + + if (disabled) return; + + const imagePaths = payload.paths.filter(isImagePath); + if (imagePaths.length === 0) { + return; + } + + const attachments = imagePaths.map(filePathToImageAttachment); + onFilesDroppedRef.current(attachments); + } ); - onFilesDroppedRef.current(attachments); + + if (disposed) { + runCleanup(unlisten); + return true; + } + + cleanup = unlisten; + return true; } catch (error) { - console.error("[useFileDropZone] Failed to process dropped files:", error); + console.warn("[useFileDropZone] Failed to listen for Tauri drag-drop:", error); + return false; } } - element.addEventListener("dragenter", handleDragEnter); - element.addEventListener("dragover", handleDragOver); - element.addEventListener("dragleave", handleDragLeave); - element.addEventListener("drop", handleDrop); + function setupDomDragDrop() { + const element = containerRef.current; + if (!element) { + return; + } + + function handleDragEnter(e: DragEvent) { + e.preventDefault(); + e.stopPropagation(); + + if (disabled) return; + + dragCounterRef.current++; + if (e.dataTransfer?.types.includes("Files")) { + setIsDragging(true); + } + } + + function handleDragOver(e: DragEvent) { + e.preventDefault(); + e.stopPropagation(); + + if (disabled) return; + + if (e.dataTransfer) { + e.dataTransfer.dropEffect = "copy"; + } + } + + function handleDragLeave(e: DragEvent) { + e.preventDefault(); + e.stopPropagation(); + + if (disabled) return; + + dragCounterRef.current--; + if (dragCounterRef.current === 0) { + setIsDragging(false); + } + } + + async function handleDrop(e: DragEvent) { + e.preventDefault(); + e.stopPropagation(); + + setIsDragging(false); + dragCounterRef.current = 0; + + if (disabled) return; + + const files = Array.from(e.dataTransfer?.files ?? []); + const imageFiles = files.filter(isImageFile); + + if (imageFiles.length === 0) return; + + try { + const attachments = await Promise.all( + imageFiles.map(fileToImageAttachment) + ); + onFilesDroppedRef.current(attachments); + } catch (error) { + console.error("[useFileDropZone] Failed to process dropped files:", error); + } + } + + element.addEventListener("dragenter", handleDragEnter); + element.addEventListener("dragover", handleDragOver); + element.addEventListener("dragleave", handleDragLeave); + element.addEventListener("drop", handleDrop); + + cleanup = () => { + element.removeEventListener("dragenter", handleDragEnter); + element.removeEventListener("dragover", handleDragOver); + element.removeEventListener("dragleave", handleDragLeave); + element.removeEventListener("drop", handleDrop); + }; + } + + void (async () => { + const tauriListenersAttached = await setupTauriDragDrop(); + if (disposed || tauriListenersAttached) { + return; + } + setupDomDragDrop(); + })(); return () => { - element.removeEventListener("dragenter", handleDragEnter); - element.removeEventListener("dragover", handleDragOver); - element.removeEventListener("dragleave", handleDragLeave); - element.removeEventListener("drop", handleDrop); + disposed = true; + runCleanup(); }; }, [disabled]); diff --git a/packages/app/src/screens/agent/agent-ready-screen.tsx b/packages/app/src/screens/agent/agent-ready-screen.tsx index b2236af47..05c882c73 100644 --- a/packages/app/src/screens/agent/agent-ready-screen.tsx +++ b/packages/app/src/screens/agent/agent-ready-screen.tsx @@ -73,6 +73,7 @@ import { normalizeAgentSnapshot, } from "@/utils/agent-snapshots"; import { mergePendingCreateImages } from "@/utils/pending-create-images"; +import { shouldClearAgentAttentionOnView } from "@/utils/agent-attention"; import type { FetchAgentsEntry } from "@server/client/daemon-client"; import { DropdownMenu, @@ -514,6 +515,9 @@ function AgentScreenContent({ const isConnected = useSessionStore( (state) => state.sessions[serverId]?.connection.isConnected ?? false ); + const focusedAgentId = useSessionStore( + (state) => state.sessions[serverId]?.focusedAgentId ?? null + ); const { ensureAgentIsInitialized, refreshAgent } = useAgentInitialization(serverId); const [missingAgentState, setMissingAgentState] = useState({ kind: "idle", @@ -894,25 +898,30 @@ function AgentScreenContent({ document.title = title; }, [agent?.title]); - // Track previous agent status to detect completion while viewing - const previousStatusRef = useRef(null); - - // Clear attention when agent finishes while user is viewing this screen + // Clear attention as soon as the user is focused on this agent screen. useEffect(() => { - if (!resolvedAgentId || !agent || !client) { + const clearAgentId = resolvedAgentId?.trim(); + if (!clearAgentId || !client) { return; } - - const previousStatus = previousStatusRef.current; - const currentStatus = agent.status; - previousStatusRef.current = currentStatus; - - // If agent transitioned from running to idle while we're viewing, - // immediately clear attention since user witnessed the completion - if (previousStatus === "running" && currentStatus === "idle") { - client.clearAgentAttention(resolvedAgentId); + if ( + !shouldClearAgentAttentionOnView({ + agentId: clearAgentId, + focusedAgentId, + isConnected, + requiresAttention: agent?.requiresAttention, + }) + ) { + return; } - }, [resolvedAgentId, agent?.status, client]); + client.clearAgentAttention(clearAgentId); + }, [ + agent?.requiresAttention, + client, + focusedAgentId, + isConnected, + resolvedAgentId, + ]); const handleRefreshAgent = useCallback(() => { if (!resolvedAgentId) { diff --git a/packages/app/src/screens/agent/draft-agent-screen.tsx b/packages/app/src/screens/agent/draft-agent-screen.tsx index 0f47da548..feb26a8c9 100644 --- a/packages/app/src/screens/agent/draft-agent-screen.tsx +++ b/packages/app/src/screens/agent/draft-agent-screen.tsx @@ -14,8 +14,8 @@ import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyl import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller"; import Animated, { useAnimatedStyle, useSharedValue } from "react-native-reanimated"; -import { Folder, GitBranch, Menu, PanelLeft } from "lucide-react-native"; -import { HeaderToggleButton } from "@/components/headers/header-toggle-button"; +import { Folder, GitBranch } from "lucide-react-native"; +import { SidebarMenuToggle } from "@/components/headers/menu-header"; import { AgentInputArea } from "@/components/agent-input-area"; import { AgentStreamView } from "@/components/agent-stream-view"; import { AgentConfigRow, FormSelectTrigger } from "@/components/agent-form/agent-form-dropdowns"; @@ -23,15 +23,18 @@ import { Combobox } from "@/components/ui/combobox"; import { FileDropZone } from "@/components/file-drop-zone"; import { useQuery } from "@tanstack/react-query"; import { useAgentFormState, type CreateAgentInitialValues } from "@/hooks/use-agent-form-state"; +import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query"; import { CHECKOUT_STATUS_STALE_TIME, checkoutStatusQueryKey, } from "@/hooks/use-checkout-status-query"; +import { useAllAgentsList } from "@/hooks/use-all-agents-list"; import { useDaemonConnections } from "@/contexts/daemon-connections-context"; import { useDaemonRegistry } from "@/contexts/daemon-registry-context"; import { buildBranchComboOptions, normalizeBranchOptionName } from "@/utils/branch-suggestions"; import { shortenPath } from "@/utils/shorten-path"; -import { usePanelStore } from "@/stores/panel-store"; +import { collectAgentWorkingDirectorySuggestions } from "@/utils/agent-working-directory-suggestions"; +import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions"; import { useSessionStore } from "@/stores/session-store"; import { useCreateFlowStore } from "@/stores/create-flow-store"; import { MAX_CONTENT_WIDTH } from "@/constants/layout"; @@ -128,9 +131,6 @@ export function DraftAgentScreen({ const insets = useSafeAreaInsets(); const { connectionStates } = useDaemonConnections(); const { daemons } = useDaemonRegistry(); - const mobileView = usePanelStore((state) => state.mobileView); - const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen); - const toggleAgentList = usePanelStore((state) => state.toggleAgentList); const params = useLocalSearchParams(); const { height: keyboardHeight } = useReanimatedKeyboardAnimation(); @@ -229,11 +229,6 @@ export function DraftAgentScreen({ : undefined; const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; - const isSidebarOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen; - const SidebarIcon = isMobile ? Menu : PanelLeft; - const sidebarIconColor = !isMobile && isSidebarOpen - ? theme.colors.foreground - : theme.colors.foregroundMuted; const [worktreeMode, setWorktreeMode] = useState<"none" | "create" | "attach">("none"); const [baseBranch, setBaseBranch] = useState(""); @@ -244,6 +239,8 @@ export function DraftAgentScreen({ const [isBranchOpen, setIsBranchOpen] = useState(false); const [branchSearchQuery, setBranchSearchQuery] = useState(""); const [debouncedBranchSearchQuery, setDebouncedBranchSearchQuery] = useState(""); + const [workingDirSearchQuery, setWorkingDirSearchQuery] = useState(""); + const [debouncedWorkingDirSearchQuery, setDebouncedWorkingDirSearchQuery] = useState(""); const workingDirAnchorRef = useRef(null); const worktreeAnchorRef = useRef(null); const branchAnchorRef = useRef(null); @@ -258,6 +255,12 @@ export function DraftAgentScreen({ return () => clearTimeout(timer); }, [branchSearchQuery]); + useEffect(() => { + const trimmed = workingDirSearchQuery.trim(); + const timer = setTimeout(() => setDebouncedWorkingDirSearchQuery(trimmed), 180); + return () => clearTimeout(timer); + }, [workingDirSearchQuery]); + type CreateAttempt = { messageId: string; text: string; @@ -320,6 +323,7 @@ export function DraftAgentScreen({ const sessionAgents = useSessionStore((state) => selectedServerId ? state.sessions[selectedServerId]?.agents : undefined ); + const { agents: allAgents } = useAllAgentsList({ serverId: selectedServerId }); const worktreePathLastCreatedAt = useMemo(() => { const map = new Map(); if (!sessionAgents) { @@ -338,24 +342,23 @@ export function DraftAgentScreen({ return map; }, [sessionAgents]); const agentWorkingDirSuggestions = useMemo(() => { - if (!selectedServerId || !sessionAgents) { - return []; - } - const pathLastCreated = new Map(); - sessionAgents.forEach((agent) => { - if (agent.cwd && !agent.cwd.includes(".paseo/worktrees")) { - const existing = pathLastCreated.get(agent.cwd); - if (!existing || agent.createdAt > existing) { - pathLastCreated.set(agent.cwd, agent.createdAt); - } - } - }); - return Array.from(pathLastCreated.keys()).sort((a, b) => { - const aTime = pathLastCreated.get(a)!.getTime(); - const bTime = pathLastCreated.get(b)!.getTime(); - return bTime - aTime; - }); - }, [selectedServerId, sessionAgents]); + const liveSources = sessionAgents + ? Array.from(sessionAgents.values()).map((agent) => ({ + cwd: agent.cwd, + createdAt: agent.createdAt, + lastActivityAt: agent.lastActivityAt, + })) + : []; + const fetchedSources = allAgents.map((agent) => ({ + cwd: agent.cwd, + lastActivityAt: agent.lastActivityAt, + })); + + return collectAgentWorkingDirectorySuggestions([ + ...liveSources, + ...fetchedSources, + ]); + }, [allAgents, sessionAgents]); const sessionClient = useSessionStore((state) => selectedServerId ? state.sessions[selectedServerId]?.client ?? null : null @@ -524,6 +527,36 @@ export function DraftAgentScreen({ staleTime: 15_000, }); + const directorySuggestionsQuery = useQuery({ + queryKey: [ + "directorySuggestions", + selectedServerId, + debouncedWorkingDirSearchQuery, + ], + queryFn: async () => { + const client = sessionClient; + if (!client) { + throw new Error("Daemon client unavailable"); + } + const payload = await client.getDirectorySuggestions({ + query: debouncedWorkingDirSearchQuery, + limit: 50, + }); + if (payload.error) { + throw new Error(payload.error); + } + return payload.directories ?? []; + }, + enabled: + Boolean(debouncedWorkingDirSearchQuery) && + Boolean(selectedServerId) && + !daemonAvailabilityError && + Boolean(sessionClient) && + isConnected, + retry: false, + staleTime: 15_000, + }); + const validateWorktreeName = useCallback( (name: string): { valid: boolean; error?: string } => { if (!name) { @@ -657,6 +690,50 @@ export function DraftAgentScreen({ const selectedWorktreeLabel = worktreeOptions.find((option) => option.path === selectedWorktreePath)?.label ?? ""; + const hasWorkingDirectorySearch = debouncedWorkingDirSearchQuery.length > 0; + const workingDirSearchError = + directorySuggestionsQuery.error instanceof Error + ? directorySuggestionsQuery.error.message + : null; + const workingDirSuggestionPaths = useMemo( + () => + buildWorkingDirectorySuggestions({ + recommendedPaths: agentWorkingDirSuggestions, + serverPaths: hasWorkingDirectorySearch ? (directorySuggestionsQuery.data ?? []) : [], + query: workingDirSearchQuery, + }), + [ + agentWorkingDirSuggestions, + directorySuggestionsQuery.data, + hasWorkingDirectorySearch, + workingDirSearchQuery, + ] + ); + const workingDirComboOptions = useMemo( + () => + workingDirSuggestionPaths.map((path) => ({ + id: path, + label: shortenPath(path), + kind: "directory" as const, + })), + [workingDirSuggestionPaths] + ); + const workingDirEmptyText = useMemo(() => { + if (hasWorkingDirectorySearch) { + if (workingDirSearchError) { + return "Failed to search directories on this host."; + } + return "No directories match your search."; + } + + return agentWorkingDirSuggestions.length > 0 + ? "No agent directories match your search." + : "We'll suggest directories from agents on this host once they exist."; + }, [ + agentWorkingDirSuggestions.length, + hasWorkingDirectorySearch, + workingDirSearchError, + ]); const displayWorkingDir = shortenPath(workingDir); const worktreeTriggerValue = worktreeMode === "create" @@ -716,6 +793,33 @@ export function DraftAgentScreen({ const createAgentClient = useSessionStore((state) => selectedServerId ? state.sessions[selectedServerId]?.client ?? null : null ); + const draftCommandConfig = useMemo(() => { + const cwd = ( + isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir + ).trim(); + if (!cwd) { + return undefined; + } + + return { + provider: selectedProvider, + cwd, + ...(modeOptions.length > 0 && selectedMode !== "" ? { modeId: selectedMode } : {}), + ...(selectedModel.trim() ? { model: selectedModel.trim() } : {}), + ...(selectedThinkingOptionId.trim() + ? { thinkingOptionId: selectedThinkingOptionId.trim() } + : {}), + }; + }, [ + isAttachWorktree, + modeOptions.length, + selectedMode, + selectedModel, + selectedProvider, + selectedThinkingOptionId, + selectedWorktreePath, + workingDir, + ]); const promptValue = machine.tag === "draft" ? machine.promptText : ""; const formErrorMessage = machine.tag === "draft" ? machine.errorMessage : ""; @@ -988,20 +1092,7 @@ export function DraftAgentScreen({ isMobile ? { paddingTop: insets.top + theme.spacing[2] } : null, ]} > - - - + @@ -1133,21 +1224,16 @@ export function DraftAgentScreen({ /> ({ - id: path, - label: shortenPath(path), - }))} + options={workingDirComboOptions} value={workingDir} onSelect={setWorkingDirFromUser} - searchPlaceholder="/path/to/project" - emptyText={ - agentWorkingDirSuggestions.length > 0 - ? "No agent directories match your search." - : "We'll suggest directories from agents on this host once they exist." - } + onSearchQueryChange={setWorkingDirSearchQuery} + searchPlaceholder="Search directories..." + emptyText={workingDirEmptyText} allowCustomValue - customValuePrefix="Use" - customValueDescription="Launch the agent in this directory" + customValuePrefix="" + customValueKind="directory" + optionsPosition="above-search" title="Working directory" open={isWorkingDirOpen} onOpenChange={setIsWorkingDirOpen} @@ -1193,6 +1279,7 @@ export function DraftAgentScreen({ onChangeText={(next) => dispatch({ type: "DRAFT_SET_PROMPT", text: next })} autoFocus={machine.tag === "draft"} onAddImages={handleAddImagesCallback} + commandDraftConfig={draftCommandConfig} /> diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 5b06afe07..9c8764a17 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -19,6 +19,7 @@ import { useDaemonRegistry, type HostProfile, type HostConnection } from "@/cont import { useDaemonConnections, type ActiveConnection, type ConnectionStatus } from "@/contexts/daemon-connections-context"; import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons"; import { measureConnectionLatency } from "@/utils/test-daemon-connection"; +import { confirmDialog } from "@/utils/confirm-dialog"; import { theme as defaultTheme } from "@/styles/theme"; import { MenuHeader } from "@/components/headers/menu-header"; import { useSessionStore } from "@/stores/session-store"; @@ -76,7 +77,6 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.xs, fontWeight: theme.fontWeight.normal, letterSpacing: 0.6, - textTransform: "uppercase", marginBottom: theme.spacing[3], marginLeft: theme.spacing[1], }, @@ -983,29 +983,21 @@ function HostDetailModal({ return; } - if (Platform.OS === "web") { - const hasBrowserConfirm = - typeof globalThis !== "undefined" && - typeof (globalThis as any).confirm === "function"; - - const confirmed = hasBrowserConfirm - ? (globalThis as any).confirm(`Restart ${host.label}? ${restartConfirmationMessage}`) - : true; - - if (confirmed) { - beginServerRestart(); + void confirmDialog({ + title: `Restart ${host.label}`, + message: restartConfirmationMessage, + confirmLabel: "Restart", + cancelLabel: "Cancel", + destructive: true, + }).then((confirmed) => { + if (!confirmed) { + return; } - return; - } - - Alert.alert(`Restart ${host.label}`, restartConfirmationMessage, [ - { text: "Cancel", style: "cancel" }, - { - text: "Restart", - style: "destructive", - onPress: beginServerRestart, - }, - ]); + beginServerRestart(); + }).catch((error) => { + console.error(`[Settings] Failed to open restart confirmation for ${host.label}`, error); + Alert.alert("Error", "Unable to open the restart confirmation dialog."); + }); }, [beginServerRestart, daemonClient, host, restartConfirmationMessage]); // Status display diff --git a/packages/app/src/stores/download-store.ts b/packages/app/src/stores/download-store.ts index 16f3b13ee..ec884ee9b 100644 --- a/packages/app/src/stores/download-store.ts +++ b/packages/app/src/stores/download-store.ts @@ -5,6 +5,7 @@ import * as LegacyFileSystem from "expo-file-system/legacy"; import * as Sharing from "expo-sharing"; import type { HostProfile } from "@/contexts/daemon-registry-context"; import { buildDaemonWebSocketUrl } from "@/utils/daemon-endpoints"; +import { openExternalUrl } from "@/utils/open-external-url"; interface DownloadProgress { percent: number; @@ -303,7 +304,7 @@ function buildDownloadUrl( function triggerBrowserDownload(url: string, fileName: string) { if (typeof document === "undefined") { if (typeof window !== "undefined") { - window.open(url, "_blank", "noopener"); + void openExternalUrl(url); } return; } diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts new file mode 100644 index 000000000..30f599dcb --- /dev/null +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts @@ -0,0 +1,174 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { TerminalEmulatorRuntime } from "./terminal-emulator-runtime"; + +type StubTerminal = { + write: (text: string, callback?: () => void) => void; + reset: () => void; + focus: () => void; +}; + +function createRuntimeWithTerminal(): { + runtime: TerminalEmulatorRuntime; + terminal: StubTerminal & { + resetCalls: number; + }; + writeCallbacks: Array<() => void>; + writeTexts: string[]; +} { + const runtime = new TerminalEmulatorRuntime(); + const writeCallbacks: Array<() => void> = []; + const writeTexts: string[] = []; + let resetCalls = 0; + + const terminal: StubTerminal & { resetCalls: number } = { + write: (text: string, callback?: () => void) => { + writeTexts.push(text); + if (callback) { + writeCallbacks.push(callback); + } + }, + reset: () => { + resetCalls += 1; + terminal.resetCalls = resetCalls; + }, + focus: () => {}, + resetCalls, + }; + + (runtime as unknown as { terminal: StubTerminal }).terminal = terminal; + + return { + runtime, + terminal, + writeCallbacks, + writeTexts, + }; +} + +describe("terminal-emulator-runtime", () => { + const originalWindow = (globalThis as { window?: unknown }).window; + + beforeEach(() => { + (globalThis as { window?: { __paseoTerminal?: unknown } }).window = { + __paseoTerminal: undefined, + }; + }); + + afterEach(() => { + (globalThis as { window?: unknown }).window = originalWindow; + vi.useRealTimers(); + }); + + it("processes write and clear operations in strict order", () => { + const { runtime, terminal, writeCallbacks, writeTexts } = createRuntimeWithTerminal(); + const committed: string[] = []; + + runtime.write({ + text: "first", + onCommitted: () => { + committed.push("first"); + }, + }); + runtime.clear({ + onCommitted: () => { + committed.push("clear"); + }, + }); + runtime.write({ + text: "second", + onCommitted: () => { + committed.push("second"); + }, + }); + + expect(writeTexts).toEqual(["first"]); + expect(terminal.resetCalls).toBe(0); + expect(committed).toEqual([]); + + writeCallbacks[0]?.(); + + expect(committed).toEqual(["first", "clear"]); + expect(terminal.resetCalls).toBe(1); + expect(writeTexts).toEqual(["first", "second"]); + + writeCallbacks[1]?.(); + expect(committed).toEqual(["first", "clear", "second"]); + }); + + it("falls back to timeout commit when xterm write callback does not fire", () => { + vi.useFakeTimers(); + const { runtime } = createRuntimeWithTerminal(); + const onCommitted = vi.fn(); + + runtime.write({ + text: "stuck", + onCommitted, + }); + + expect(onCommitted).not.toHaveBeenCalled(); + vi.advanceTimersByTime(5_000); + expect(onCommitted).toHaveBeenCalledTimes(1); + }); + + it("ignores stale duplicate write callbacks from a previous operation", () => { + const { runtime, writeCallbacks } = createRuntimeWithTerminal(); + const committed: string[] = []; + + runtime.write({ + text: "first", + onCommitted: () => { + committed.push("first"); + }, + }); + runtime.write({ + text: "second", + onCommitted: () => { + committed.push("second"); + }, + }); + + writeCallbacks[0]?.(); + expect(committed).toEqual(["first"]); + + writeCallbacks[0]?.(); + expect(committed).toEqual(["first"]); + + writeCallbacks[1]?.(); + expect(committed).toEqual(["first", "second"]); + }); + + it("commits pending output operations during unmount to avoid deadlock", () => { + const { runtime } = createRuntimeWithTerminal(); + const onCommittedA = vi.fn(); + const onCommittedB = vi.fn(); + + runtime.write({ + text: "a", + onCommitted: onCommittedA, + }); + runtime.write({ + text: "b", + onCommitted: onCommittedB, + }); + + runtime.unmount(); + + expect(onCommittedA).toHaveBeenCalledTimes(1); + expect(onCommittedB).toHaveBeenCalledTimes(1); + }); + + it("forces a refit when resize is requested", () => { + const runtime = new TerminalEmulatorRuntime(); + const fitAndEmitResize = vi.fn(); + + (runtime as unknown as { fitAndEmitResize: (force: boolean) => void }).fitAndEmitResize = + fitAndEmitResize; + + runtime.resize(); + runtime.resize({ force: true }); + + expect(fitAndEmitResize).toHaveBeenNthCalledWith(1, false); + expect(fitAndEmitResize).toHaveBeenNthCalledWith(2, true); + }); +}); diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts index 8ca94ae55..dfe09ed24 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts @@ -51,6 +51,12 @@ type TerminalEmulatorRuntimeDisposables = { disposeTerminal: () => void; }; +type TerminalOutputOperation = { + type: "write" | "clear"; + text: string; + onCommitted?: () => void; +}; + declare global { interface Window { __paseoTerminal?: Terminal; @@ -59,6 +65,7 @@ declare global { const DEFAULT_TOUCH_SCROLL_LINE_HEIGHT_PX = 18; const FIT_TIMEOUT_DELAYS_MS = [0, 16, 48, 120, 250, 500, 1_000, 2_000]; +const OUTPUT_OPERATION_TIMEOUT_MS = 5_000; export class TerminalEmulatorRuntime { private callbacks: TerminalEmulatorRuntimeCallbacks = {}; @@ -69,10 +76,12 @@ export class TerminalEmulatorRuntime { }; private terminal: Terminal | null = null; private fitAddon: FitAddon | null = null; + private fitAndEmitResize: ((force: boolean) => void) | null = null; private lastSize: { rows: number; cols: number } | null = null; private cleanup: (() => void) | null = null; - private pendingWriteText = ""; - private isWriteFlushQueued = false; + private outputOperations: TerminalOutputOperation[] = []; + private inFlightOutputOperation: TerminalOutputOperation | null = null; + private inFlightOutputOperationTimeout: ReturnType | null = null; setCallbacks(input: { callbacks: TerminalEmulatorRuntimeCallbacks }): void { this.callbacks = input.callbacks; @@ -160,6 +169,7 @@ export class TerminalEmulatorRuntime { cols: nextCols, }); }; + this.fitAndEmitResize = fitAndEmitResize; fitAndEmitResize(true); @@ -287,6 +297,8 @@ export class TerminalEmulatorRuntime { }); } + this.processOutputQueue(); + const disposables: TerminalEmulatorRuntimeDisposables = { disposeInput: () => { inputDisposable.dispose(); @@ -338,30 +350,43 @@ export class TerminalEmulatorRuntime { }; } - write(input: { text: string }): void { - if (!this.terminal || input.text.length === 0) { + write(input: { text: string; onCommitted?: () => void }): void { + if (input.text.length === 0) { + input.onCommitted?.(); return; } - this.pendingWriteText += input.text; + this.outputOperations.push({ + type: "write", + text: input.text, + ...(input.onCommitted ? { onCommitted: input.onCommitted } : {}), + }); terminalDebugLog({ scope: "emulator-runtime", event: "output:enqueue", details: { chunkLength: input.text.length, - queueLength: this.pendingWriteText.length, + queueLength: this.outputOperations.length, preview: summarizeTerminalText({ text: input.text, maxChars: 64 }), }, }); - this.scheduleWriteFlush(); + this.processOutputQueue(); } - clear(): void { - this.pendingWriteText = ""; + clear(input?: { onCommitted?: () => void }): void { + this.outputOperations.push({ + type: "clear", + text: "", + ...(input?.onCommitted ? { onCommitted: input.onCommitted } : {}), + }); terminalDebugLog({ scope: "emulator-runtime", event: "output:clear", }); - this.terminal?.reset(); + this.processOutputQueue(); + } + + resize(input?: { force?: boolean }): void { + this.fitAndEmitResize?.(input?.force ?? false); } focus(): void { @@ -373,11 +398,19 @@ export class TerminalEmulatorRuntime { scope: "emulator-runtime", event: "mount:unmount", details: { - pendingWriteLength: this.pendingWriteText.length, + pendingWriteOperations: this.outputOperations.length, }, }); - this.pendingWriteText = ""; - this.isWriteFlushQueued = false; + this.clearInFlightOutputTimeout(); + const inFlightOperation = this.inFlightOutputOperation; + this.inFlightOutputOperation = null; + if (inFlightOperation?.onCommitted) { + inFlightOperation.onCommitted(); + } + const pendingOperations = this.outputOperations.splice(0, this.outputOperations.length); + for (const operation of pendingOperations) { + operation.onCommitted?.(); + } this.cleanup?.(); this.cleanup = null; @@ -386,27 +419,43 @@ export class TerminalEmulatorRuntime { } this.terminal = null; this.fitAddon = null; + this.fitAndEmitResize = null; this.lastSize = null; } - private scheduleWriteFlush(): void { - if (this.isWriteFlushQueued) { + private processOutputQueue(): void { + if (this.inFlightOutputOperation) { return; } - this.isWriteFlushQueued = true; - queueMicrotask(() => { - this.isWriteFlushQueued = false; - this.flushWriteQueue(); - }); - } - - private flushWriteQueue(): void { - if (!this.terminal || this.pendingWriteText.length === 0) { + const terminal = this.terminal; + if (!terminal) { return; } - const text = this.pendingWriteText; - this.pendingWriteText = ""; + + const operation = this.outputOperations.shift(); + if (!operation) { + return; + } + + this.inFlightOutputOperation = operation; + const finalizeOperation = (expectedOperation: TerminalOutputOperation) => { + if (this.inFlightOutputOperation !== expectedOperation) { + return; + } + this.inFlightOutputOperation = null; + this.clearInFlightOutputTimeout(); + expectedOperation.onCommitted?.(); + this.processOutputQueue(); + }; + + if (operation.type === "clear") { + terminal.reset(); + finalizeOperation(operation); + return; + } + + const text = operation.text; terminalDebugLog({ scope: "emulator-runtime", event: "output:flush", @@ -415,7 +464,25 @@ export class TerminalEmulatorRuntime { preview: summarizeTerminalText({ text, maxChars: 96 }), }, }); - this.terminal.write(text); + this.inFlightOutputOperationTimeout = setTimeout(() => { + finalizeOperation(operation); + }, OUTPUT_OPERATION_TIMEOUT_MS); + + try { + terminal.write(text, () => { + finalizeOperation(operation); + }); + } catch { + finalizeOperation(operation); + } + } + + private clearInFlightOutputTimeout(): void { + if (!this.inFlightOutputOperationTimeout) { + return; + } + clearTimeout(this.inFlightOutputOperationTimeout); + this.inFlightOutputOperationTimeout = null; } private applyDocumentBoundsStyles(input: { root: HTMLDivElement }): () => void { diff --git a/packages/app/src/terminal/runtime/terminal-output-delivery-queue.test.ts b/packages/app/src/terminal/runtime/terminal-output-delivery-queue.test.ts new file mode 100644 index 000000000..d901e7e4c --- /dev/null +++ b/packages/app/src/terminal/runtime/terminal-output-delivery-queue.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it, vi } from "vitest"; + +import { TerminalOutputDeliveryQueue } from "./terminal-output-delivery-queue"; + +describe("terminal-output-delivery-queue", () => { + it("retries in-flight delivery when consume is missing", () => { + vi.useFakeTimers(); + const delivered: Array<{ sequence: number; text: string }> = []; + const queue = new TerminalOutputDeliveryQueue({ + onDeliver: (chunk) => { + delivered.push(chunk); + }, + deliveryTimeoutMs: 100, + }); + + queue.enqueue({ sequence: 1, text: "a" }); + queue.enqueue({ sequence: 2, text: "b" }); + + expect(delivered).toEqual([{ sequence: 1, text: "a" }]); + + vi.advanceTimersByTime(100); + expect(delivered).toEqual([ + { sequence: 1, text: "a" }, + { sequence: 1, text: "a" }, + ]); + + queue.consume({ sequence: 1 }); + expect(delivered).toEqual([ + { sequence: 1, text: "a" }, + { sequence: 1, text: "a" }, + { sequence: 2, text: "b" }, + ]); + + vi.useRealTimers(); + }); + + it("delivers first chunk immediately and blocks later chunks until consumed", () => { + const delivered: Array<{ sequence: number; text: string }> = []; + const queue = new TerminalOutputDeliveryQueue({ + onDeliver: (chunk) => { + delivered.push(chunk); + }, + }); + + queue.enqueue({ sequence: 1, text: "a" }); + queue.enqueue({ sequence: 2, text: "b" }); + queue.enqueue({ sequence: 3, text: "c" }); + + expect(delivered).toEqual([{ sequence: 1, text: "a" }]); + + queue.consume({ sequence: 1 }); + expect(delivered).toEqual([ + { sequence: 1, text: "a" }, + { sequence: 3, text: "bc" }, + ]); + }); + + it("ignores stale consume acknowledgements", () => { + const delivered = vi.fn(); + const queue = new TerminalOutputDeliveryQueue({ onDeliver: delivered }); + + queue.enqueue({ sequence: 1, text: "x" }); + queue.consume({ sequence: 99 }); + queue.enqueue({ sequence: 2, text: "y" }); + + expect(delivered).toHaveBeenCalledTimes(1); + expect(delivered).toHaveBeenNthCalledWith(1, { sequence: 1, text: "x" }); + + queue.consume({ sequence: 1 }); + + expect(delivered).toHaveBeenCalledTimes(2); + expect(delivered).toHaveBeenNthCalledWith(2, { sequence: 2, text: "y" }); + }); + + it("resets in-flight and pending chunks", () => { + const delivered: Array<{ sequence: number; text: string }> = []; + const queue = new TerminalOutputDeliveryQueue({ + onDeliver: (chunk) => { + delivered.push(chunk); + }, + }); + + queue.enqueue({ sequence: 1, text: "hello" }); + queue.enqueue({ sequence: 2, text: " world" }); + + queue.reset(); + queue.enqueue({ sequence: 3, text: "next" }); + + expect(delivered).toEqual([ + { sequence: 1, text: "hello" }, + { sequence: 3, text: "next" }, + ]); + }); + + it("preserves empty chunk payloads for authoritative clears", () => { + const delivered: Array<{ sequence: number; text: string }> = []; + const queue = new TerminalOutputDeliveryQueue({ + onDeliver: (chunk) => { + delivered.push(chunk); + }, + }); + + queue.enqueue({ sequence: 1, text: "abc" }); + queue.consume({ sequence: 1 }); + queue.enqueue({ sequence: 2, text: "" }); + + expect(delivered).toEqual([ + { sequence: 1, text: "abc" }, + { sequence: 2, text: "" }, + ]); + }); + + it("treats clear chunks as a delivery barrier and drops pending stale text", () => { + const delivered: Array<{ sequence: number; text: string }> = []; + const queue = new TerminalOutputDeliveryQueue({ + onDeliver: (chunk) => { + delivered.push(chunk); + }, + }); + + queue.enqueue({ sequence: 1, text: "a" }); + queue.enqueue({ sequence: 2, text: "b" }); + queue.enqueue({ sequence: 3, text: "" }); + queue.enqueue({ sequence: 4, text: "c" }); + + expect(delivered).toEqual([{ sequence: 1, text: "a" }]); + + queue.consume({ sequence: 1 }); + expect(delivered).toEqual([ + { sequence: 1, text: "a" }, + { sequence: 3, text: "" }, + ]); + + queue.consume({ sequence: 3 }); + expect(delivered).toEqual([ + { sequence: 1, text: "a" }, + { sequence: 3, text: "" }, + { sequence: 4, text: "c" }, + ]); + }); +}); diff --git a/packages/app/src/terminal/runtime/terminal-output-delivery-queue.ts b/packages/app/src/terminal/runtime/terminal-output-delivery-queue.ts new file mode 100644 index 000000000..c6f89ab0e --- /dev/null +++ b/packages/app/src/terminal/runtime/terminal-output-delivery-queue.ts @@ -0,0 +1,151 @@ +import { summarizeTerminalText, terminalDebugLog } from "./terminal-debug"; + +export type TerminalOutputDeliveryChunk = { + sequence: number; + text: string; +}; + +export type TerminalOutputDeliveryQueueOptions = { + onDeliver: (chunk: TerminalOutputDeliveryChunk) => void; + deliveryTimeoutMs?: number; +}; + +const DEFAULT_DELIVERY_TIMEOUT_MS = 8_000; + +export class TerminalOutputDeliveryQueue { + private readonly pendingChunks: TerminalOutputDeliveryChunk[] = []; + private inFlightChunk: TerminalOutputDeliveryChunk | null = null; + private inFlightTimeout: ReturnType | null = null; + private lastSeenSequence = 0; + private readonly deliveryTimeoutMs: number; + + constructor(private readonly options: TerminalOutputDeliveryQueueOptions) { + this.deliveryTimeoutMs = + options.deliveryTimeoutMs ?? DEFAULT_DELIVERY_TIMEOUT_MS; + } + + enqueue(chunk: TerminalOutputDeliveryChunk): void { + if (chunk.sequence <= 0) { + return; + } + if (chunk.sequence <= this.lastSeenSequence) { + return; + } + this.lastSeenSequence = chunk.sequence; + + if (chunk.text.length === 0) { + this.pendingChunks.length = 0; + this.pendingChunks.push(chunk); + terminalDebugLog({ + scope: "output-delivery-queue", + event: "enqueue:clear", + details: { + sequence: chunk.sequence, + }, + }); + this.tryDeliver(); + return; + } + + const lastPendingChunk = this.pendingChunks[this.pendingChunks.length - 1]; + if (lastPendingChunk && lastPendingChunk.text.length > 0) { + lastPendingChunk.sequence = chunk.sequence; + lastPendingChunk.text += chunk.text; + } else { + this.pendingChunks.push(chunk); + } + terminalDebugLog({ + scope: "output-delivery-queue", + event: "enqueue:text", + details: { + sequence: chunk.sequence, + pendingCount: this.pendingChunks.length, + textLength: chunk.text.length, + preview: summarizeTerminalText({ text: chunk.text, maxChars: 80 }), + }, + }); + this.tryDeliver(); + } + + consume(input: { sequence: number }): void { + if (this.inFlightChunk?.sequence !== input.sequence) { + return; + } + + terminalDebugLog({ + scope: "output-delivery-queue", + event: "consume", + details: { + sequence: input.sequence, + }, + }); + this.clearInFlightTimeout(); + this.inFlightChunk = null; + this.tryDeliver(); + } + + reset(): void { + this.clearInFlightTimeout(); + this.pendingChunks.length = 0; + this.inFlightChunk = null; + this.lastSeenSequence = 0; + } + + private tryDeliver(): void { + if (this.inFlightChunk) { + return; + } + const nextChunk = this.pendingChunks.shift(); + if (!nextChunk) { + return; + } + + this.inFlightChunk = nextChunk; + terminalDebugLog({ + scope: "output-delivery-queue", + event: "deliver:start", + details: { + sequence: nextChunk.sequence, + pendingCount: this.pendingChunks.length, + }, + }); + this.deliverInFlightChunk(); + } + + private deliverInFlightChunk(): void { + const chunk = this.inFlightChunk; + if (!chunk) { + return; + } + + this.clearInFlightTimeout(); + this.inFlightTimeout = setTimeout(() => { + if (!this.inFlightChunk) { + return; + } + terminalDebugLog({ + scope: "output-delivery-queue", + event: "deliver:timeout-retry", + details: { + sequence: this.inFlightChunk.sequence, + pendingCount: this.pendingChunks.length, + timeoutMs: this.deliveryTimeoutMs, + }, + }); + this.deliverInFlightChunk(); + }, this.deliveryTimeoutMs); + + this.options.onDeliver({ + sequence: chunk.sequence, + text: chunk.text, + }); + } + + private clearInFlightTimeout(): void { + if (!this.inFlightTimeout) { + return; + } + clearTimeout(this.inFlightTimeout); + this.inFlightTimeout = null; + } +} diff --git a/packages/app/src/terminal/runtime/terminal-stream-controller.test.ts b/packages/app/src/terminal/runtime/terminal-stream-controller.test.ts index 90cf0c93e..0bcee67f0 100644 --- a/packages/app/src/terminal/runtime/terminal-stream-controller.test.ts +++ b/packages/app/src/terminal/runtime/terminal-stream-controller.test.ts @@ -12,6 +12,7 @@ type FakeStreamSubscriber = (chunk: TerminalStreamControllerChunk) => void; class FakeTerminalStreamClient implements TerminalStreamControllerClient { private readonly streamSubscribers = new Map>(); + private readonly pendingChunksByStreamId = new Map(); public attachCalls: Array<{ terminalId: string; options?: { @@ -47,6 +48,14 @@ class FakeTerminalStreamClient implements TerminalStreamControllerClient { streamId: number, handler: (chunk: TerminalStreamControllerChunk) => void ): () => void { + const pendingChunks = this.pendingChunksByStreamId.get(streamId); + if (pendingChunks && pendingChunks.length > 0) { + for (const chunk of pendingChunks) { + handler(chunk); + } + this.pendingChunksByStreamId.delete(streamId); + } + const subscribers = this.streamSubscribers.get(streamId) ?? new Set(); subscribers.add(handler); this.streamSubscribers.set(streamId, subscribers); @@ -61,21 +70,44 @@ class FakeTerminalStreamClient implements TerminalStreamControllerClient { emitChunk(input: { streamId: number; + offset?: number; endOffset: number; + replay?: boolean; data: string; }): void { const subscribers = this.streamSubscribers.get(input.streamId); if (!subscribers || subscribers.size === 0) { return; } + const bytes = new TextEncoder().encode(input.data); const chunk: TerminalStreamControllerChunk = { + offset: input.offset ?? input.endOffset - bytes.byteLength, endOffset: input.endOffset, - data: new TextEncoder().encode(input.data), + replay: input.replay, + data: bytes, }; for (const subscriber of subscribers) { subscriber(chunk); } } + + bufferChunk(input: { + streamId: number; + offset?: number; + endOffset: number; + replay?: boolean; + data: string; + }): void { + const chunks = this.pendingChunksByStreamId.get(input.streamId) ?? []; + const bytes = new TextEncoder().encode(input.data); + chunks.push({ + offset: input.offset ?? input.endOffset - bytes.byteLength, + endOffset: input.endOffset, + replay: input.replay, + data: bytes, + }); + this.pendingChunksByStreamId.set(input.streamId, chunks); + } } function createControllerHarness(input?: { @@ -251,4 +283,203 @@ describe("terminal-stream-controller", () => { expect(harness.resets).toEqual(["term-reset"]); expect(harness.controller.getActiveStreamId()).toBe(12); }); + + it("delivers buffered replay chunks flushed synchronously during subscribe", async () => { + const harness = createControllerHarness(); + harness.client.nextAttachResponses.push({ + streamId: 17, + replayedFrom: 0, + currentOffset: 10, + reset: false, + error: null, + }); + harness.client.bufferChunk({ + streamId: 17, + offset: 0, + endOffset: 10, + replay: true, + data: "buffered-replay", + }); + + harness.controller.setTerminal({ terminalId: "term-buffered" }); + await flushAsyncWork(); + + expect(harness.chunks).toEqual([ + { terminalId: "term-buffered", text: "buffered-replay" }, + ]); + }); + + it("clears stale selected output when bootstrap replay starts before current offset", async () => { + const harness = createControllerHarness(); + harness.client.nextAttachResponses.push({ + streamId: 61, + replayedFrom: 0, + currentOffset: 20, + reset: false, + error: null, + }); + + harness.controller.setTerminal({ terminalId: "term-bootstrap-reset" }); + await flushAsyncWork(); + + expect(harness.resets).toEqual(["term-bootstrap-reset"]); + }); + + it("reattaches and replays from last contiguous offset when chunk offsets gap", async () => { + const harness = createControllerHarness(); + harness.client.nextAttachResponses.push({ + streamId: 31, + currentOffset: 0, + reset: false, + error: null, + }); + harness.client.nextAttachResponses.push({ + streamId: 32, + currentOffset: 8, + reset: false, + error: null, + }); + + harness.controller.setTerminal({ terminalId: "term-gap" }); + await flushAsyncWork(); + + harness.client.emitChunk({ + streamId: 31, + offset: 0, + endOffset: 2, + data: "ok", + }); + harness.client.emitChunk({ + streamId: 31, + offset: 4, + endOffset: 8, + data: "miss", + }); + await flushAsyncWork(); + + expect(harness.chunks).toEqual([{ terminalId: "term-gap", text: "ok" }]); + expect(harness.client.detachCalls).toContain(31); + expect(harness.client.attachCalls.length).toBe(2); + expect(harness.client.attachCalls[1]?.options).toEqual({ + resumeOffset: 2, + rows: 24, + cols: 80, + }); + expect(harness.controller.getActiveStreamId()).toBe(32); + }); + + it("does not treat replay range before currentOffset as a live gap", async () => { + const harness = createControllerHarness(); + harness.client.nextAttachResponses.push({ + streamId: 41, + replayedFrom: 50, + currentOffset: 100, + reset: false, + error: null, + }); + harness.client.bufferChunk({ + streamId: 41, + offset: 80, + endOffset: 100, + data: "replay-tail", + }); + + harness.controller.setTerminal({ terminalId: "term-replay" }); + await flushAsyncWork(); + + harness.client.emitChunk({ + streamId: 41, + offset: 100, + endOffset: 102, + data: "ok", + }); + + expect(harness.client.detachCalls).toEqual([]); + expect(harness.client.attachCalls).toHaveLength(1); + expect(harness.controller.getActiveStreamId()).toBe(41); + expect(harness.chunks).toEqual([ + { terminalId: "term-replay", text: "replay-tail" }, + { terminalId: "term-replay", text: "ok" }, + ]); + }); + + it("accepts clamped replay start after reconnect without entering a reconnect loop", async () => { + const harness = createControllerHarness(); + harness.client.nextAttachResponses.push({ + streamId: 71, + replayedFrom: 205, + currentOffset: 205, + reset: false, + error: null, + }); + harness.client.nextAttachResponses.push({ + streamId: 72, + replayedFrom: 396, + currentOffset: 978, + reset: true, + error: null, + }); + + harness.controller.setTerminal({ terminalId: "term-clamped-replay" }); + await flushAsyncWork(); + + harness.client.emitChunk({ + streamId: 71, + offset: 205, + endOffset: 299, + data: "first", + }); + harness.client.emitChunk({ + streamId: 71, + offset: 396, + endOffset: 493, + data: "gap", + }); + await flushAsyncWork(); + + expect(harness.client.attachCalls).toHaveLength(2); + expect(harness.client.attachCalls[1]?.options).toEqual({ + resumeOffset: 299, + rows: 24, + cols: 80, + }); + expect(harness.controller.getActiveStreamId()).toBe(72); + + harness.client.emitChunk({ + streamId: 72, + replay: true, + offset: 396, + endOffset: 493, + data: "replay-1", + }); + harness.client.emitChunk({ + streamId: 72, + replay: true, + offset: 493, + endOffset: 590, + data: "replay-2", + }); + harness.client.emitChunk({ + streamId: 72, + replay: true, + offset: 687, + endOffset: 784, + data: "replay-3", + }); + harness.client.emitChunk({ + streamId: 72, + replay: false, + offset: 978, + endOffset: 1075, + data: "live", + }); + await flushAsyncWork(); + + expect(harness.client.attachCalls).toHaveLength(2); + expect(harness.client.detachCalls.filter((streamId) => streamId === 71)).toHaveLength(1); + expect(harness.chunks.at(-1)).toEqual({ + terminalId: "term-clamped-replay", + text: "live", + }); + }); }); diff --git a/packages/app/src/terminal/runtime/terminal-stream-controller.ts b/packages/app/src/terminal/runtime/terminal-stream-controller.ts index 19dead0d2..9ef9e3a5f 100644 --- a/packages/app/src/terminal/runtime/terminal-stream-controller.ts +++ b/packages/app/src/terminal/runtime/terminal-stream-controller.ts @@ -10,13 +10,16 @@ import { summarizeTerminalText, terminalDebugLog } from "./terminal-debug"; export type TerminalStreamControllerAttachPayload = { streamId: number | null; + replayedFrom?: number; currentOffset: number; reset: boolean; error?: string | null; }; export type TerminalStreamControllerChunk = { + offset: number; endOffset: number; + replay?: boolean; data: Uint8Array; }; @@ -71,6 +74,8 @@ type TerminalStreamControllerActiveStream = { terminalId: string; streamId: number; decoder: TextDecoder; + nextExpectedOffset: number | null; + catchUpEndOffset: number | null; unsubscribe: () => void; }; @@ -308,6 +313,37 @@ export class TerminalStreamController { const decoder = new TextDecoder(); const streamId = attachPayload.streamId; + const replayedFromOffset = + typeof attachPayload.replayedFrom === "number" + ? Math.max(0, Math.floor(attachPayload.replayedFrom)) + : null; + const currentOffset = Math.max( + 0, + Math.floor(attachPayload.currentOffset) + ); + const shouldResetForReplayBootstrap = + typeof resumeOffset !== "number" && + typeof replayedFromOffset === "number" && + replayedFromOffset < currentOffset; + if (shouldResetForReplayBootstrap) { + this.options.onReset?.({ terminalId: input.terminalId }); + } + const startExpectedOffset = + replayedFromOffset ?? + (typeof resumeOffset === "number" + ? Math.max(0, Math.floor(resumeOffset)) + : currentOffset); + const catchUpEndOffset = currentOffset; + + const activeStream: TerminalStreamControllerActiveStream = { + terminalId: input.terminalId, + streamId, + decoder, + nextExpectedOffset: startExpectedOffset, + catchUpEndOffset, + unsubscribe: () => {}, + }; + this.activeStream = activeStream; const unsubscribe = this.options.client.onTerminalStreamData(streamId, (chunk) => { this.handleChunk({ terminalId: input.terminalId, @@ -316,21 +352,22 @@ export class TerminalStreamController { decoder, }); }); - - this.activeStream = { - terminalId: input.terminalId, - streamId, - decoder, - unsubscribe, - }; + if (this.activeStream === activeStream) { + activeStream.unsubscribe = unsubscribe; + } else { + unsubscribe(); + } terminalDebugLog({ scope: "stream-controller", event: "attach:success", details: { terminalId: input.terminalId, streamId, + replayedFrom: attachPayload.replayedFrom ?? null, + requestedResumeOffset: resumeOffset ?? null, currentOffset: attachPayload.currentOffset, reset: attachPayload.reset, + bootstrapReset: shouldResetForReplayBootstrap, }, }); this.updateStatus({ @@ -390,9 +427,54 @@ export class TerminalStreamController { return; } + const chunkOffset = Number.isFinite(input.chunk.offset) + ? Math.max(0, Math.floor(input.chunk.offset)) + : 0; + const chunkEndOffset = Number.isFinite(input.chunk.endOffset) + ? Math.max(0, Math.floor(input.chunk.endOffset)) + : chunkOffset; + if (chunkEndOffset < chunkOffset) { + return; + } + + const expectedOffset = activeStream.nextExpectedOffset; + if (typeof expectedOffset === "number") { + if (chunkEndOffset <= expectedOffset) { + return; + } + if (chunkOffset !== expectedOffset) { + const catchUpEndOffset = activeStream.catchUpEndOffset; + const canSkipReplayGap = + chunkOffset > expectedOffset && + typeof catchUpEndOffset === "number" && + expectedOffset < catchUpEndOffset && + chunkOffset <= catchUpEndOffset; + + if (canSkipReplayGap) { + activeStream.nextExpectedOffset = chunkOffset; + } else { + this.recoverFromStreamGap({ + terminalId: input.terminalId, + streamId: input.streamId, + expectedOffset, + observedOffset: chunkOffset, + }); + return; + } + } + } + + if ( + typeof activeStream.catchUpEndOffset === "number" && + chunkEndOffset >= activeStream.catchUpEndOffset + ) { + activeStream.catchUpEndOffset = null; + } + + activeStream.nextExpectedOffset = chunkEndOffset; updateTerminalResumeOffset({ terminalId: input.terminalId, - offset: input.chunk.endOffset, + offset: chunkEndOffset, resumeOffsetByTerminalId: this.resumeOffsetByTerminalId, }); @@ -406,7 +488,9 @@ export class TerminalStreamController { details: { terminalId: input.terminalId, streamId: input.streamId, - endOffset: input.chunk.endOffset, + offset: chunkOffset, + endOffset: chunkEndOffset, + replay: Boolean(input.chunk.replay), byteLength: input.chunk.data.byteLength, textLength: text.length, preview: summarizeTerminalText({ text, maxChars: 96 }), @@ -419,6 +503,46 @@ export class TerminalStreamController { }); } + private recoverFromStreamGap(input: { + terminalId: string; + streamId: number; + expectedOffset: number; + observedOffset: number; + }): void { + const activeStream = this.activeStream; + if (!activeStream) { + return; + } + if (activeStream.streamId !== input.streamId || activeStream.terminalId !== input.terminalId) { + return; + } + if (this.selectedTerminalId !== input.terminalId) { + return; + } + + updateTerminalResumeOffset({ + terminalId: input.terminalId, + offset: input.expectedOffset, + resumeOffsetByTerminalId: this.resumeOffsetByTerminalId, + }); + + this.attachGeneration += 1; + const generation = this.attachGeneration; + + void this.detachActiveStream({ shouldDetach: true }); + this.updateStatus({ + terminalId: input.terminalId, + streamId: null, + isAttaching: true, + error: + this.options.reconnectErrorMessage ?? DEFAULT_RECONNECT_ERROR_MESSAGE, + }); + void this.attachTerminal({ + terminalId: input.terminalId, + generation, + }); + } + private async detachActiveStream(input: { shouldDetach: boolean }): Promise { const activeStream = this.activeStream; if (!activeStream) { diff --git a/packages/app/src/utils/agent-attention.test.ts b/packages/app/src/utils/agent-attention.test.ts new file mode 100644 index 000000000..8b7b4e674 --- /dev/null +++ b/packages/app/src/utils/agent-attention.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; + +import { shouldClearAgentAttentionOnView } from "./agent-attention"; + +describe("shouldClearAgentAttentionOnView", () => { + it("returns true only when the viewed agent is focused, connected, and requires attention", () => { + expect( + shouldClearAgentAttentionOnView({ + agentId: "agent-1", + focusedAgentId: "agent-1", + isConnected: true, + requiresAttention: true, + }) + ).toBe(true); + }); + + it("returns false when the app is disconnected", () => { + expect( + shouldClearAgentAttentionOnView({ + agentId: "agent-1", + focusedAgentId: "agent-1", + isConnected: false, + requiresAttention: true, + }) + ).toBe(false); + }); + + it("returns false when the agent is not focused", () => { + expect( + shouldClearAgentAttentionOnView({ + agentId: "agent-1", + focusedAgentId: "agent-2", + isConnected: true, + requiresAttention: true, + }) + ).toBe(false); + }); + + it("returns false when attention is already clear", () => { + expect( + shouldClearAgentAttentionOnView({ + agentId: "agent-1", + focusedAgentId: "agent-1", + isConnected: true, + requiresAttention: false, + }) + ).toBe(false); + }); + + it("returns false for empty agent ids", () => { + expect( + shouldClearAgentAttentionOnView({ + agentId: "", + focusedAgentId: "agent-1", + isConnected: true, + requiresAttention: true, + }) + ).toBe(false); + }); +}); diff --git a/packages/app/src/utils/agent-attention.ts b/packages/app/src/utils/agent-attention.ts new file mode 100644 index 000000000..ea102faa2 --- /dev/null +++ b/packages/app/src/utils/agent-attention.ts @@ -0,0 +1,22 @@ +interface ShouldClearAgentAttentionOnViewInput { + agentId: string | null | undefined; + focusedAgentId: string | null | undefined; + isConnected: boolean; + requiresAttention: boolean | null | undefined; +} + +export function shouldClearAgentAttentionOnView( + input: ShouldClearAgentAttentionOnViewInput +): boolean { + const agentId = input.agentId?.trim(); + if (!agentId) { + return false; + } + if (!input.isConnected) { + return false; + } + if (!input.requiresAttention) { + return false; + } + return input.focusedAgentId === agentId; +} diff --git a/packages/app/src/utils/agent-working-directory-suggestions.test.ts b/packages/app/src/utils/agent-working-directory-suggestions.test.ts new file mode 100644 index 000000000..8a0e2a6dc --- /dev/null +++ b/packages/app/src/utils/agent-working-directory-suggestions.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { collectAgentWorkingDirectorySuggestions } from "@/utils/agent-working-directory-suggestions"; + +describe("collectAgentWorkingDirectorySuggestions", () => { + it("deduplicates by cwd and sorts by most recent timestamp", () => { + const results = collectAgentWorkingDirectorySuggestions([ + { + cwd: "/Users/me/project-alpha", + createdAt: new Date("2026-02-10T10:00:00.000Z"), + }, + { + cwd: "/Users/me/project-beta", + createdAt: new Date("2026-02-11T10:00:00.000Z"), + }, + { + cwd: "/Users/me/project-alpha", + lastActivityAt: new Date("2026-02-12T10:00:00.000Z"), + }, + ]); + + expect(results).toEqual([ + "/Users/me/project-alpha", + "/Users/me/project-beta", + ]); + }); + + it("excludes Paseo-owned worktree paths", () => { + const results = collectAgentWorkingDirectorySuggestions([ + { + cwd: "/Users/me/repo/.paseo/worktrees/feature-a", + createdAt: new Date("2026-02-12T10:00:00.000Z"), + }, + { + cwd: "/Users/me/repo", + createdAt: new Date("2026-02-10T10:00:00.000Z"), + }, + { + cwd: "C:\\Users\\me\\repo\\.paseo\\worktrees\\feature-b", + createdAt: new Date("2026-02-11T10:00:00.000Z"), + }, + ]); + + expect(results).toEqual(["/Users/me/repo"]); + }); + + it("ignores empty cwd values", () => { + const results = collectAgentWorkingDirectorySuggestions([ + { cwd: " ", createdAt: new Date("2026-02-10T10:00:00.000Z") }, + { cwd: null, createdAt: new Date("2026-02-11T10:00:00.000Z") }, + { cwd: undefined, lastActivityAt: new Date("2026-02-12T10:00:00.000Z") }, + { + cwd: "/Users/me/project", + createdAt: new Date("2026-02-09T10:00:00.000Z"), + }, + ]); + + expect(results).toEqual(["/Users/me/project"]); + }); +}); diff --git a/packages/app/src/utils/agent-working-directory-suggestions.ts b/packages/app/src/utils/agent-working-directory-suggestions.ts new file mode 100644 index 000000000..62dba3d63 --- /dev/null +++ b/packages/app/src/utils/agent-working-directory-suggestions.ts @@ -0,0 +1,51 @@ +export interface AgentWorkingDirectorySource { + cwd?: string | null; + createdAt?: Date | null; + lastActivityAt?: Date | null; +} + +const PASEO_WORKTREE_PATH_PATTERN = /(^|\/)\.paseo\/worktrees(\/|$)/; + +export function collectAgentWorkingDirectorySuggestions( + sources: Iterable +): string[] { + const lastSeenByPath = new Map(); + + for (const source of sources) { + const cwd = source.cwd?.trim(); + if (!cwd) { + continue; + } + if (isPaseoOwnedWorktreePath(cwd)) { + continue; + } + + const timestamp = toEpochMs(source.lastActivityAt ?? source.createdAt); + const previous = lastSeenByPath.get(cwd); + if (previous === undefined || timestamp > previous) { + lastSeenByPath.set(cwd, timestamp); + } + } + + return Array.from(lastSeenByPath.entries()) + .sort((left, right) => { + const timeDiff = right[1] - left[1]; + if (timeDiff !== 0) { + return timeDiff; + } + return left[0].localeCompare(right[0]); + }) + .map(([cwd]) => cwd); +} + +function isPaseoOwnedWorktreePath(cwd: string): boolean { + return PASEO_WORKTREE_PATH_PATTERN.test(cwd.replace(/\\/g, "/")); +} + +function toEpochMs(date: Date | null | undefined): number { + if (!(date instanceof Date)) { + return 0; + } + const value = date.getTime(); + return Number.isFinite(value) ? value : 0; +} diff --git a/packages/app/src/utils/confirm-dialog.test.ts b/packages/app/src/utils/confirm-dialog.test.ts new file mode 100644 index 000000000..df739d40d --- /dev/null +++ b/packages/app/src/utils/confirm-dialog.test.ts @@ -0,0 +1,116 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +type MockPlatform = "web" | "ios" | "android"; + +type AlertButton = { + onPress?: () => void; +}; + +async function loadModuleForPlatform(platform: MockPlatform): Promise<{ + confirmDialog: typeof import("./confirm-dialog").confirmDialog; + alertMock: ReturnType; +}> { + vi.resetModules(); + + const alertMock = vi.fn(); + vi.doMock("react-native", () => ({ + Alert: { + alert: alertMock, + }, + Platform: { OS: platform }, + })); + + const module = await import("./confirm-dialog"); + return { confirmDialog: module.confirmDialog, alertMock }; +} + +function clearDialogGlobals(): void { + delete (globalThis as { __TAURI__?: unknown }).__TAURI__; + delete (globalThis as { confirm?: unknown }).confirm; +} + +describe("confirmDialog", () => { + afterEach(() => { + vi.doUnmock("react-native"); + vi.restoreAllMocks(); + vi.resetModules(); + clearDialogGlobals(); + }); + + it("uses Tauri dialog.ask on web when available", async () => { + const askMock = vi.fn(async () => true); + (globalThis as { __TAURI__?: unknown }).__TAURI__ = { + dialog: { ask: askMock }, + }; + + const { confirmDialog, alertMock } = await loadModuleForPlatform("web"); + const confirmed = await confirmDialog({ + title: "Restart host", + message: "This will restart the daemon.", + confirmLabel: "Restart", + cancelLabel: "Cancel", + destructive: true, + }); + + expect(confirmed).toBe(true); + expect(alertMock).not.toHaveBeenCalled(); + expect(askMock).toHaveBeenCalledWith("This will restart the daemon.", { + title: "Restart host", + okLabel: "Restart", + cancelLabel: "Cancel", + kind: "warning", + }); + }); + + it("falls back to browser confirm on web when Tauri APIs are unavailable", async () => { + const browserConfirm = vi.fn(() => true); + (globalThis as { confirm?: unknown }).confirm = browserConfirm; + + const { confirmDialog } = await loadModuleForPlatform("web"); + const confirmed = await confirmDialog({ + title: "Restart host", + message: "This will restart the daemon.", + }); + + expect(confirmed).toBe(true); + expect(browserConfirm).toHaveBeenCalledWith( + "Restart host\n\nThis will restart the daemon." + ); + }); + + it("throws on web when no confirm backend exists", async () => { + const { confirmDialog } = await loadModuleForPlatform("web"); + + await expect( + confirmDialog({ + title: "Restart host", + message: "This will restart the daemon.", + }) + ).rejects.toThrow("[ConfirmDialog] No web confirmation backend is available."); + }); + + it("uses native Alert on iOS/Android", async () => { + const { confirmDialog, alertMock } = await loadModuleForPlatform("ios"); + alertMock.mockImplementation( + ( + _title: string, + _message: string, + buttons?: AlertButton[] + ) => { + const confirmButton = buttons?.[1]; + confirmButton?.onPress?.(); + } + ); + + const confirmed = await confirmDialog({ + title: "Restart host", + message: "This will restart the daemon.", + confirmLabel: "Restart", + cancelLabel: "Cancel", + destructive: true, + }); + + expect(confirmed).toBe(true); + expect(alertMock).toHaveBeenCalled(); + }); +}); diff --git a/packages/app/src/utils/confirm-dialog.ts b/packages/app/src/utils/confirm-dialog.ts new file mode 100644 index 000000000..fdc7adf39 --- /dev/null +++ b/packages/app/src/utils/confirm-dialog.ts @@ -0,0 +1,144 @@ +import { Alert, Platform } from "react-native"; + +export interface ConfirmDialogInput { + title: string; + message: string; + confirmLabel?: string; + cancelLabel?: string; + destructive?: boolean; +} + +interface TauriDialogAskOptions { + title?: string; + okLabel?: string; + cancelLabel?: string; + kind?: "info" | "warning" | "error"; +} + +interface TauriDialogApi { + ask?: (message: string, options?: TauriDialogAskOptions) => Promise; +} + +interface TauriCoreApi { + invoke?: (command: string, args?: Record) => Promise; +} + +interface TauriGlobalApi { + dialog?: TauriDialogApi; + core?: TauriCoreApi; +} + +interface ConfirmButtonConfig { + confirmLabel: string; + cancelLabel: string; +} + +function resolveButtonLabels(input: ConfirmDialogInput): ConfirmButtonConfig { + return { + confirmLabel: input.confirmLabel ?? "Confirm", + cancelLabel: input.cancelLabel ?? "Cancel", + }; +} + +async function showNativeConfirmDialog(input: ConfirmDialogInput): Promise { + const labels = resolveButtonLabels(input); + + return new Promise((resolve) => { + Alert.alert( + input.title, + input.message, + [ + { + text: labels.cancelLabel, + style: "cancel", + onPress: () => resolve(false), + }, + { + text: labels.confirmLabel, + style: input.destructive ? "destructive" : "default", + onPress: () => resolve(true), + }, + ], + { + cancelable: true, + onDismiss: () => resolve(false), + } + ); + }); +} + +function getTauriApi(): TauriGlobalApi | null { + if (Platform.OS !== "web") { + return null; + } + + const tauriApi = (globalThis as { __TAURI__?: TauriGlobalApi }).__TAURI__; + return tauriApi ?? null; +} + +function buildTauriAskOptions(input: ConfirmDialogInput): TauriDialogAskOptions { + const labels = resolveButtonLabels(input); + + return { + title: input.title, + okLabel: labels.confirmLabel, + cancelLabel: labels.cancelLabel, + kind: input.destructive ? "warning" : "info", + }; +} + +async function showTauriConfirmDialog(input: ConfirmDialogInput): Promise { + const tauriApi = getTauriApi(); + if (!tauriApi) { + return null; + } + + const options = buildTauriAskOptions(input); + const tauriAsk = tauriApi.dialog?.ask; + + if (typeof tauriAsk === "function") { + try { + return Boolean(await tauriAsk(input.message, options)); + } catch (error) { + console.warn("[ConfirmDialog] Tauri dialog.ask failed", error); + } + } + + const tauriInvoke = tauriApi.core?.invoke; + if (typeof tauriInvoke === "function") { + try { + const result = await tauriInvoke("plugin:dialog|ask", { + message: input.message, + ...options, + }); + return result === true; + } catch (error) { + console.warn("[ConfirmDialog] Tauri plugin:dialog|ask failed", error); + } + } + + return null; +} + +function showWebConfirmDialog(input: ConfirmDialogInput): boolean { + const browserConfirm = (globalThis as { confirm?: (message?: string) => boolean }).confirm; + if (typeof browserConfirm !== "function") { + throw new Error("[ConfirmDialog] No web confirmation backend is available."); + } + + const promptMessage = `${input.title}\n\n${input.message}`; + return browserConfirm(promptMessage); +} + +export async function confirmDialog(input: ConfirmDialogInput): Promise { + if (Platform.OS !== "web") { + return showNativeConfirmDialog(input); + } + + const tauriResult = await showTauriConfirmDialog(input); + if (tauriResult !== null) { + return tauriResult; + } + + return showWebConfirmDialog(input); +} diff --git a/packages/app/src/utils/open-external-url.ts b/packages/app/src/utils/open-external-url.ts new file mode 100644 index 000000000..ee8318456 --- /dev/null +++ b/packages/app/src/utils/open-external-url.ts @@ -0,0 +1,28 @@ +import * as Linking from "expo-linking"; +import { Platform } from "react-native"; +import { getIsTauri } from "@/constants/layout"; + +interface TauriWindowWithOpener { + __TAURI__?: { + opener?: { + openUrl?: (url: string) => Promise; + }; + }; +} + +export async function openExternalUrl(url: string): Promise { + if (Platform.OS === "web") { + if (typeof window !== "undefined" && getIsTauri()) { + const opener = (window as TauriWindowWithOpener).__TAURI__?.opener?.openUrl; + if (typeof opener === "function") { + await opener(url); + return; + } + } + + window.open(url, "_blank", "noopener,noreferrer"); + return; + } + + await Linking.openURL(url); +} diff --git a/packages/app/src/utils/syntax-highlighter.ts b/packages/app/src/utils/syntax-highlighter.ts index 698015e35..e0f38a455 100644 --- a/packages/app/src/utils/syntax-highlighter.ts +++ b/packages/app/src/utils/syntax-highlighter.ts @@ -5,6 +5,7 @@ import { parser as cssParser } from "@lezer/css"; import { parser as htmlParser } from "@lezer/html"; import { parser as pythonParser } from "@lezer/python"; import { parser as markdownParser } from "@lezer/markdown"; +import { parser as elixirParser } from "lezer-elixir"; import type { Parser } from "@lezer/common"; // Map file extensions to parsers @@ -26,6 +27,9 @@ const parsersByExtension: Record = { htm: htmlParser, // Python py: pythonParser, + // Elixir + ex: elixirParser, + exs: elixirParser, // Markdown md: markdownParser, mdx: markdownParser, diff --git a/packages/app/src/utils/tauri-window.ts b/packages/app/src/utils/tauri-window.ts index 95aadc764..41a8f86aa 100644 --- a/packages/app/src/utils/tauri-window.ts +++ b/packages/app/src/utils/tauri-window.ts @@ -109,7 +109,21 @@ export function useTrafficLightPadding(): { left: number; top: number } { useEffect(() => { if (Platform.OS !== "web" || !getIsTauriMac()) return; - let unlisten: (() => void) | undefined; + let disposed = false; + let cleanup: (() => void) | undefined; + let didCleanup = false; + + function runCleanup() { + if (!cleanup || didCleanup) return; + didCleanup = true; + try { + void Promise.resolve(cleanup()).catch((error) => { + console.warn("[TauriWindow] Failed to remove resize listener", error); + }); + } catch (error) { + console.warn("[TauriWindow] Failed to remove resize listener", error); + } + } async function setup() { const win = await getTauriWindow(); @@ -117,19 +131,28 @@ export function useTrafficLightPadding(): { left: number; top: number } { // Check initial fullscreen state const fullscreen = await win.isFullscreen(); + if (disposed) return; setIsFullscreen(fullscreen); // Listen for resize events which include fullscreen changes - unlisten = await win.onResized(async () => { + const unlisten = await win.onResized(async () => { + if (disposed) return; const fs = await win.isFullscreen(); + if (disposed) return; setIsFullscreen(fs); }); + + cleanup = unlisten; + if (disposed) { + runCleanup(); + } } - setup(); + void setup(); return () => { - unlisten?.(); + disposed = true; + runCleanup(); }; }, []); diff --git a/packages/app/src/utils/terminal-list.test.ts b/packages/app/src/utils/terminal-list.test.ts new file mode 100644 index 000000000..b7b38e39f --- /dev/null +++ b/packages/app/src/utils/terminal-list.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; + +import { upsertTerminalListEntry } from "./terminal-list"; + +describe("terminal-list", () => { + it("adds a created terminal when the list is empty", () => { + const result = upsertTerminalListEntry({ + terminals: [], + terminal: { + id: "term-1", + name: "Terminal 1", + cwd: "/tmp/project", + }, + }); + + expect(result).toEqual([{ id: "term-1", name: "Terminal 1" }]); + }); + + it("appends a created terminal when the id does not already exist", () => { + const result = upsertTerminalListEntry({ + terminals: [{ id: "term-1", name: "Terminal 1" }], + terminal: { + id: "term-2", + name: "Terminal 2", + cwd: "/tmp/project", + }, + }); + + expect(result).toEqual([ + { id: "term-1", name: "Terminal 1" }, + { id: "term-2", name: "Terminal 2" }, + ]); + }); + + it("replaces the existing terminal entry when ids match", () => { + const result = upsertTerminalListEntry({ + terminals: [ + { id: "term-1", name: "Terminal 1" }, + { id: "term-2", name: "Old Name" }, + ], + terminal: { + id: "term-2", + name: "Renamed Terminal", + cwd: "/tmp/project", + }, + }); + + expect(result).toEqual([ + { id: "term-1", name: "Terminal 1" }, + { id: "term-2", name: "Renamed Terminal" }, + ]); + }); +}); diff --git a/packages/app/src/utils/terminal-list.ts b/packages/app/src/utils/terminal-list.ts new file mode 100644 index 000000000..a7b2bd9ed --- /dev/null +++ b/packages/app/src/utils/terminal-list.ts @@ -0,0 +1,32 @@ +import type { + CreateTerminalResponse, + ListTerminalsResponse, +} from "@server/shared/messages"; + +type TerminalListEntry = ListTerminalsResponse["payload"]["terminals"][number]; +type CreatedTerminal = NonNullable; + +function toTerminalListEntry(input: { terminal: CreatedTerminal }): TerminalListEntry { + return { + id: input.terminal.id, + name: input.terminal.name, + }; +} + +export function upsertTerminalListEntry(input: { + terminals: TerminalListEntry[]; + terminal: CreatedTerminal; +}): TerminalListEntry[] { + const createdTerminal = toTerminalListEntry({ terminal: input.terminal }); + const existingIndex = input.terminals.findIndex( + (terminal) => terminal.id === createdTerminal.id + ); + + if (existingIndex < 0) { + return [...input.terminals, createdTerminal]; + } + + const nextTerminals = [...input.terminals]; + nextTerminals[existingIndex] = createdTerminal; + return nextTerminals; +} diff --git a/packages/app/src/utils/tool-call-display.test.ts b/packages/app/src/utils/tool-call-display.test.ts index c4f1e24ed..04ea25cc2 100644 --- a/packages/app/src/utils/tool-call-display.test.ts +++ b/packages/app/src/utils/tool-call-display.test.ts @@ -76,6 +76,34 @@ describe("tool-call-display", () => { }); }); + it("builds display model from worktree setup detail", () => { + const display = buildToolCallDisplayModel({ + name: "paseo_worktree_setup", + status: "running", + error: null, + detail: { + type: "worktree_setup", + worktreePath: "/tmp/repo/.paseo/worktrees/repo/branch", + branchName: "feature-branch", + log: "==> [1/1] Running: npm install\n", + commands: [ + { + index: 1, + command: "npm install", + cwd: "/tmp/repo/.paseo/worktrees/repo/branch", + status: "running", + exitCode: null, + }, + ], + }, + }); + + expect(display).toEqual({ + displayName: "Worktree Setup", + summary: "feature-branch", + }); + }); + it("does not derive command summary from unknown raw detail", () => { const display = buildToolCallDisplayModel({ name: "exec_command", diff --git a/packages/app/src/utils/tool-call-icon.ts b/packages/app/src/utils/tool-call-icon.ts index d3e8cb320..c0087b9fc 100644 --- a/packages/app/src/utils/tool-call-icon.ts +++ b/packages/app/src/utils/tool-call-icon.ts @@ -19,6 +19,7 @@ const TOOL_DETAIL_ICONS: Record = edit: Pencil, write: Pencil, search: Search, + worktree_setup: SquareTerminal, unknown: Wrench, }; diff --git a/packages/app/src/utils/working-directory-suggestions.test.ts b/packages/app/src/utils/working-directory-suggestions.test.ts new file mode 100644 index 000000000..d2c8a2d3d --- /dev/null +++ b/packages/app/src/utils/working-directory-suggestions.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { buildWorkingDirectorySuggestions } from "./working-directory-suggestions"; + +describe("buildWorkingDirectorySuggestions", () => { + it("returns de-duplicated recommendations when query is empty", () => { + const results = buildWorkingDirectorySuggestions({ + recommendedPaths: ["/Users/me/projects/paseo", "/Users/me/projects/paseo"], + serverPaths: ["/Users/me/projects/playground"], + query: "", + }); + + expect(results).toEqual(["/Users/me/projects/paseo"]); + }); + + it("prioritizes matching recommended directories before server matches", () => { + const results = buildWorkingDirectorySuggestions({ + recommendedPaths: ["/Users/me/projects/paseo", "/Users/me/documents"], + serverPaths: [ + "/Users/me/projects/playground", + "/Users/me/projects/paseo", + "/Users/me/projects/planbook", + ], + query: "pla", + }); + + expect(results).toEqual([ + "/Users/me/projects/playground", + "/Users/me/projects/planbook", + ]); + }); + + it("puts matching recommended items first when they also match query", () => { + const results = buildWorkingDirectorySuggestions({ + recommendedPaths: ["/Users/me/projects/playground", "/Users/me/projects/paseo"], + serverPaths: [ + "/Users/me/projects/planbook", + "/Users/me/projects/playground", + ], + query: "pla", + }); + + expect(results).toEqual([ + "/Users/me/projects/playground", + "/Users/me/projects/planbook", + ]); + }); + + it("treats '~' as an active query and includes server suggestions", () => { + const results = buildWorkingDirectorySuggestions({ + recommendedPaths: ["/Users/me/projects/paseo"], + serverPaths: [ + "/Users/me/documents", + "/Users/me/projects", + ], + query: "~", + }); + + expect(results).toEqual([ + "/Users/me/projects/paseo", + "/Users/me/documents", + "/Users/me/projects", + ]); + }); +}); diff --git a/packages/app/src/utils/working-directory-suggestions.ts b/packages/app/src/utils/working-directory-suggestions.ts new file mode 100644 index 000000000..af3ae666b --- /dev/null +++ b/packages/app/src/utils/working-directory-suggestions.ts @@ -0,0 +1,72 @@ +export interface BuildWorkingDirectorySuggestionsInput { + recommendedPaths: string[]; + serverPaths: string[]; + query: string; +} + +export function buildWorkingDirectorySuggestions( + input: BuildWorkingDirectorySuggestionsInput +): string[] { + const rawQuery = input.query.trim(); + const recommended = uniquePaths(input.recommendedPaths); + if (!rawQuery) { + return recommended; + } + + const normalizedQuery = normalizeQuery(rawQuery); + const shouldFilterByQuery = normalizedQuery.length > 0; + + const recommendedMatches = shouldFilterByQuery + ? recommended.filter((entry) => pathMatchesQuery(entry, normalizedQuery)) + : recommended; + const seen = new Set(recommendedMatches); + const ordered = [...recommendedMatches]; + + for (const entry of uniquePaths(input.serverPaths)) { + if (shouldFilterByQuery && !pathMatchesQuery(entry, normalizedQuery)) { + continue; + } + if (seen.has(entry)) { + continue; + } + ordered.push(entry); + seen.add(entry); + } + + return ordered; +} + +function uniquePaths(paths: string[]): string[] { + const seen = new Set(); + const ordered: string[] = []; + for (const pathEntry of paths) { + const trimmed = pathEntry.trim(); + if (!trimmed || seen.has(trimmed)) { + continue; + } + seen.add(trimmed); + ordered.push(trimmed); + } + return ordered; +} + +function normalizeQuery(query: string): string { + let normalized = query.trim(); + if (!normalized) { + return ""; + } + if (normalized.startsWith("~")) { + normalized = normalized.slice(1); + } + normalized = normalized.replace(/^\/+/, "").toLowerCase(); + return normalized; +} + +function pathMatchesQuery(candidatePath: string, query: string): boolean { + const lowerPath = candidatePath.toLowerCase(); + if (lowerPath.includes(query)) { + return true; + } + const segments = lowerPath.split("/"); + return (segments[segments.length - 1] ?? "").includes(query); +} diff --git a/packages/cli/package.json b/packages/cli/package.json index 0d4ecd352..b0cac921f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/cli", - "version": "0.1.4", + "version": "0.1.13", "description": "Paseo CLI - control your AI coding agents from the command line", "type": "module", "files": [ @@ -22,8 +22,8 @@ }, "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/relay": "0.1.4", - "@getpaseo/server": "0.1.4", + "@getpaseo/relay": "0.1.13", + "@getpaseo/server": "0.1.13", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 44a0e5264..9adc5e137 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -1,4 +1,5 @@ import { Command } from 'commander' +import { createRequire } from 'node:module' import { createAgentCommand } from './commands/agent/index.js' import { createDaemonCommand } from './commands/daemon/index.js' import { createPermitCommand } from './commands/permit/index.js' @@ -20,7 +21,9 @@ import { runUpdateCommand } from './commands/agent/update.js' import { withOutput } from './output/index.js' import { onboardCommand } from './commands/onboard.js' -const VERSION = '0.1.0' +const require = createRequire(import.meta.url) +const packageJson = require('../package.json') as { version?: string } +const VERSION = typeof packageJson.version === 'string' ? packageJson.version : '0.0.0' // Helper function to collect multiple option values into an array function collectMultiple(value: string, previous: string[]): string[] { diff --git a/packages/cli/src/commands/agent/run.ts b/packages/cli/src/commands/agent/run.ts index 27472c328..77496b6f5 100644 --- a/packages/cli/src/commands/agent/run.ts +++ b/packages/cli/src/commands/agent/run.ts @@ -1,5 +1,9 @@ import type { Command } from 'commander' -import type { AgentSnapshotPayload } from '@getpaseo/server' +import { + getStructuredAgentResponse, + StructuredAgentResponseError, + type AgentSnapshotPayload, +} from '@getpaseo/server' import { connectToDaemon, getDaemonHost } from '../../utils/client.js' import type { CommandOptions, SingleResult, OutputSchema, CommandError } from '../../output/index.js' import { readFileSync } from 'node:fs' @@ -105,98 +109,55 @@ function loadOutputSchema(value: string): Record { return parsed as Record } -function extractFirstJsonObject(text: string): string | null { - const source = text.trim() - if (!source) { - return null +class StructuredRunStatusError extends Error { + readonly kind: 'timeout' | 'permission' | 'error' | 'empty' + + constructor(kind: 'timeout' | 'permission' | 'error' | 'empty', message: string) { + super(message) + this.name = 'StructuredRunStatusError' + this.kind = kind + } +} + +type ConnectedDaemonClient = Awaited> + +export interface StructuredResponseTimelineClient { + fetchAgentTimeline: ConnectedDaemonClient['fetchAgentTimeline'] +} + +export async function resolveStructuredResponseMessage(options: { + client: StructuredResponseTimelineClient + agentId: string + lastMessage: string | null +}): Promise { + const direct = options.lastMessage?.trim() + if (direct) { + return direct } - const startIndexes: number[] = [] - for (let i = 0; i < source.length; i += 1) { - if (source[i] === '{') { - startIndexes.push(i) - } - } - - for (const start of startIndexes) { - let depth = 0 - let inString = false - let escaped = false - - for (let i = start; i < source.length; i += 1) { - const ch = source[i]! - - if (inString) { - if (escaped) { - escaped = false - continue - } - if (ch === '\\') { - escaped = true - continue - } - if (ch === '"') { - inString = false - } + try { + const timeline = await options.client.fetchAgentTimeline(options.agentId, { + direction: 'tail', + projection: 'projected', + limit: 200, + }) + for (let index = timeline.entries.length - 1; index >= 0; index -= 1) { + const entry = timeline.entries[index] + if (!entry || entry.item.type !== 'assistant_message') { continue } - - if (ch === '"') { - inString = true - continue - } - - if (ch === '{') { - depth += 1 - continue - } - if (ch === '}') { - depth -= 1 - if (depth === 0) { - const candidate = source.slice(start, i + 1).trim() - try { - JSON.parse(candidate) - return candidate - } catch { - // Keep scanning. - } - } + const text = entry.item.text.trim() + if (text.length > 0) { + return text } } + } catch { + // Leave empty; caller will surface a consistent structured-output failure message. } return null } -function parseStructuredOutput(lastMessage: string): Record { - const trimmed = lastMessage.trim() - const fenced = trimmed.match(/```(?:json)?\s*\n([\s\S]*?)\n```/) - const jsonText = fenced?.[1]?.trim() ?? extractFirstJsonObject(trimmed) ?? trimmed - - let parsed: unknown - try { - parsed = JSON.parse(jsonText) - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - const error: CommandError = { - code: 'OUTPUT_SCHEMA_FAILED', - message: 'Agent response is not valid JSON', - details: message, - } - throw error - } - - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - const error: CommandError = { - code: 'OUTPUT_SCHEMA_FAILED', - message: 'Agent response JSON must be an object', - } - throw error - } - - return parsed as Record -} - function structuredRunSchema(output: Record): OutputSchema { return { ...agentRunSchema, @@ -318,6 +279,107 @@ export async function runRunCommand( labels['ui'] = 'true' } + if (outputSchema) { + let structuredAgent: AgentSnapshotPayload | null = null + + const callStructuredTurn = async (structuredPrompt: string): Promise => { + if (!structuredAgent) { + structuredAgent = await client.createAgent({ + provider: (options.provider as 'claude' | 'codex' | 'opencode') ?? 'claude', + cwd, + title: options.name, + modeId: options.mode, + model: options.model, + initialPrompt: structuredPrompt, + images, + git, + worktreeName: options.worktree, + labels: Object.keys(labels).length > 0 ? labels : undefined, + }) + } else { + await client.sendMessage(structuredAgent.id, structuredPrompt) + } + + const state = await client.waitForFinish(structuredAgent.id, 10 * 60 * 1000) + if (state.status === 'timeout') { + throw new StructuredRunStatusError('timeout', 'Timed out waiting for structured output') + } + if (state.status === 'permission') { + throw new StructuredRunStatusError( + 'permission', + 'Agent is waiting for permission before producing structured output' + ) + } + if (state.status === 'error') { + throw new StructuredRunStatusError( + 'error', + state.error ?? 'Agent failed before producing structured output' + ) + } + + const lastMessage = await resolveStructuredResponseMessage({ + client, + agentId: structuredAgent.id, + lastMessage: state.lastMessage, + }) + if (!lastMessage) { + throw new StructuredRunStatusError( + 'empty', + 'Agent finished without a structured output message' + ) + } + + return lastMessage + } + + let output: Record + try { + output = await getStructuredAgentResponse>({ + caller: callStructuredTurn, + prompt, + schema: outputSchema, + schemaName: 'RunOutput', + maxRetries: 2, + }) + } catch (err) { + if (err instanceof StructuredRunStatusError) { + const error: CommandError = { + code: 'OUTPUT_SCHEMA_FAILED', + message: err.message, + } + throw error + } + if (err instanceof StructuredAgentResponseError) { + const error: CommandError = { + code: 'OUTPUT_SCHEMA_FAILED', + message: 'Agent response did not match the required output schema', + details: + err.validationErrors.length > 0 + ? err.validationErrors.join('\n') + : err.lastResponse || 'No response', + } + throw error + } + throw err + } + + if (!structuredAgent) { + const error: CommandError = { + code: 'OUTPUT_SCHEMA_FAILED', + message: 'Agent finished without a structured output message', + } + throw error + } + + await client.close() + + return { + type: 'single', + data: toRunResult(structuredAgent, 'completed'), + schema: structuredRunSchema(output), + } + } + // Create the agent const agent = await client.createAgent({ provider: (options.provider as 'claude' | 'codex' | 'opencode') ?? 'claude', @@ -326,59 +388,12 @@ export async function runRunCommand( modeId: options.mode, model: options.model, initialPrompt: prompt, - outputSchema, images, git, worktreeName: options.worktree, labels: Object.keys(labels).length > 0 ? labels : undefined, }) - if (outputSchema) { - const state = await client.waitForFinish(agent.id, 10 * 60 * 1000) - - if (state.status === 'timeout') { - const error: CommandError = { - code: 'OUTPUT_SCHEMA_FAILED', - message: 'Timed out waiting for structured output', - } - throw error - } - - if (state.status === 'permission') { - const error: CommandError = { - code: 'OUTPUT_SCHEMA_FAILED', - message: 'Agent is waiting for permission before producing structured output', - } - throw error - } - - if (state.status === 'error') { - const error: CommandError = { - code: 'OUTPUT_SCHEMA_FAILED', - message: state.error ?? 'Agent failed before producing structured output', - } - throw error - } - - const lastMessage = state.lastMessage?.trim() - if (!lastMessage) { - const error: CommandError = { - code: 'OUTPUT_SCHEMA_FAILED', - message: 'Agent finished without a structured output message', - } - throw error - } - - const output = parseStructuredOutput(lastMessage) - await client.close() - - return { - type: 'single', - data: toRunResult(agent, 'completed'), - schema: structuredRunSchema(output), - } - } - // Default run behavior is foreground: wait for completion unless --detach is set. if (!options.detach) { const state = await client.waitForFinish(agent.id, 10 * 60 * 1000) diff --git a/packages/cli/src/commands/daemon/local-daemon.ts b/packages/cli/src/commands/daemon/local-daemon.ts index e8721ba85..73c1888a3 100644 --- a/packages/cli/src/commands/daemon/local-daemon.ts +++ b/packages/cli/src/commands/daemon/local-daemon.ts @@ -1,4 +1,4 @@ -import { spawn } from 'node:child_process' +import { spawn, spawnSync } from 'node:child_process' import { closeSync, existsSync, openSync, readFileSync, rmSync } from 'node:fs' import { createRequire } from 'node:module' import path from 'node:path' @@ -99,6 +99,22 @@ function buildRunnerArgs(options: DaemonStartOptions): string[] { return args } +function buildChildEnv(options: DaemonStartOptions): NodeJS.ProcessEnv { + const childEnv: NodeJS.ProcessEnv = { ...process.env } + if (options.home) { + childEnv.PASEO_HOME = options.home + } + if (options.listen) { + childEnv.PASEO_LISTEN = options.listen + } else if (options.port) { + childEnv.PASEO_LISTEN = `127.0.0.1:${options.port}` + } + if (options.allowedHosts) { + childEnv.PASEO_ALLOWED_HOSTS = options.allowedHosts + } + return childEnv +} + function resolveDaemonRunnerEntry(): string { const serverExportPath = require.resolve('@getpaseo/server') let currentDir = path.dirname(serverExportPath) @@ -279,18 +295,7 @@ export async function startLocalDaemonDetached( throw new Error('Cannot use --listen and --port together') } - const childEnv: NodeJS.ProcessEnv = { ...process.env } - if (options.home) { - childEnv.PASEO_HOME = options.home - } - if (options.listen) { - childEnv.PASEO_LISTEN = options.listen - } else if (options.port) { - childEnv.PASEO_LISTEN = `127.0.0.1:${options.port}` - } - if (options.allowedHosts) { - childEnv.PASEO_ALLOWED_HOSTS = options.allowedHosts - } + const childEnv = buildChildEnv(options) const paseoHome = resolvePaseoHome(childEnv) const logPath = path.join(paseoHome, DAEMON_LOG_FILENAME) @@ -356,6 +361,29 @@ export async function startLocalDaemonDetached( } } +export function startLocalDaemonForeground(options: DaemonStartOptions): number { + if (options.listen && options.port) { + throw new Error('Cannot use --listen and --port together') + } + + const childEnv = buildChildEnv(options) + const daemonRunnerEntry = resolveDaemonRunnerEntry() + const result = spawnSync( + process.execPath, + [...process.execArgv, daemonRunnerEntry, ...buildRunnerArgs(options)], + { + env: childEnv, + stdio: 'inherit', + } + ) + + if (result.error) { + throw result.error + } + + return result.status ?? 1 +} + export async function stopLocalDaemon( options: StopLocalDaemonOptions = {} ): Promise { diff --git a/packages/cli/src/commands/daemon/start.ts b/packages/cli/src/commands/daemon/start.ts index caa84ef69..ffae16ffa 100644 --- a/packages/cli/src/commands/daemon/start.ts +++ b/packages/cli/src/commands/daemon/start.ts @@ -1,14 +1,7 @@ import { Command } from 'commander' import chalk from 'chalk' import { - createPaseoDaemon, - loadConfig, - resolvePaseoHome, - createRootLogger, - loadPersistedConfig, -} from '@getpaseo/server' -import type { CliConfigOverrides } from '@getpaseo/server' -import { + startLocalDaemonForeground, startLocalDaemonDetached, type DaemonStartOptions as StartOptions, } from './local-daemon.js' @@ -34,34 +27,6 @@ export function startCommand(): Command { }) } -function toCliOverrides(options: StartOptions): CliConfigOverrides { - const cliOverrides: CliConfigOverrides = {} - - if (options.listen) { - cliOverrides.listen = options.listen - } else if (options.port) { - cliOverrides.listen = `127.0.0.1:${options.port}` - } - - if (options.relay === false) { - cliOverrides.relayEnabled = false - } - - if (options.allowedHosts) { - const raw = options.allowedHosts.trim() - cliOverrides.allowedHosts = - raw.toLowerCase() === 'true' - ? true - : raw.split(',').map(h => h.trim()).filter(Boolean) - } - - if (options.mcp === false) { - cliOverrides.mcpEnabled = false - } - - return cliOverrides -} - export async function runStart(options: StartOptions): Promise { if (options.listen && options.port) { console.error(chalk.red('Cannot use --listen and --port together')) @@ -78,63 +43,9 @@ export async function runStart(options: StartOptions): Promise { } return } - - if (options.home) { - process.env.PASEO_HOME = options.home - } - - let paseoHome: string - let logger: ReturnType - let config: ReturnType - try { - paseoHome = resolvePaseoHome() - const persistedConfig = loadPersistedConfig(paseoHome) - logger = createRootLogger(persistedConfig) - config = loadConfig(paseoHome, { cli: toCliOverrides(options) }) - } catch (err) { - exitWithError(getErrorMessage(err)) - } - - let daemon: Awaited> - try { - daemon = await createPaseoDaemon(config, logger) - } catch (err) { - const message = getErrorMessage(err) - exitWithError(`Failed to initialize daemon: ${message}`) - } - - let shuttingDown = false - const handleShutdown = async (signal: string) => { - if (shuttingDown) { - logger.info('Forcing exit...') - process.exit(1) - } - shuttingDown = true - logger.info(`${signal} received, shutting down gracefully... (press Ctrl+C again to force exit)`) - - const forceExit = setTimeout(() => { - logger.warn('Forcing shutdown - HTTP server didn\'t close in time') - process.exit(1) - }, 10000) - - try { - await daemon.stop() - clearTimeout(forceExit) - logger.info('Server closed') - process.exit(0) - } catch (err) { - clearTimeout(forceExit) - logger.error({ err }, 'Shutdown failed') - process.exit(1) - } - } - - process.on('SIGTERM', () => handleShutdown('SIGTERM')) - process.on('SIGINT', () => handleShutdown('SIGINT')) - - try { - await daemon.start() + const status = startLocalDaemonForeground(options) + process.exit(status) } catch (err) { const message = getErrorMessage(err) exitWithError(`Failed to start daemon: ${message}`) diff --git a/packages/cli/src/commands/onboard.ts b/packages/cli/src/commands/onboard.ts index 7f589780d..26ec43c16 100644 --- a/packages/cli/src/commands/onboard.ts +++ b/packages/cli/src/commands/onboard.ts @@ -25,9 +25,6 @@ interface OnboardOptions extends DaemonStartOptions { } type OnboardPersistedConfig = PersistedConfig & { - providers?: PersistedConfig['providers'] & { - local?: PersistedConfig['providers'] extends { local?: infer T } ? T : { autoDownload?: boolean } - } features?: PersistedConfig['features'] & { dictation?: PersistedConfig['features'] extends { dictation?: infer T } ? T & { enabled?: boolean } @@ -103,13 +100,6 @@ function savePersistedConfig(paseoHome: string, config: OnboardPersistedConfig): function applyVoiceSelection(config: OnboardPersistedConfig, enabled: boolean): OnboardPersistedConfig { return { ...config, - providers: { - ...config.providers, - local: { - ...config.providers?.local, - autoDownload: enabled, - }, - }, features: { ...config.features, dictation: { @@ -366,7 +356,7 @@ export async function runOnboard(options: OnboardOptions): Promise { const config = loadConfig(paseoHome, { cli: toCliOverrides(options) }) const voiceStatus = voiceEnabled - ? 'Voice features enabled. Local speech models will download in the background if missing.' + ? 'Voice features enabled. Local speech models will be downloaded automatically if missing.' : 'Voice features disabled. Local speech models will not be downloaded.' log.message(voiceStatus) diff --git a/packages/cli/src/utils/client.ts b/packages/cli/src/utils/client.ts index 51786a948..e64482ed1 100644 --- a/packages/cli/src/utils/client.ts +++ b/packages/cli/src/utils/client.ts @@ -62,7 +62,6 @@ export async function connectToDaemon(options?: ConnectOptions): Promise void +}): StructuredResponseTimelineClient { + return { + fetchAgentTimeline: async () => { + options.onFetch?.() + if (options.throwOnFetch) { + throw new Error('timeline unavailable') + } + return { + entries: options.entries ?? [], + } as Awaited> + }, + } +} + +console.log('=== Run Output Schema Helper Tests ===\n') + +// Test 1: Direct lastMessage is returned without timeline fetch. +{ + let fetchCount = 0 + const client = createClient({ onFetch: () => fetchCount += 1 }) + const result = await resolveStructuredResponseMessage({ + client, + agentId: 'agent-1', + lastMessage: ' {"summary":"ok"} ', + }) + assert.strictEqual(result, '{"summary":"ok"}') + assert.strictEqual(fetchCount, 0, 'should not fetch timeline when lastMessage exists') + console.log('✓ returns direct lastMessage when present') +} + +// Test 2: Falls back to latest assistant message from timeline. +{ + const client = createClient({ + entries: [ + { item: { type: 'user_message', text: 'prompt' } }, + { item: { type: 'assistant_message', text: '' } }, + { item: { type: 'assistant_message', text: ' {"summary":"from timeline"} ' } }, + ], + }) + const result = await resolveStructuredResponseMessage({ + client, + agentId: 'agent-2', + lastMessage: null, + }) + assert.strictEqual(result, '{"summary":"from timeline"}') + console.log('✓ falls back to latest assistant timeline entry') +} + +// Test 3: Returns null when timeline has no assistant messages. +{ + const client = createClient({ + entries: [ + { item: { type: 'user_message', text: 'prompt' } }, + { item: { type: 'reasoning', text: 'thinking' } }, + ], + }) + const result = await resolveStructuredResponseMessage({ + client, + agentId: 'agent-3', + lastMessage: null, + }) + assert.strictEqual(result, null) + console.log('✓ returns null when no assistant messages exist') +} + +// Test 4: Returns null if timeline fetch fails. +{ + const client = createClient({ throwOnFetch: true }) + const result = await resolveStructuredResponseMessage({ + client, + agentId: 'agent-4', + lastMessage: null, + }) + assert.strictEqual(result, null) + console.log('✓ returns null when timeline fetch throws') +} + +console.log('\n=== All helper tests passed ===') diff --git a/packages/cli/tests/e2e/run-output-schema.test.ts b/packages/cli/tests/e2e/run-output-schema.test.ts new file mode 100644 index 000000000..d5023828b --- /dev/null +++ b/packages/cli/tests/e2e/run-output-schema.test.ts @@ -0,0 +1,139 @@ +#!/usr/bin/env npx tsx + +import assert from 'node:assert' +import { createE2ETestContext, type TestDaemonContext } from '../helpers/test-daemon.ts' + +interface E2EContext extends TestDaemonContext { + paseo: (args: string[], opts?: { timeout?: number; cwd?: string }) => Promise<{ + exitCode: number + stdout: string + stderr: string + }> +} + +const schema = JSON.stringify({ + type: 'object', + properties: { + summary: { type: 'string' }, + }, + required: ['summary'], + additionalProperties: false, +}) + +const impossibleSchema = JSON.stringify({ + type: 'object', + properties: { + summary: { type: 'string' }, + }, + required: ['summary'], + allOf: [ + { properties: { summary: { type: 'string' } } }, + { properties: { summary: { type: 'number' } } }, + ], +}) + +let ctx: E2EContext + +async function setup(): Promise { + ctx = await createE2ETestContext({ timeout: 180000 }) +} + +async function cleanup(): Promise { + if (ctx) { + await ctx.stop() + } +} + +async function runProviderCase(input: { + provider: 'claude' | 'codex' | 'opencode' + mode: string + model: string +}): Promise { + const result = await ctx.paseo( + [ + 'run', + '--provider', + input.provider, + '--mode', + input.mode, + '--model', + input.model, + '--output-schema', + schema, + `Return valid JSON with a short summary for provider ${input.provider}.`, + ], + { timeout: 180000 } + ) + + assert.strictEqual( + result.exitCode, + 0, + `expected ${input.provider} run to succeed, got ${result.exitCode}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}` + ) + + const parsed = JSON.parse(result.stdout.trim()) as { summary?: unknown } + assert.strictEqual( + typeof parsed.summary, + 'string', + `expected ${input.provider} output to contain string summary, got: ${result.stdout}` + ) + assert(parsed.summary && parsed.summary.length > 0, 'summary must not be empty') +} + +async function test_all_providers_return_structured_output(): Promise { + await runProviderCase({ + provider: 'claude', + mode: 'bypassPermissions', + model: 'haiku', + }) + await runProviderCase({ + provider: 'codex', + mode: 'full-access', + model: 'gpt-5.3-codex', + }) + await runProviderCase({ + provider: 'opencode', + mode: 'default', + model: 'opencode/kimi-k2.5-free', + }) +} + +async function test_schema_validation_is_enforced(): Promise { + const result = await ctx.paseo( + [ + 'run', + '--provider', + 'claude', + '--mode', + 'bypassPermissions', + '--model', + 'haiku', + '--output-schema', + impossibleSchema, + 'Return exactly {"summary":"ok"} and nothing else.', + ], + { timeout: 180000 } + ) + + assert.notStrictEqual(result.exitCode, 0, 'expected impossible schema to fail') + const output = `${result.stdout}\n${result.stderr}` + assert( + output.includes('OUTPUT_SCHEMA_FAILED'), + `expected OUTPUT_SCHEMA_FAILED for impossible schema\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}` + ) +} + +async function main(): Promise { + try { + await setup() + await test_all_providers_return_structured_output() + await test_schema_validation_is_enforced() + } catch (error) { + console.error(error) + process.exitCode = 1 + } finally { + await cleanup() + } +} + +main() diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 3e887f338..602141fb8 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/desktop", - "version": "0.1.4", + "version": "0.1.13", "private": true, "description": "Paseo desktop app (Tauri wrapper)", "scripts": { diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index 161e328aa..9b8df883e 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/packages/desktop/src-tauri/Cargo.lock @@ -81,6 +81,137 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "async-signal" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "atk" version = "0.18.2" @@ -173,6 +304,19 @@ dependencies = [ "objc2", ] +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "borsh" version = "1.6.0" @@ -416,6 +560,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "convert_case" version = "0.4.0" @@ -660,6 +813,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" dependencies = [ "bitflags 2.10.0", + "block2", + "libc", "objc2", ] @@ -753,6 +908,33 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "env_filter" version = "0.1.4" @@ -780,6 +962,43 @@ dependencies = [ "typeid", ] +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "fdeflate" version = "0.3.7" @@ -914,6 +1133,19 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.31" @@ -1281,6 +1513,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -1573,6 +1811,25 @@ dependencies = [ "serde", ] +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + [[package]] name = "itoa" version = "1.0.17" @@ -1735,6 +1992,12 @@ dependencies = [ "libc", ] +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + [[package]] name = "litemap" version = "0.8.1" @@ -2165,12 +2428,34 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "open" +version = "5.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" +dependencies = [ + "dunce", + "is-wsl", + "libc", + "pathdiff", +] + [[package]] name = "option-ext" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "pango" version = "0.18.3" @@ -2196,6 +2481,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2228,10 +2519,18 @@ dependencies = [ "serde_json", "tauri", "tauri-build", + "tauri-plugin-dialog", "tauri-plugin-log", + "tauri-plugin-opener", "tauri-plugin-websocket", ] +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -2384,6 +2683,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "piper" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.32" @@ -2416,6 +2726,20 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -2793,6 +3117,30 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + [[package]] name = "ring" version = "0.17.14" @@ -2861,6 +3209,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.36" @@ -3205,6 +3566,16 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.8" @@ -3602,6 +3973,46 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-dialog" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9204b425d9be8d12aa60c2a83a289cf7d1caae40f57f336ed1155b3a5c0e359b" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed390cc669f937afeb8b28032ce837bac8ea023d975a2e207375ec05afaf1804" +dependencies = [ + "anyhow", + "dunce", + "glob", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "toml 0.9.11+spec-1.1.0", + "url", +] + [[package]] name = "tauri-plugin-log" version = "2.8.0" @@ -3624,6 +4035,28 @@ dependencies = [ "time", ] +[[package]] +name = "tauri-plugin-opener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc624469b06f59f5a29f874bbc61a2ed737c0f9c23ef09855a292c389c42e83f" +dependencies = [ + "dunce", + "glob", + "objc2-app-kit", + "objc2-foundation", + "open", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "url", + "windows", + "zbus", +] + [[package]] name = "tauri-plugin-websocket" version = "2.4.2" @@ -3745,6 +4178,19 @@ dependencies = [ "toml 0.9.11+spec-1.1.0", ] +[[package]] +name = "tempfile" +version = "3.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "tendril" version = "0.4.3" @@ -4055,9 +4501,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -4126,6 +4584,17 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "uds_windows" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" +dependencies = [ + "memoffset", + "tempfile", + "winapi", +] + [[package]] name = "unic-char-property" version = "0.9.0" @@ -5070,6 +5539,67 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfeff997a0aaa3eb20c4652baf788d2dfa6d2839a0ead0b3ff69ce2f9c4bdd1" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 0.7.14", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bbd5a90dbe8feee5b13def448427ae314ccd26a49cac47905cafefb9ff846f1" +dependencies = [ + "proc-macro-crate 3.4.0", + "proc-macro2", + "quote", + "syn 2.0.114", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" +dependencies = [ + "serde", + "winnow 0.7.14", + "zvariant", +] + [[package]] name = "zerocopy" version = "0.8.33" @@ -5155,3 +5685,43 @@ name = "zmij" version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94f63c051f4fe3c1509da62131a678643c5b6fbdc9273b2b79d4378ebda003d2" + +[[package]] +name = "zvariant" +version = "5.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68b64ef4f40c7951337ddc7023dd03528a57a3ce3408ee9da5e948bd29b232c4" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 0.7.14", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "484d5d975eb7afb52cc6b929c13d3719a20ad650fea4120e6310de3fc55e415c" +dependencies = [ + "proc-macro-crate 3.4.0", + "proc-macro2", + "quote", + "syn 2.0.114", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.114", + "winnow 0.7.14", +] diff --git a/packages/desktop/src-tauri/Cargo.toml b/packages/desktop/src-tauri/Cargo.toml index 5af352912..b69373ca7 100644 --- a/packages/desktop/src-tauri/Cargo.toml +++ b/packages/desktop/src-tauri/Cargo.toml @@ -26,5 +26,7 @@ serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } log = "0.4" tauri = { version = "2.9.5", features = [] } +tauri-plugin-dialog = "2" tauri-plugin-log = "2" +tauri-plugin-opener = "2" tauri-plugin-websocket = "2" diff --git a/packages/desktop/src-tauri/capabilities/default.json b/packages/desktop/src-tauri/capabilities/default.json index 7ec3a7c54..21bd32027 100644 --- a/packages/desktop/src-tauri/capabilities/default.json +++ b/packages/desktop/src-tauri/capabilities/default.json @@ -14,7 +14,10 @@ "permissions": [ "core:default", "core:window:allow-start-dragging", + "core:window:allow-set-badge-count", "core:window:allow-toggle-maximize", + "dialog:default", + "opener:default", "websocket:default" ] } diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index bc8b3e553..7c12e3e32 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -19,6 +19,8 @@ fn set_zoom_factor(webview: &WebviewWindow, factor: f64) { #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() + .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_websocket::init()) .setup(|app| { if cfg!(debug_assertions) { diff --git a/packages/relay/package.json b/packages/relay/package.json index e8603eeaa..5d13a1344 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/relay", - "version": "0.1.4", + "version": "0.1.13", "description": "Paseo relay for bridging daemon and client connections", "type": "module", "publishConfig": { diff --git a/packages/server/package.json b/packages/server/package.json index dd3470e48..b7997dd76 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/server", - "version": "0.1.4", + "version": "0.1.13", "description": "Paseo backend server", "type": "module", "publishConfig": { @@ -9,6 +9,7 @@ "types": "./dist/server/server/exports.d.ts", "files": [ "dist/server", + "dist/src", "dist/scripts", "README.md", ".env.example", @@ -52,10 +53,10 @@ "test:e2e:mobile": "playwright test --project='Mobile Chrome'" }, "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.2.11", - "@getpaseo/relay": "0.1.4", "@ai-sdk/openai": "2.0.52", + "@anthropic-ai/claude-agent-sdk": "^0.2.11", "@deepgram/sdk": "^3.4.0", + "@getpaseo/relay": "0.1.13", "@lezer/common": "^1.5.0", "@lezer/css": "^1.3.0", "@lezer/highlight": "^1.2.3", @@ -73,8 +74,9 @@ "dotenv": "^17.2.3", "express": "^4.18.2", "express-basic-auth": "^1.2.1", + "lezer-elixir": "^1.1.2", "mnemonic-id": "^3.2.7", - "node-pty": "^1.0.0", + "node-pty": "1.2.0-beta.11", "onnxruntime-node": "^1.23.0", "openai": "^4.20.0", "pino": "^10.2.0", @@ -91,13 +93,13 @@ }, "devDependencies": { "@playwright/test": "^1.56.1", - "playwright": "^1.56.1", "@types/express": "^4.17.20", "@types/node": "^20.9.0", "@types/qrcode": "^1.5.6", "@types/uuid": "^9.0.7", "@types/ws": "^8.5.8", "@vitest/ui": "^3.2.4", + "playwright": "^1.56.1", "tsx": "^4.6.0", "typescript": "^5.2.2", "vitest": "^3.2.4" diff --git a/packages/server/scripts/daemon-runner.ts b/packages/server/scripts/daemon-runner.ts index d6c6f186c..1b96be948 100644 --- a/packages/server/scripts/daemon-runner.ts +++ b/packages/server/scripts/daemon-runner.ts @@ -1,6 +1,27 @@ import { fileURLToPath } from "url"; import { existsSync } from "node:fs"; import { runSupervisor } from "./supervisor.js"; +import { applySherpaLoaderEnv } from "../src/server/speech/providers/local/sherpa/sherpa-runtime-env.js"; + +type DaemonRunnerConfig = { + devMode: boolean; + workerArgs: string[]; +}; + +function parseConfig(argv: string[]): DaemonRunnerConfig { + let devMode = false; + const workerArgs: string[] = []; + + for (const arg of argv) { + if (arg === "--dev") { + devMode = true; + continue; + } + workerArgs.push(arg); + } + + return { devMode, workerArgs }; +} function resolveWorkerEntry(): string { const candidates = [ @@ -19,18 +40,32 @@ function resolveWorkerEntry(): string { return candidates[0]; } -function resolveWorkerExecArgv(): string[] { - const workerEntry = resolveWorkerEntry(); +function resolveDevWorkerEntry(): string { + const candidate = fileURLToPath(new URL("../src/server/index.ts", import.meta.url)); + if (!existsSync(candidate)) { + throw new Error(`Dev worker entry not found: ${candidate}`); + } + return candidate; +} + +function resolveWorkerExecArgv(workerEntry: string): string[] { return workerEntry.endsWith(".ts") ? ["--import", "tsx"] : []; } +const config = parseConfig(process.argv.slice(2)); +const workerEntry = config.devMode ? resolveDevWorkerEntry() : resolveWorkerEntry(); + +applySherpaLoaderEnv(process.env); + runSupervisor({ name: "DaemonRunner", - startupMessage: "Starting daemon worker (IPC restart enabled)", - resolveWorkerEntry, - workerArgs: process.argv.slice(2), + startupMessage: config.devMode + ? "Starting daemon worker (dev mode, crash restarts enabled)" + : "Starting daemon worker (IPC restart enabled)", + resolveWorkerEntry: () => workerEntry, + workerArgs: config.workerArgs, workerEnv: process.env, - workerExecArgv: resolveWorkerExecArgv(), - restartOnCrash: false, + workerExecArgv: resolveWorkerExecArgv(workerEntry), + restartOnCrash: config.devMode, shutdownReasons: ["cli_shutdown"], }); diff --git a/packages/server/scripts/dev-runner.ts b/packages/server/scripts/dev-runner.ts index 1f303a1f9..de50c9eb0 100644 --- a/packages/server/scripts/dev-runner.ts +++ b/packages/server/scripts/dev-runner.ts @@ -1,7 +1,13 @@ import { fileURLToPath } from "url"; import { existsSync } from "node:fs"; +import dotenv from "dotenv"; import { runSupervisor } from "./supervisor.js"; +dotenv.config({ + path: fileURLToPath(new URL("../.env", import.meta.url)), + quiet: true, +}); + const WORKER_ENTRY = fileURLToPath(new URL("../src/server/index.ts", import.meta.url)); if (!existsSync(WORKER_ENTRY)) { throw new Error(`Dev worker entry not found: ${WORKER_ENTRY}`); diff --git a/packages/server/scripts/download-speech-models.ts b/packages/server/scripts/download-speech-models.ts index fe2b91667..0fe05cb93 100644 --- a/packages/server/scripts/download-speech-models.ts +++ b/packages/server/scripts/download-speech-models.ts @@ -42,5 +42,5 @@ function parseArgs(argv: string[]): { modelsDir: string; modelIds: LocalSpeechMo const logger = createRootLogger({ level: "info", format: "pretty" }); const { modelsDir, modelIds } = parseArgs(process.argv.slice(2)); -await ensureLocalSpeechModels({ modelsDir, modelIds, autoDownload: true, logger }); +await ensureLocalSpeechModels({ modelsDir, modelIds, logger }); logger.info({ modelsDir, modelIds }, "Done downloading speech models"); diff --git a/packages/server/scripts/generate-sherpa-tts-matrix.ts b/packages/server/scripts/generate-sherpa-tts-matrix.ts index 8bc52eced..0e30bc42a 100644 --- a/packages/server/scripts/generate-sherpa-tts-matrix.ts +++ b/packages/server/scripts/generate-sherpa-tts-matrix.ts @@ -284,7 +284,6 @@ logger.info( await ensureLocalSpeechModels({ modelsDir: options.modelsDir, modelIds: options.modelIds, - autoDownload: true, logger, }); diff --git a/packages/server/scripts/transcribe-local-wav.ts b/packages/server/scripts/transcribe-local-wav.ts index 3c7ff94f6..f056b3526 100644 --- a/packages/server/scripts/transcribe-local-wav.ts +++ b/packages/server/scripts/transcribe-local-wav.ts @@ -18,12 +18,11 @@ type CliOptions = { outPath?: string; model: LocalSttModelId; modelsDir: string; - autoDownload: boolean; }; function usage(): string { return [ - "Usage: npm run speech:transcribe:local -- [--out ] [--model ] [--models-dir ] [--no-auto-download]", + "Usage: npm run speech:transcribe:local -- [--out ] [--model ] [--models-dir ]", "", "Examples:", " npm run speech:transcribe:local -- ./sample.wav", @@ -52,7 +51,6 @@ function parseArgs(argv: string[]): CliOptions { let outPath: string | undefined; let model = LocalSttModelIdSchema.parse(process.env.PASEO_LOCAL_STT_MODEL ?? DEFAULT_LOCAL_STT_MODEL); let modelsDir = defaultModelsDir; - let autoDownload = true; for (let i = 0; i < argv.length; i++) { const arg = argv[i]; @@ -87,11 +85,6 @@ function parseArgs(argv: string[]): CliOptions { continue; } - if (arg === "--no-auto-download") { - autoDownload = false; - continue; - } - if (arg.startsWith("-")) { throw new Error(`Unknown option: ${arg}`); } @@ -108,7 +101,6 @@ function parseArgs(argv: string[]): CliOptions { ...(outPath ? { outPath } : {}), model, modelsDir, - autoDownload, }; } @@ -139,7 +131,6 @@ async function main(): Promise { providers, local: { modelsDir: options.modelsDir, - autoDownload: options.autoDownload, models: { dictationStt: options.model, voiceStt: options.model, diff --git a/packages/server/scripts/voice-main-daemon-e2e.ts b/packages/server/scripts/voice-main-daemon-e2e.ts index a393dd0f6..efcecfbc9 100644 --- a/packages/server/scripts/voice-main-daemon-e2e.ts +++ b/packages/server/scripts/voice-main-daemon-e2e.ts @@ -49,7 +49,9 @@ async function runVoiceRoundTrip(params: { }): Promise { const client = new DaemonClient({ url: `${params.daemonUrl}/ws` }); await client.connect(); - client.subscribeAgentUpdates({ subscriptionId: `voice-e2e-${randomUUID()}` }); + await client.fetchAgents({ + subscribe: { subscriptionId: `voice-e2e-${randomUUID()}` }, + }); const mode = await client.setVoiceMode(true, params.voiceAgentId); if (!mode.accepted) { diff --git a/packages/server/scripts/voice-roundtrip-debug.ts b/packages/server/scripts/voice-roundtrip-debug.ts index 4c1beef8d..60be54d26 100644 --- a/packages/server/scripts/voice-roundtrip-debug.ts +++ b/packages/server/scripts/voice-roundtrip-debug.ts @@ -48,7 +48,7 @@ async function main(): Promise { try { await client.connect(); - client.subscribeAgentUpdates({ subscriptionId: "voice-debug" }); + await client.fetchAgents({ subscribe: { subscriptionId: "voice-debug" } }); const voiceCwd = mkdtempSync(path.join(tmpdir(), "voice-roundtrip-debug-")); const voiceAgent = await client.createAgent({ diff --git a/packages/server/src/client/daemon-client-terminal-stream-manager.test.ts b/packages/server/src/client/daemon-client-terminal-stream-manager.test.ts index 4652f728a..e6b170e63 100644 --- a/packages/server/src/client/daemon-client-terminal-stream-manager.test.ts +++ b/packages/server/src/client/daemon-client-terminal-stream-manager.test.ts @@ -40,7 +40,18 @@ describe("TerminalStreamManager", () => { expect(seen).toEqual(["hello"]); expect(sendAck).toHaveBeenCalledTimes(1); - expect(sendAck).toHaveBeenCalledWith({ streamId: 7, offset: 9 }); + }); + + test("acks buffered chunks even while no subscriber is attached", () => { + const sendAck = vi.fn(); + const manager = new TerminalStreamManager({ sendAck }); + + manager.receiveChunk({ + chunk: createChunk({ streamId: 2, offset: 0, data: "abc" }), + }); + + expect(sendAck).toHaveBeenCalledTimes(1); + expect(sendAck).toHaveBeenCalledWith({ streamId: 2, offset: 3 }); }); test("does not ack when every handler throws", () => { diff --git a/packages/server/src/client/daemon-client-terminal-stream-manager.ts b/packages/server/src/client/daemon-client-terminal-stream-manager.ts index 89a32232a..26310c3d6 100644 --- a/packages/server/src/client/daemon-client-terminal-stream-manager.ts +++ b/packages/server/src/client/daemon-client-terminal-stream-manager.ts @@ -79,8 +79,6 @@ export class TerminalStreamManager { const streamHandlers = this.handlers.get(chunk.streamId); if (!streamHandlers || streamHandlers.size === 0) { this.bufferChunk({ chunk }); - // Ack buffered chunks immediately to keep the daemon-side stream window - // moving even before a UI subscriber is attached. this.maybeAckChunk({ streamId: chunk.streamId, endOffset: chunk.endOffset, diff --git a/packages/server/src/client/daemon-client.test.ts b/packages/server/src/client/daemon-client.test.ts index d3ecaa215..6c934683d 100644 --- a/packages/server/src/client/daemon-client.test.ts +++ b/packages/server/src/client/daemon-client.test.ts @@ -347,6 +347,63 @@ describe("DaemonClient", () => { }); }); + test("requests directory suggestions via RPC", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + + const client = new DaemonClient({ + url: "ws://test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const promise = client.getDirectorySuggestions( + { query: "proj", limit: 10 }, + "req-directories" + ); + + expect(mock.sent).toHaveLength(1); + const request = JSON.parse(mock.sent[0]) as { + type: "session"; + message: { + type: "directory_suggestions_request"; + query: string; + limit?: number; + requestId: string; + }; + }; + expect(request.message.type).toBe("directory_suggestions_request"); + expect(request.message.query).toBe("proj"); + expect(request.message.limit).toBe(10); + expect(request.message.requestId).toBe("req-directories"); + + mock.triggerMessage( + JSON.stringify({ + type: "session", + message: { + type: "directory_suggestions_response", + payload: { + directories: ["/Users/test/projects/paseo"], + error: null, + requestId: "req-directories", + }, + }, + }) + ); + + await expect(promise).resolves.toEqual({ + directories: ["/Users/test/projects/paseo"], + error: null, + requestId: "req-directories", + }); + }); + test("requests checkout merge from base via RPC", async () => { const logger = createMockLogger(); const mock = createMockTransport(); @@ -478,6 +535,7 @@ describe("DaemonClient", () => { { key: "created_at", direction: "desc" }, ], page: { limit: 25, cursor: "cursor-1" }, + subscribe: { subscriptionId: "sub-1" }, }); expect(mock.sent).toHaveLength(1); @@ -492,6 +550,7 @@ describe("DaemonClient", () => { direction: "asc" | "desc"; }>; page?: { limit: number; cursor?: string }; + subscribe?: { subscriptionId?: string }; }; }; expect(request.message.type).toBe("fetch_agents_request"); @@ -500,6 +559,7 @@ describe("DaemonClient", () => { { key: "created_at", direction: "desc" }, ]); expect(request.message.page).toEqual({ limit: 25, cursor: "cursor-1" }); + expect(request.message.subscribe).toEqual({ subscriptionId: "sub-1" }); mock.triggerMessage( JSON.stringify({ @@ -508,6 +568,7 @@ describe("DaemonClient", () => { type: "fetch_agents_response", payload: { requestId: request.message.requestId, + subscriptionId: "sub-1", entries: [], pageInfo: { nextCursor: null, @@ -521,6 +582,7 @@ describe("DaemonClient", () => { await expect(promise).resolves.toEqual({ requestId: request.message.requestId, + subscriptionId: "sub-1", entries: [], pageInfo: { nextCursor: null, @@ -707,6 +769,139 @@ describe("DaemonClient", () => { }); }); + test("lists commands with draft config via RPC", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + + const client = new DaemonClient({ + url: "ws://test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const promise = client.listCommands("__new_agent__", { + draftConfig: { + provider: "codex", + cwd: "/tmp/project", + modeId: "bypassPermissions", + model: "gpt-5", + thinkingOptionId: "off", + }, + }); + expect(mock.sent).toHaveLength(1); + + const request = JSON.parse(mock.sent[0]) as { + type: "session"; + message: { + type: "list_commands_request"; + agentId: string; + draftConfig?: { + provider: string; + cwd: string; + modeId?: string; + model?: string; + thinkingOptionId?: string; + }; + requestId: string; + }; + }; + expect(request.message.type).toBe("list_commands_request"); + expect(request.message.agentId).toBe("__new_agent__"); + expect(request.message.draftConfig).toEqual({ + provider: "codex", + cwd: "/tmp/project", + modeId: "bypassPermissions", + model: "gpt-5", + thinkingOptionId: "off", + }); + + mock.triggerMessage( + JSON.stringify({ + type: "session", + message: { + type: "list_commands_response", + payload: { + agentId: "__new_agent__", + commands: [ + { name: "help", description: "Show help", argumentHint: "" }, + ], + error: null, + requestId: request.message.requestId, + }, + }, + }) + ); + + await expect(promise).resolves.toEqual({ + agentId: "__new_agent__", + commands: [{ name: "help", description: "Show help", argumentHint: "" }], + error: null, + requestId: request.message.requestId, + }); + }); + + test("lists commands with legacy requestId signature via RPC", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + + const client = new DaemonClient({ + url: "ws://test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const promise = client.listCommands("agent-1", "req-legacy"); + expect(mock.sent).toHaveLength(1); + + const request = JSON.parse(mock.sent[0]) as { + type: "session"; + message: { + type: "list_commands_request"; + agentId: string; + draftConfig?: unknown; + requestId: string; + }; + }; + expect(request.message.type).toBe("list_commands_request"); + expect(request.message.agentId).toBe("agent-1"); + expect(request.message.requestId).toBe("req-legacy"); + expect(request.message.draftConfig).toBeUndefined(); + + mock.triggerMessage( + JSON.stringify({ + type: "session", + message: { + type: "list_commands_response", + payload: { + agentId: "agent-1", + commands: [], + error: null, + requestId: "req-legacy", + }, + }, + }) + ); + + await expect(promise).resolves.toEqual({ + agentId: "agent-1", + commands: [], + error: null, + requestId: "req-legacy", + }); + }); + test("auto-acks terminal stream chunks after delivery", async () => { const logger = createMockLogger(); const mock = createMockTransport(); @@ -753,6 +948,48 @@ describe("DaemonClient", () => { unsubscribe(); }); + test("handles terminal binary frames delivered as UTF-8 strings", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + + const client = new DaemonClient({ + url: "ws://test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const seen: string[] = []; + client.onTerminalStreamData(11, (chunk) => { + seen.push(new TextDecoder().decode(chunk.data)); + }); + + const payload = new TextEncoder().encode("ls\r\n"); + const frame = encodeBinaryMuxFrame({ + channel: BinaryMuxChannel.Terminal, + messageType: TerminalBinaryMessageType.OutputUtf8, + streamId: 11, + offset: 0, + payload, + }); + const frameAsString = new TextDecoder("utf-8", { fatal: true }).decode(frame); + + mock.triggerMessage(frameAsString); + + expect(seen).toEqual(["ls\r\n"]); + expect(mock.sent).toHaveLength(1); + const ackFrame = decodeBinaryMuxFrame(asUint8Array(mock.sent[0])!); + expect(ackFrame?.channel).toBe(BinaryMuxChannel.Terminal); + expect(ackFrame?.messageType).toBe(TerminalBinaryMessageType.Ack); + expect(ackFrame?.streamId).toBe(11); + expect(ackFrame?.offset).toBe(4); + }); + test("acks buffered terminal chunks when handler is attached", async () => { const logger = createMockLogger(); const mock = createMockTransport(); @@ -1194,4 +1431,89 @@ describe("DaemonClient", () => { expect(received).toHaveLength(0); expect(logger.warn).toHaveBeenCalled(); }); + + test("sends subscribe/unsubscribe terminals messages", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + + const client = new DaemonClient({ + url: "ws://test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + client.subscribeTerminals({ cwd: "/tmp/project" }); + client.unsubscribeTerminals({ cwd: "/tmp/project" }); + + expect(mock.sent).toHaveLength(2); + expect(JSON.parse(String(mock.sent[0]))).toEqual({ + type: "session", + message: { + type: "subscribe_terminals_request", + cwd: "/tmp/project", + }, + }); + expect(JSON.parse(String(mock.sent[1]))).toEqual({ + type: "session", + message: { + type: "unsubscribe_terminals_request", + cwd: "/tmp/project", + }, + }); + }); + + test("dispatches terminals_changed events to typed listeners", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + + const client = new DaemonClient({ + url: "ws://test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const received: Array<{ cwd: string; names: string[] }> = []; + const unsubscribe = client.on("terminals_changed", (message) => { + received.push({ + cwd: message.payload.cwd, + names: message.payload.terminals.map((terminal) => terminal.name), + }); + }); + + mock.triggerMessage( + wrapSessionMessage({ + type: "terminals_changed", + payload: { + cwd: "/tmp/project", + terminals: [ + { + id: "term-1", + name: "Dev Server", + }, + ], + }, + }) + ); + + unsubscribe(); + + expect(received).toEqual([ + { + cwd: "/tmp/project", + names: ["Dev Server"], + }, + ]); + }); }); diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index e11f114fb..d4c41001a 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -26,6 +26,7 @@ import type { CheckoutPrStatusResponse, ValidateBranchResponse, BranchSuggestionsResponse, + DirectorySuggestionsResponse, PaseoWorktreeListResponse, PaseoWorktreeArchiveResponse, ProjectIconResponse, @@ -197,6 +198,7 @@ type CheckoutPrCreatePayload = CheckoutPrCreateResponse["payload"]; type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"]; type ValidateBranchPayload = ValidateBranchResponse["payload"]; type BranchSuggestionsPayload = BranchSuggestionsResponse["payload"]; +type DirectorySuggestionsPayload = DirectorySuggestionsResponse["payload"]; type PaseoWorktreeListPayload = PaseoWorktreeListResponse["payload"]; type PaseoWorktreeArchivePayload = PaseoWorktreeArchiveResponse["payload"]; type FileExplorerPayload = FileExplorerResponse["payload"]; @@ -207,6 +209,14 @@ type SpeechModelsListPayload = SpeechModelsListResponse["payload"]; type SpeechModelsDownloadPayload = SpeechModelsDownloadResponse["payload"]; type ListCommandsPayload = ListCommandsResponse["payload"]; type ExecuteCommandPayload = ExecuteCommandResponse["payload"]; +type ListCommandsDraftConfig = Pick< + AgentSessionConfig, + "provider" | "cwd" | "modeId" | "model" | "thinkingOptionId" +>; +type ListCommandsOptions = { + requestId?: string; + draftConfig?: ListCommandsDraftConfig; +}; type SetVoiceModePayload = Extract< SessionOutboundMessage, { type: "set_voice_mode_response" } @@ -348,14 +358,11 @@ export class DaemonClient { private connectReject: ((error: Error) => void) | null = null; private lastErrorValue: string | null = null; private connectionState: ConnectionState = { status: "idle" }; - private agentUpdateSubscriptions = new Map< - string, - { labels?: Record; agentId?: string } | undefined - >(); private checkoutDiffSubscriptions = new Map< string, { cwd: string; compare: { mode: "uncommitted" | "base"; baseRef?: string } } >(); + private terminalDirectorySubscriptions = new Set(); private logger: Logger; private pendingSendQueue: PendingSend[] = []; private relayClientId: string | null = null; @@ -472,8 +479,8 @@ export class DaemonClient { this.lastErrorValue = null; this.reconnectAttempt = 0; this.updateConnectionState({ status: "connected" }); - this.resubscribeAgentUpdates(); this.resubscribeCheckoutDiffSubscriptions(); + this.resubscribeTerminalDirectorySubscriptions(); this.flushPendingSendQueue(); this.resolveConnect(); }), @@ -993,6 +1000,7 @@ export class DaemonClient { ...(options?.filter ? { filter: options.filter } : {}), ...(options?.sort ? { sort: options.sort } : {}), ...(options?.page ? { page: options.page } : {}), + ...(options?.subscribe ? { subscribe: options.subscribe } : {}), }); return this.sendRequest({ requestId: resolvedRequestId, @@ -1039,44 +1047,6 @@ export class DaemonClient { return payload.agent; } - subscribeAgentUpdates(options?: { - subscriptionId?: string; - filter?: { labels?: Record; agentId?: string }; - }): string { - const subscriptionId = options?.subscriptionId ?? crypto.randomUUID(); - this.agentUpdateSubscriptions.set(subscriptionId, options?.filter); - const message = SessionInboundMessageSchema.parse({ - type: "subscribe_agent_updates", - subscriptionId, - ...(options?.filter ? { filter: options.filter } : {}), - }); - this.sendSessionMessage(message); - return subscriptionId; - } - - unsubscribeAgentUpdates(subscriptionId: string): void { - this.agentUpdateSubscriptions.delete(subscriptionId); - const message = SessionInboundMessageSchema.parse({ - type: "unsubscribe_agent_updates", - subscriptionId, - }); - this.sendSessionMessage(message); - } - - private resubscribeAgentUpdates(): void { - if (this.agentUpdateSubscriptions.size === 0) { - return; - } - for (const [subscriptionId, filter] of this.agentUpdateSubscriptions) { - const message = SessionInboundMessageSchema.parse({ - type: "subscribe_agent_updates", - subscriptionId, - ...(filter ? { filter } : {}), - }); - this.sendSessionMessage(message); - } - } - private resubscribeCheckoutDiffSubscriptions(): void { if (this.checkoutDiffSubscriptions.size === 0) { return; @@ -1093,6 +1063,18 @@ export class DaemonClient { } } + private resubscribeTerminalDirectorySubscriptions(): void { + if (this.terminalDirectorySubscriptions.size === 0) { + return; + } + for (const cwd of this.terminalDirectorySubscriptions) { + this.sendSessionMessage({ + type: "subscribe_terminals_request", + cwd, + }); + } + } + // ============================================================================ // Agent Lifecycle // ============================================================================ @@ -2068,6 +2050,22 @@ export class DaemonClient { }); } + async getDirectorySuggestions( + options: { query: string; limit?: number }, + requestId?: string + ): Promise { + return this.sendCorrelatedSessionRequest({ + requestId, + message: { + type: "directory_suggestions_request", + query: options.query, + limit: options.limit, + }, + responseType: "directory_suggestions_response", + timeout: 10000, + }); + } + // ============================================================================ // File Explorer // ============================================================================ @@ -2181,15 +2179,30 @@ export class DaemonClient { }); } + async listCommands(agentId: string, requestId?: string): Promise; async listCommands( agentId: string, - requestId?: string + options?: ListCommandsOptions + ): Promise; + async listCommands( + agentId: string, + requestIdOrOptions?: string | ListCommandsOptions ): Promise { + const requestId = + typeof requestIdOrOptions === "string" + ? requestIdOrOptions + : requestIdOrOptions?.requestId; + const draftConfig = + typeof requestIdOrOptions === "string" + ? undefined + : requestIdOrOptions?.draftConfig; + return this.sendCorrelatedSessionRequest({ requestId, message: { type: "list_commands_request", agentId, + ...(draftConfig ? { draftConfig } : {}), }, responseType: "list_commands_response", timeout: 30000, @@ -2314,6 +2327,28 @@ export class DaemonClient { // Terminals // ============================================================================ + subscribeTerminals(input: { cwd: string }): void { + this.terminalDirectorySubscriptions.add(input.cwd); + if (!this.transport || this.connectionState.status !== "connected") { + return; + } + this.sendSessionMessage({ + type: "subscribe_terminals_request", + cwd: input.cwd, + }); + } + + unsubscribeTerminals(input: { cwd: string }): void { + this.terminalDirectorySubscriptions.delete(input.cwd); + if (!this.transport || this.connectionState.status !== "connected") { + return; + } + this.sendSessionMessage({ + type: "unsubscribe_terminals_request", + cwd: input.cwd, + }); + } + async listTerminals( cwd: string, requestId?: string diff --git a/packages/server/src/server/agent/agent-management-mcp.ts b/packages/server/src/server/agent/agent-management-mcp.ts index 06fa277dd..45f51c6b4 100644 --- a/packages/server/src/server/agent/agent-management-mcp.ts +++ b/packages/server/src/server/agent/agent-management-mcp.ts @@ -46,7 +46,10 @@ import { toAgentPayload } from "./agent-projections.js"; import { curateAgentActivity } from "./activity-curator.js"; import { AGENT_PROVIDER_DEFINITIONS } from "./provider-registry.js"; import { AgentStorage } from "./agent-storage.js"; -import { appendTimelineItemIfAgentKnown } from "./timeline-append.js"; +import { + appendTimelineItemIfAgentKnown, + emitLiveTimelineItemIfAgentKnown, +} from "./timeline-append.js"; import { type WorktreeConfig } from "../../utils/worktree.js"; import { WaitForAgentTracker } from "./wait-for-agent-tracker.js"; import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js"; @@ -357,6 +360,12 @@ export async function createAgentManagementMcpServer( agentId: snapshot.id, item, }), + emitLiveTimelineItem: (item) => + emitLiveTimelineItemIfAgentKnown({ + agentManager, + agentId: snapshot.id, + item, + }), logger: childLogger, }); } @@ -374,7 +383,9 @@ export async function createAgentManagementMcpServer( }); try { - agentManager.recordUserMessage(snapshot.id, trimmedPrompt); + agentManager.recordUserMessage(snapshot.id, trimmedPrompt, { + emitState: false, + }); } catch (error) { childLogger.error( { err: error, agentId: snapshot.id }, @@ -599,7 +610,9 @@ export async function createAgentManagementMcpServer( } try { - agentManager.recordUserMessage(agentId, prompt); + agentManager.recordUserMessage(agentId, prompt, { + emitState: false, + }); } catch (error) { childLogger.error( { err: error, agentId }, diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 86a74d620..6ef4c77fa 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "vitest"; +import { describe, expect, test, vi } from "vitest"; import { mkdtempSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; @@ -351,6 +351,150 @@ describe("AgentManager", () => { expect(afterHydrate).toEqual(beforeReload); }); + test("reloadAgentSession cancels active run and resumes existing session once thread_started is observed", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-active-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + + class DelayedPersistenceSession extends TestAgentSession { + private persistenceReady = false; + private interrupted = false; + private releaseGate: (() => void) | null = null; + private readonly gate = new Promise((resolve) => { + this.releaseGate = resolve; + }); + + constructor( + config: AgentSessionConfig, + private readonly stableSessionId: string, + initiallyReady = false + ) { + super(config); + this.persistenceReady = initiallyReady; + } + + async *stream(): AsyncGenerator { + yield { type: "turn_started", provider: this.provider }; + this.persistenceReady = true; + yield { + type: "thread_started", + provider: this.provider, + sessionId: this.stableSessionId, + }; + await this.gate; + if (this.interrupted) { + yield { type: "turn_canceled", provider: this.provider, reason: "Interrupted" }; + return; + } + yield { type: "turn_completed", provider: this.provider }; + } + + async getRuntimeInfo() { + return { + provider: this.provider, + sessionId: this.persistenceReady ? this.stableSessionId : null, + model: null, + modeId: null, + }; + } + + describePersistence() { + if (!this.persistenceReady) { + return null; + } + return { + provider: this.provider, + sessionId: this.stableSessionId, + }; + } + + async interrupt(): Promise { + this.interrupted = true; + this.releaseGate?.(); + } + + async close(): Promise { + this.interrupted = true; + this.releaseGate?.(); + } + } + + class DelayedPersistenceClient implements AgentClient { + readonly provider = "codex" as const; + readonly capabilities = TEST_CAPABILITIES; + createSessionCalls = 0; + resumeSessionCalls = 0; + private nextSessionNumber = 1; + + async isAvailable(): Promise { + return true; + } + + async createSession(config: AgentSessionConfig): Promise { + const sessionId = `delayed-session-${this.nextSessionNumber++}`; + this.createSessionCalls += 1; + return new DelayedPersistenceSession(config, sessionId); + } + + async resumeSession( + handle: AgentPersistenceHandle, + overrides?: Partial + ): Promise { + this.resumeSessionCalls += 1; + const metadata = (handle.metadata ?? {}) as Partial; + const merged: AgentSessionConfig = { + ...metadata, + ...overrides, + provider: "codex", + cwd: overrides?.cwd ?? metadata.cwd ?? process.cwd(), + }; + return new DelayedPersistenceSession(merged, handle.sessionId, true); + } + } + + const client = new DelayedPersistenceClient(); + const manager = new AgentManager({ + clients: { codex: client }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000114", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); + expect(snapshot.persistence).toBeNull(); + + const stream = manager.streamAgent(snapshot.id, "hello"); + const first = await stream.next(); + expect(first.done).toBe(false); + expect(first.value?.type).toBe("turn_started"); + const second = await stream.next(); + expect(second.done).toBe(false); + expect(second.value?.type).toBe("thread_started"); + + const active = manager.getAgent(snapshot.id); + expect(active?.lifecycle).toBe("running"); + expect(active?.persistence?.sessionId).toBe("delayed-session-1"); + + const reloaded = await manager.reloadAgentSession(snapshot.id, { + systemPrompt: "voice mode on", + }); + + expect(client.createSessionCalls).toBe(1); + expect(client.resumeSessionCalls).toBe(1); + expect(reloaded.persistence?.sessionId).toBe("delayed-session-1"); + + // Drain stream after cancellation to ensure clean shutdown. + while (true) { + const next = await stream.next(); + if (next.done) { + break; + } + } + }); + test("fetchTimeline returns full timeline with reset when cursor epoch is stale", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-timeline-stale-")); const storagePath = join(workdir, "agents"); @@ -405,6 +549,66 @@ describe("AgentManager", () => { expect(result.rows[result.rows.length - 1]?.seq).toBe(3); }); + test("emits live timeline updates without recording canonical timeline rows", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-timeline-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000120", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); + + const streamEvents: Array<{ + seq?: number; + epoch?: string; + eventType?: string; + itemType?: string; + }> = []; + manager.subscribe( + (event) => { + if (event.type !== "agent_stream") { + return; + } + streamEvents.push({ + seq: event.seq, + epoch: event.epoch, + eventType: event.event.type, + itemType: event.event.type === "timeline" ? event.event.item.type : undefined, + }); + }, + { agentId: snapshot.id, replayState: false } + ); + + await manager.emitLiveTimelineItem(snapshot.id, { + type: "assistant_message", + text: "live-only update", + }); + + expect(streamEvents).toHaveLength(1); + expect(streamEvents[0]).toMatchObject({ + eventType: "timeline", + itemType: "assistant_message", + }); + expect(streamEvents[0]?.seq).toBeUndefined(); + expect(streamEvents[0]?.epoch).toBeUndefined(); + + expect(manager.getTimeline(snapshot.id)).toEqual([]); + const fetched = manager.fetchTimeline(snapshot.id, { + direction: "tail", + limit: 0, + }); + expect(fetched.rows).toEqual([]); + }); + test("fetchTimeline returns full timeline with reset when cursor seq falls behind retention window", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-timeline-gap-")); const storagePath = join(workdir, "agents"); @@ -588,6 +792,74 @@ describe("AgentManager", () => { expect(refreshed?.runtimeInfo?.model).toBe("gpt-5.2-codex"); }); + test("keeps updatedAt monotonic when user message and run start happen in the same millisecond", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000120", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); + + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_750_000_000_000); + try { + manager.recordUserMessage(snapshot.id, "hello"); + const afterMessage = manager.getAgent(snapshot.id); + expect(afterMessage).toBeDefined(); + const messageUpdatedAt = afterMessage!.updatedAt.getTime(); + + const stream = manager.streamAgent(snapshot.id, "hello"); + const afterRunStart = manager.getAgent(snapshot.id); + expect(afterRunStart).toBeDefined(); + expect(afterRunStart!.updatedAt.getTime()).toBeGreaterThan(messageUpdatedAt); + + await stream.return(undefined); + } finally { + nowSpy.mockRestore(); + } + }); + + test("recordUserMessage can skip emitting agent_state when run start will emit running", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000121", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); + + const lifecycleUpdates: string[] = []; + manager.subscribe((event) => { + if (event.type !== "agent_state" || event.agent.id !== snapshot.id) { + return; + } + lifecycleUpdates.push(event.agent.lifecycle); + }); + lifecycleUpdates.length = 0; + + manager.recordUserMessage(snapshot.id, "hello", { emitState: false }); + + expect(lifecycleUpdates).toEqual([]); + }); + test("runAgent assembles finalText from trailing assistant chunks", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); const storagePath = join(workdir, "agents"); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 08003deba..f230e3984 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -11,6 +11,7 @@ import { z } from "zod"; import type { AgentCapabilityFlags, AgentClient, + AgentSlashCommand, AgentMode, AgentPermissionRequest, AgentPermissionResponse, @@ -307,6 +308,15 @@ export class AgentManager { this.onAgentAttention = callback; } + private touchUpdatedAt(agent: ManagedAgent): Date { + const nowMs = Date.now(); + const previousMs = agent.updatedAt.getTime(); + const nextMs = nowMs > previousMs ? nowMs : previousMs + 1; + const next = new Date(nextMs); + agent.updatedAt = next; + return next; + } + subscribe(callback: AgentSubscriber, options?: SubscribeOptions): () => void { const targetAgentId = options?.agentId == null @@ -428,6 +438,36 @@ export class AgentManager { return Promise.all(checks); } + async listDraftCommands(config: AgentSessionConfig): Promise { + const normalizedConfig = await this.normalizeConfig(config); + const client = this.requireClient(normalizedConfig.provider); + const available = await client.isAvailable(); + if (!available) { + throw new Error( + `Provider '${normalizedConfig.provider}' is not available. Please ensure the CLI is installed.` + ); + } + + const session = await client.createSession(normalizedConfig); + try { + if (!session.listCommands) { + throw new Error( + `Provider '${normalizedConfig.provider}' does not support listing commands` + ); + } + return await session.listCommands(); + } finally { + try { + await session.close(); + } catch (error) { + this.logger.warn( + { err: error, provider: normalizedConfig.provider }, + "Failed to close draft command listing session" + ); + } + } + } + getAgent(id: string): ManagedAgent | null { const agent = this.agents.get(id); return agent ? { ...agent } : null; @@ -657,7 +697,11 @@ export class AgentManager { agentId: string, overrides?: Partial ): Promise { - const existing = this.requireAgent(agentId); + let existing = this.requireAgent(agentId); + if (existing.lifecycle === "running" || existing.pendingRun) { + await this.cancelAgentRun(agentId); + existing = this.requireAgent(agentId); + } const timelineState = this.ensureTimelineState(existing); const preservedTimeline = [...existing.timeline]; const preservedTimelineRows = timelineState.rows.map((row) => ({ ...row })); @@ -847,7 +891,7 @@ export class AgentManager { recordUserMessage( agentId: string, text: string, - options?: { messageId?: string } + options?: { messageId?: string; emitState?: boolean } ): void { const agent = this.requireAgent(agentId); const item: AgentTimelineItem = { @@ -855,8 +899,8 @@ export class AgentManager { text, messageId: options?.messageId, }; - agent.updatedAt = new Date(); - agent.lastUserMessageAt = agent.updatedAt; + const updatedAt = this.touchUpdatedAt(agent); + agent.lastUserMessageAt = updatedAt; const row = this.recordTimeline(agent, item); this.dispatchStream(agentId, { type: "timeline", @@ -866,12 +910,14 @@ export class AgentManager { seq: row.seq, epoch: this.ensureTimelineState(agent).epoch, }); - this.emitState(agent); + if (options?.emitState !== false) { + this.emitState(agent); + } } async appendTimelineItem(agentId: string, item: AgentTimelineItem): Promise { const agent = this.requireAgent(agentId); - agent.updatedAt = new Date(); + this.touchUpdatedAt(agent); const row = this.recordTimeline(agent, item); this.dispatchStream(agentId, { type: "timeline", @@ -884,6 +930,19 @@ export class AgentManager { await this.persistSnapshot(agent); } + async emitLiveTimelineItem( + agentId: string, + item: AgentTimelineItem + ): Promise { + const agent = this.requireAgent(agentId); + this.touchUpdatedAt(agent); + this.dispatchStream(agentId, { + type: "timeline", + item, + provider: agent.provider, + }); + } + streamAgent( agentId: string, prompt: AgentPromptInput, @@ -955,7 +1014,7 @@ export class AgentManager { agent.lifecycle = "running"; // Bump updatedAt when lifecycle changes so downstream consumers can // deterministically order idle->running transitions. - agent.updatedAt = new Date(); + this.touchUpdatedAt(agent); self.emitState(agent); return streamForwarder; @@ -1527,7 +1586,7 @@ export class AgentManager { ): void { // Only update timestamp for live events, not history replay if (!options?.fromHistory) { - agent.updatedAt = new Date(); + this.touchUpdatedAt(agent); } let timelineRow: AgentTimelineRow | null = null; @@ -1537,9 +1596,13 @@ export class AgentManager { // Update persistence with the new session ID from the provider. // persistence.sessionId is the single source of truth for session identity. { + const previousSessionId = agent.persistence?.sessionId ?? null; const handle = agent.session.describePersistence(); if (handle) { agent.persistence = attachPersistenceCwd(handle, agent.cwd); + if (agent.persistence?.sessionId !== previousSessionId) { + this.emitState(agent); + } } } break; diff --git a/packages/server/src/server/agent/agent-metadata-generator.ts b/packages/server/src/server/agent/agent-metadata-generator.ts index cc6cfdf6c..bbee72f45 100644 --- a/packages/server/src/server/agent/agent-metadata-generator.ts +++ b/packages/server/src/server/agent/agent-metadata-generator.ts @@ -4,8 +4,10 @@ import type { Logger } from "pino"; import type { AgentManager } from "./agent-manager.js"; import { + DEFAULT_STRUCTURED_GENERATION_PROVIDERS, + StructuredAgentFallbackError, StructuredAgentResponseError, - generateStructuredAgentResponse, + generateStructuredAgentResponseWithFallback, } from "./agent-response-loop.js"; import { validateBranchSlug } from "../../utils/worktree.js"; import { @@ -14,12 +16,8 @@ import { type CheckoutStatusResult, } from "../../utils/checkout-git.js"; -const AUTO_GEN_PROVIDER = "codex" as const; -const AUTO_GEN_MODEL = "gpt-5.1-codex-mini"; -const AUTO_GEN_REASONING_EFFORT = "low"; - export type AgentMetadataGeneratorDeps = { - generateStructuredAgentResponse?: typeof generateStructuredAgentResponse; + generateStructuredAgentResponseWithFallback?: typeof generateStructuredAgentResponseWithFallback; getCheckoutStatus?: typeof getCheckoutStatus; renameCurrentBranch?: typeof renameCurrentBranch; }; @@ -148,7 +146,9 @@ export async function generateAndApplyAgentMetadata( return; } - const generator = options.deps?.generateStructuredAgentResponse ?? generateStructuredAgentResponse; + const generator = + options.deps?.generateStructuredAgentResponseWithFallback ?? + generateStructuredAgentResponseWithFallback; const getCheckoutStatusImpl = options.deps?.getCheckoutStatus ?? getCheckoutStatus; const renameCurrentBranchImpl = options.deps?.renameCurrentBranch ?? renameCurrentBranch; @@ -157,21 +157,22 @@ export async function generateAndApplyAgentMetadata( try { result = await generator({ manager: options.agentManager, - agentConfig: { - provider: AUTO_GEN_PROVIDER, - model: AUTO_GEN_MODEL, - thinkingOptionId: AUTO_GEN_REASONING_EFFORT, - cwd: options.cwd, - title: "Agent metadata generator", - internal: true, - }, + cwd: options.cwd, prompt: buildPrompt(needs), schema, schemaName: "AgentMetadata", maxRetries: 2, + providers: DEFAULT_STRUCTURED_GENERATION_PROVIDERS, + agentConfigOverrides: { + title: "Agent metadata generator", + internal: true, + }, }); } catch (error) { - if (error instanceof StructuredAgentResponseError) { + if ( + error instanceof StructuredAgentResponseError || + error instanceof StructuredAgentFallbackError + ) { options.logger.warn( { err: error, agentId: options.agentId }, "Structured metadata generation failed" diff --git a/packages/server/src/server/agent/agent-response-loop.test.ts b/packages/server/src/server/agent/agent-response-loop.test.ts index 8759ebc62..d0359e9b8 100644 --- a/packages/server/src/server/agent/agent-response-loop.test.ts +++ b/packages/server/src/server/agent/agent-response-loop.test.ts @@ -2,9 +2,12 @@ import { describe, it, expect } from "vitest"; import { z } from "zod"; import { getStructuredAgentResponse, + generateStructuredAgentResponseWithFallback, + StructuredAgentFallbackError, StructuredAgentResponseError, type AgentCaller, } from "./agent-response-loop.js"; +import type { AgentManager } from "./agent-manager.js"; function createScriptedCaller(responses: string[]) { const prompts: string[] = []; @@ -142,3 +145,134 @@ describe("getStructuredAgentResponse", () => { expect(result).toEqual({ value: 42 }); }); }); + +describe("generateStructuredAgentResponseWithFallback", () => { + const schema = z.object({ summary: z.string() }); + + function createManager(availability: Array<{ provider: string; available: boolean; error: string | null }>) { + return { + listProviderAvailability: async () => availability, + } as unknown as AgentManager; + } + + it("uses the first available provider in the waterfall", async () => { + const calls: Array<{ provider: string; model?: string }> = []; + const manager = createManager([ + { provider: "claude", available: true, error: null }, + { provider: "codex", available: true, error: null }, + { provider: "opencode", available: true, error: null }, + ]); + + const result = await generateStructuredAgentResponseWithFallback({ + manager, + cwd: "/tmp/project", + prompt: "Return JSON", + schema, + providers: [ + { provider: "claude", model: "haiku" }, + { provider: "codex", model: "gpt-5.1-codex-mini" }, + ], + runner: async (options) => { + calls.push({ + provider: options.agentConfig.provider, + model: options.agentConfig.model ?? undefined, + }); + return { summary: "ok" }; + }, + }); + + expect(result).toEqual({ summary: "ok" }); + expect(calls).toEqual([{ provider: "claude", model: "haiku" }]); + }); + + it("skips unavailable providers and uses the next available one", async () => { + const calls: Array<{ provider: string; model?: string }> = []; + const manager = createManager([ + { provider: "claude", available: false, error: "missing auth" }, + { provider: "codex", available: true, error: null }, + { provider: "opencode", available: true, error: null }, + ]); + + const result = await generateStructuredAgentResponseWithFallback({ + manager, + cwd: "/tmp/project", + prompt: "Return JSON", + schema, + providers: [ + { provider: "claude", model: "haiku" }, + { provider: "codex", model: "gpt-5.1-codex-mini" }, + ], + runner: async (options) => { + calls.push({ + provider: options.agentConfig.provider, + model: options.agentConfig.model ?? undefined, + }); + return { summary: "ok" }; + }, + }); + + expect(result).toEqual({ summary: "ok" }); + expect(calls).toEqual([{ provider: "codex", model: "gpt-5.1-codex-mini" }]); + }); + + it("falls back when an available provider fails", async () => { + const calls: Array<{ provider: string; model?: string }> = []; + const manager = createManager([ + { provider: "claude", available: true, error: null }, + { provider: "codex", available: true, error: null }, + { provider: "opencode", available: true, error: null }, + ]); + + const result = await generateStructuredAgentResponseWithFallback({ + manager, + cwd: "/tmp/project", + prompt: "Return JSON", + schema, + providers: [ + { provider: "claude", model: "haiku" }, + { provider: "codex", model: "gpt-5.1-codex-mini" }, + ], + runner: async (options) => { + calls.push({ + provider: options.agentConfig.provider, + model: options.agentConfig.model ?? undefined, + }); + if (options.agentConfig.provider === "claude") { + throw new Error("claude failed"); + } + return { summary: "ok" }; + }, + }); + + expect(result).toEqual({ summary: "ok" }); + expect(calls).toEqual([ + { provider: "claude", model: "haiku" }, + { provider: "codex", model: "gpt-5.1-codex-mini" }, + ]); + }); + + it("throws a fallback error when all providers are unavailable or fail", async () => { + const manager = createManager([ + { provider: "claude", available: false, error: "missing auth" }, + { provider: "codex", available: true, error: null }, + { provider: "opencode", available: false, error: "not installed" }, + ]); + + await expect( + generateStructuredAgentResponseWithFallback({ + manager, + cwd: "/tmp/project", + prompt: "Return JSON", + schema, + providers: [ + { provider: "claude", model: "haiku" }, + { provider: "codex", model: "gpt-5.1-codex-mini" }, + { provider: "opencode", model: "opencode/kimi-k2.5-free" }, + ], + runner: async () => { + throw new Error("failed"); + }, + }) + ).rejects.toBeInstanceOf(StructuredAgentFallbackError); + }); +}); diff --git a/packages/server/src/server/agent/agent-response-loop.ts b/packages/server/src/server/agent/agent-response-loop.ts index 02344ee89..4daf453e1 100644 --- a/packages/server/src/server/agent/agent-response-loop.ts +++ b/packages/server/src/server/agent/agent-response-loop.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { zodToJsonSchema } from "zod-to-json-schema"; import Ajv, { type ErrorObject, type Options as AjvOptions } from "ajv"; -import type { AgentSessionConfig } from "./agent-sdk-types.js"; +import type { AgentProvider, AgentSessionConfig } from "./agent-sdk-types.js"; import type { AgentManager } from "./agent-manager.js"; import { getAgentProviderDefinition } from "./provider-manifest.js"; @@ -21,6 +21,43 @@ export class StructuredAgentResponseError extends Error { } } +export type StructuredGenerationProvider = { + provider: AgentProvider; + model?: string; + thinkingOptionId?: string; +}; + +export type StructuredGenerationAttempt = { + provider: AgentProvider; + model: string | null; + available: boolean; + error: string | null; +}; + +export class StructuredAgentFallbackError extends Error { + readonly attempts: StructuredGenerationAttempt[]; + + constructor(attempts: StructuredGenerationAttempt[]) { + const summary = attempts + .map((attempt) => { + const modelSuffix = attempt.model ? ` (${attempt.model})` : ""; + if (!attempt.available) { + return `${attempt.provider}${modelSuffix}: unavailable${attempt.error ? ` (${attempt.error})` : ""}`; + } + return `${attempt.provider}${modelSuffix}: failed${attempt.error ? ` (${attempt.error})` : ""}`; + }) + .join("; "); + + super( + summary.length > 0 + ? `Structured generation failed for all providers: ${summary}` + : "Structured generation failed for all providers" + ); + this.name = "StructuredAgentFallbackError"; + this.attempts = attempts; + } +} + export interface StructuredAgentResponseOptions { caller: AgentCaller; prompt: string; @@ -39,6 +76,27 @@ export interface StructuredAgentGenerationOptions { schemaName?: string; } +export interface StructuredAgentGenerationWithFallbackOptions { + manager: AgentManager; + cwd: string; + prompt: string; + schema: z.ZodType | JsonSchema; + providers: readonly StructuredGenerationProvider[]; + agentConfigOverrides?: Omit< + AgentSessionConfig, + "provider" | "cwd" | "model" | "thinkingOptionId" + >; + maxRetries?: number; + schemaName?: string; + runner?: (options: StructuredAgentGenerationOptions) => Promise; +} + +export const DEFAULT_STRUCTURED_GENERATION_PROVIDERS: readonly StructuredGenerationProvider[] = [ + { provider: "claude", model: "haiku" }, + { provider: "codex", model: "gpt-5.1-codex-mini" }, + { provider: "opencode", model: "opencode/kimi-k2.5-free" }, +] as const; + interface SchemaValidator { jsonSchema: JsonSchema; validate: (value: unknown) => { ok: true; value: T } | { ok: false; errors: string[] }; @@ -293,3 +351,82 @@ export async function generateStructuredAgentResponse( } } } + +function errorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + return String(error); +} + +export async function generateStructuredAgentResponseWithFallback( + options: StructuredAgentGenerationWithFallbackOptions +): Promise { + const { + manager, + cwd, + prompt, + schema, + providers, + agentConfigOverrides, + maxRetries, + schemaName, + runner, + } = options; + + if (providers.length === 0) { + throw new StructuredAgentFallbackError([]); + } + + const runStructured = + runner ?? + ((input: StructuredAgentGenerationOptions) => + generateStructuredAgentResponse(input)); + const availability = await manager.listProviderAvailability(); + const availabilityByProvider = new Map( + availability.map((entry) => [entry.provider, entry]) + ); + const attempts: StructuredGenerationAttempt[] = []; + + for (const candidate of providers) { + const availabilityEntry = availabilityByProvider.get(candidate.provider); + if (availabilityEntry && !availabilityEntry.available) { + attempts.push({ + provider: candidate.provider, + model: candidate.model ?? null, + available: false, + error: availabilityEntry.error ?? null, + }); + continue; + } + + try { + const result = await runStructured({ + manager, + prompt, + schema, + maxRetries, + schemaName, + agentConfig: { + ...agentConfigOverrides, + provider: candidate.provider, + cwd, + ...(candidate.model ? { model: candidate.model } : {}), + ...(candidate.thinkingOptionId + ? { thinkingOptionId: candidate.thinkingOptionId } + : {}), + }, + }); + return result; + } catch (error) { + attempts.push({ + provider: candidate.provider, + model: candidate.model ?? null, + available: true, + error: errorMessage(error), + }); + } + } + + throw new StructuredAgentFallbackError(attempts); +} diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index d26ce1cbe..afffb5014 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -134,6 +134,21 @@ export type ToolCallDetail = type: "search"; query: string; } + | { + type: "worktree_setup"; + worktreePath: string; + branchName: string; + log: string; + commands: Array<{ + index: number; + command: string; + cwd: string; + status: "running" | "completed" | "failed"; + exitCode: number | null; + durationMs?: number; + }>; + truncated?: boolean; + } | { type: "unknown"; input: unknown | null; diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index 6b143c5c1..cfa6f664b 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -28,7 +28,10 @@ import { toAgentPayload } from "./agent-projections.js"; import { curateAgentActivity } from "./activity-curator.js"; import { AGENT_PROVIDER_DEFINITIONS } from "./provider-registry.js"; import { AgentStorage } from "./agent-storage.js"; -import { appendTimelineItemIfAgentKnown } from "./timeline-append.js"; +import { + appendTimelineItemIfAgentKnown, + emitLiveTimelineItemIfAgentKnown, +} from "./timeline-append.js"; import { type WorktreeConfig } from "../../utils/worktree.js"; import { WaitForAgentTracker } from "./wait-for-agent-tracker.js"; import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js"; @@ -563,6 +566,12 @@ export async function createAgentMcpServer( agentId: snapshot.id, item, }), + emitLiveTimelineItem: (item) => + emitLiveTimelineItemIfAgentKnown({ + agentManager, + agentId: snapshot.id, + item, + }), logger: childLogger, }); } @@ -579,7 +588,9 @@ export async function createAgentMcpServer( }); try { - agentManager.recordUserMessage(snapshot.id, trimmedPrompt); + agentManager.recordUserMessage(snapshot.id, trimmedPrompt, { + emitState: false, + }); } catch (error) { childLogger.error( { err: error, agentId: snapshot.id }, @@ -806,7 +817,9 @@ export async function createAgentMcpServer( } try { - agentManager.recordUserMessage(agentId, prompt); + agentManager.recordUserMessage(agentId, prompt, { + emitState: false, + }); } catch (error) { childLogger.error( { err: error, agentId }, diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index 3162a09bf..7bd4ae29d 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -1428,7 +1428,14 @@ class ClaudeAgentSession implements AgentSession { switch (message.type) { case "system": if (message.subtype === "init") { - this.handleSystemMessage(message); + const threadSessionId = this.handleSystemMessage(message); + if (threadSessionId) { + events.push({ + type: "thread_started", + provider: "claude", + sessionId: threadSessionId, + }); + } } else if (message.subtype === "status") { const status = (message as { status?: string }).status; if (status === "compacting") { @@ -1513,9 +1520,9 @@ class ClaudeAgentSession implements AgentSession { return events; } - private handleSystemMessage(message: SDKSystemMessage): void { + private handleSystemMessage(message: SDKSystemMessage): string | null { if (message.subtype !== "init") { - return; + return null; } const msg = message as unknown as { @@ -1533,13 +1540,15 @@ class ClaudeAgentSession implements AgentSession { : ""; const newSessionId = newSessionIdRaw.trim(); if (!newSessionId) { - return; + return null; } const existingSessionId = this.claudeSessionId; + let threadStartedSessionId: string | null = null; if (existingSessionId === null) { // First time setting session ID (empty → filled) - this is expected this.claudeSessionId = newSessionId; + threadStartedSessionId = newSessionId; this.logger.debug( { sessionId: newSessionId }, "Claude session ID set for the first time" @@ -1578,6 +1587,7 @@ class ClaudeAgentSession implements AgentSession { // Invalidate cached runtime info so it picks up the new model this.cachedRuntimeInfo = null; } + return threadStartedSessionId; } private convertUsage(message: SDKResultMessage): AgentUsage | undefined { diff --git a/packages/server/src/server/agent/timeline-append.ts b/packages/server/src/server/agent/timeline-append.ts index f68f6b848..d0b8e3ab9 100644 --- a/packages/server/src/server/agent/timeline-append.ts +++ b/packages/server/src/server/agent/timeline-append.ts @@ -21,3 +21,18 @@ export async function appendTimelineItemIfAgentKnown( throw error; } } + +export async function emitLiveTimelineItemIfAgentKnown( + options: AppendTimelineItemIfAgentKnownOptions +): Promise { + try { + await options.agentManager.emitLiveTimelineItem(options.agentId, options.item); + return true; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes("Unknown agent")) { + return false; + } + throw error; + } +} diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index ee4f3ed29..ce58cd97c 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -63,7 +63,7 @@ import type { AgentProvider, } from "./agent/agent-sdk-types.js"; import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js"; -import { acquirePidLock, releasePidLock, isLocked, PidLockError } from "./pid-lock.js"; +import { acquirePidLock, releasePidLock } from "./pid-lock.js"; import { isHostAllowed, type AllowedHostsConfig } from "./allowed-hosts.js"; import { createVoiceMcpSocketBridgeManager, @@ -137,16 +137,6 @@ export async function createPaseoDaemon( ): Promise { const logger = rootLogger.child({ module: "bootstrap" }); - // Fail fast before expensive bootstrap (speech/runtime/model checks) if another daemon is active. - const lockState = await isLocked(config.paseoHome); - if (lockState.locked) { - const existingLock = lockState.info; - throw new PidLockError( - `Another Paseo daemon is already running (PID ${existingLock?.pid ?? "unknown"}, started ${existingLock?.startedAt ?? "unknown"})`, - existingLock - ); - } - // Acquire PID lock before expensive bootstrap work so duplicate starts fail immediately. await acquirePidLock(config.paseoHome, config.listen); diff --git a/packages/server/src/server/daemon-client.e2e.test.ts b/packages/server/src/server/daemon-client.e2e.test.ts index b23b6183a..2e1e4537e 100644 --- a/packages/server/src/server/daemon-client.e2e.test.ts +++ b/packages/server/src/server/daemon-client.e2e.test.ts @@ -168,6 +168,32 @@ describe("daemon client E2E", () => { rmSync(cwd, { recursive: true, force: true }); }, 30000); + test("returns home-scoped directory suggestions", async () => { + const insideHomeDir = mkdtempSync(path.join(homedir(), "paseo-dir-suggestion-")); + const outsideHomeDir = mkdtempSync(path.join(tmpdir(), "paseo-dir-suggestion-outside-")); + + try { + const insideQuery = path.basename(insideHomeDir); + const insideResult = await ctx.client.getDirectorySuggestions({ + query: insideQuery, + limit: 25, + }); + expect(insideResult.error).toBeNull(); + expect(insideResult.directories).toContain(insideHomeDir); + + const outsideQuery = path.basename(outsideHomeDir); + const outsideResult = await ctx.client.getDirectorySuggestions({ + query: outsideQuery, + limit: 25, + }); + expect(outsideResult.error).toBeNull(); + expect(outsideResult.directories).not.toContain(outsideHomeDir); + } finally { + rmSync(insideHomeDir, { recursive: true, force: true }); + rmSync(outsideHomeDir, { recursive: true, force: true }); + } + }, 30000); + test("emits server_info on websocket connect", async () => { const client = new DaemonClient({ url: `ws://127.0.0.1:${ctx.daemon.port}/ws`, @@ -265,7 +291,9 @@ describe("daemon client E2E", () => { async () => { const cwd = tmpCwd(); - ctx.client.subscribeAgentUpdates(); + await ctx.client.fetchAgents({ + subscribe: { subscriptionId: "daemon-client-lifecycle" }, + }); const agentUpdatePromise = waitForSignal(15000, (resolve) => { const unsubscribe = ctx.client.on("agent_update", (message) => { diff --git a/packages/server/src/server/daemon-e2e/agent-operations.e2e.test.ts b/packages/server/src/server/daemon-e2e/agent-operations.e2e.test.ts index 47f3091a8..a471c4ba3 100644 --- a/packages/server/src/server/daemon-e2e/agent-operations.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/agent-operations.e2e.test.ts @@ -143,7 +143,9 @@ describe("daemon E2E", () => { async () => { const cwd = tmpCwd(); - ctx.client.subscribeAgentUpdates(); + await ctx.client.fetchAgents({ + subscribe: { subscriptionId: "agent-operations-cancel" }, + }); // Create Codex agent const agent = await ctx.client.createAgent({ @@ -217,7 +219,9 @@ describe("daemon E2E", () => { async () => { const cwd = tmpCwd(); - ctx.client.subscribeAgentUpdates(); + await ctx.client.fetchAgents({ + subscribe: { subscriptionId: "agent-operations-mode" }, + }); // Create a Codex agent with default mode ("auto") const agent = await ctx.client.createAgent({ diff --git a/packages/server/src/server/daemon-e2e/connection-offer.e2e.test.ts b/packages/server/src/server/daemon-e2e/connection-offer.e2e.test.ts index eff0746a7..1bbe3d276 100644 --- a/packages/server/src/server/daemon-e2e/connection-offer.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/connection-offer.e2e.test.ts @@ -186,7 +186,8 @@ describe("ConnectionOfferV2 (daemon E2E)", () => { PASEO_HOME: tempHome, PASEO_LISTEN: `0.0.0.0:${port}`, OPENAI_API_KEY: "", - PASEO_LOCAL_AUTO_DOWNLOAD: "0", + PASEO_DICTATION_ENABLED: "0", + PASEO_VOICE_MODE_ENABLED: "0", PASEO_LOG_FORMAT: "json", }; diff --git a/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts b/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts index cd087b4c5..4253f0b79 100644 --- a/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts @@ -7,7 +7,7 @@ import { type DaemonTestContext, } from "../test-utils/index.js"; import { createMessageCollector, type MessageCollector } from "../test-utils/message-collector.js"; -import { slugify } from "../../utils/worktree.js"; +import { deriveWorktreeProjectHash } from "../../utils/worktree.js"; import type { AgentTimelineItem } from "../agent/agent-sdk-types.js"; import type { AgentSnapshotPayload, SessionOutboundMessage } from "../messages.js"; @@ -105,6 +105,43 @@ async function waitForPathExists( ); } +async function withShell(shell: string, run: () => Promise): Promise { + const originalShell = process.env.SHELL; + process.env.SHELL = shell; + try { + return await run(); + } finally { + if (originalShell === undefined) { + delete process.env.SHELL; + } else { + process.env.SHELL = originalShell; + } + } +} + +interface WorktreeTerminalBootstrapEntry { + name: string | null; + command: string; + status: "started" | "failed"; + terminalId: string | null; + error: string | null; +} + +function getWorktreeTerminalBootstrapEntries( + item: Extract +): WorktreeTerminalBootstrapEntry[] | null { + const detail = item.detail; + if (!detail || detail.type !== "unknown" || !detail.output) { + return null; + } + const output = detail.output as Record; + const terminals = output.terminals; + if (!Array.isArray(terminals)) { + return null; + } + return terminals as WorktreeTerminalBootstrapEntry[]; +} + // Use gpt-5.1-codex-mini with low thinking preset for faster test execution const CODEX_TEST_MODEL = "gpt-5.1-codex-mini"; const CODEX_TEST_THINKING_OPTION_ID = "low"; @@ -397,9 +434,10 @@ describe("daemon E2E", () => { ); expect(completed.callId).toBeTruthy(); - expect(completed.detail.type).toBe("unknown"); - if (completed.detail.type === "unknown") { - expect(completed.detail.output).toBeTruthy(); + expect(completed.detail.type).toBe("worktree_setup"); + if (completed.detail.type === "worktree_setup") { + expect(completed.detail.commands.length).toBeGreaterThan(0); + expect(completed.detail.log.length).toBeGreaterThan(0); } expect(existsSync(path.join(agent.cwd, "setup-done.txt"))).toBe(true); @@ -412,100 +450,136 @@ describe("daemon E2E", () => { test( "bootstraps configured worktree terminals after setup succeeds", async () => { - const repoRoot = tmpCwd(); + await withShell("/bin/sh", async () => { + const repoRoot = tmpCwd(); - const { execSync } = await import("child_process"); - execSync("git init -b main", { cwd: repoRoot, stdio: "pipe" }); - execSync("git config user.email 'test@test.com'", { - cwd: repoRoot, - stdio: "pipe", - }); - execSync("git config user.name 'Test'", { cwd: repoRoot, stdio: "pipe" }); - - writeFileSync(path.join(repoRoot, "file.txt"), "hello\n"); - execSync("git add .", { cwd: repoRoot, stdio: "pipe" }); - execSync("git -c commit.gpgsign=false commit -m 'initial'", { - cwd: repoRoot, - stdio: "pipe", - }); - execSync("git branch -M main", { cwd: repoRoot, stdio: "pipe" }); - - const setupCommand = - 'while [ ! -f "$PASEO_WORKTREE_PATH/allow-setup" ]; do sleep 0.05; done; echo "done" > "$PASEO_WORKTREE_PATH/setup-done.txt"'; - writeFileSync( - path.join(repoRoot, "paseo.json"), - JSON.stringify({ - worktree: { - setup: [setupCommand], - terminals: [ - { - name: "Dev Server", - command: 'echo "dev-server" > dev-terminal.txt; tail -f /dev/null', - }, - { - command: 'echo "lint-watch" > lint-terminal.txt; tail -f /dev/null', - }, - ], - }, - }) - ); - execSync("git add paseo.json", { cwd: repoRoot, stdio: "pipe" }); - execSync("git -c commit.gpgsign=false commit -m 'add setup and terminals'", { - cwd: repoRoot, - stdio: "pipe", - }); - - const agent = await withTimeout({ - promise: ctx.client.createAgent({ - provider: "codex", - model: CODEX_TEST_MODEL, - thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, + const { execSync } = await import("child_process"); + execSync("git init -b main", { cwd: repoRoot, stdio: "pipe" }); + execSync("git config user.email 'test@test.com'", { cwd: repoRoot, - title: "Async Worktree Setup + Terminals Test", - git: { - createWorktree: true, - createNewBranch: true, - baseBranch: "main", - newBranchName: "async-setup-terminals-test", - worktreeSlug: "async-setup-terminals-test", - }, - }), - timeoutMs: 2500, - label: "createAgent should not block on setup", + stdio: "pipe", + }); + execSync("git config user.name 'Test'", { cwd: repoRoot, stdio: "pipe" }); + + writeFileSync(path.join(repoRoot, "file.txt"), "hello\n"); + execSync("git add .", { cwd: repoRoot, stdio: "pipe" }); + execSync("git -c commit.gpgsign=false commit -m 'initial'", { + cwd: repoRoot, + stdio: "pipe", + }); + execSync("git branch -M main", { cwd: repoRoot, stdio: "pipe" }); + + const setupCommand = + 'while [ ! -f "$PASEO_WORKTREE_PATH/allow-setup" ]; do sleep 0.05; done; echo "done" > "$PASEO_WORKTREE_PATH/setup-done.txt"; echo "$PASEO_WORKTREE_PORT" > "$PASEO_WORKTREE_PATH/setup-port.txt"'; + writeFileSync( + path.join(repoRoot, "paseo.json"), + JSON.stringify({ + worktree: { + setup: [setupCommand], + terminals: [ + { + name: "Dev Server", + command: "tail -f /dev/null", + }, + { + command: "tail -f /dev/null", + }, + ], + }, + }) + ); + execSync("git add paseo.json", { cwd: repoRoot, stdio: "pipe" }); + execSync("git -c commit.gpgsign=false commit -m 'add setup and terminals'", { + cwd: repoRoot, + stdio: "pipe", + }); + + const agent = await withTimeout({ + promise: ctx.client.createAgent({ + provider: "codex", + model: CODEX_TEST_MODEL, + thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, + cwd: repoRoot, + title: "Async Worktree Setup + Terminals Test", + git: { + createWorktree: true, + createNewBranch: true, + baseBranch: "main", + newBranchName: "async-setup-terminals-test", + worktreeSlug: "async-setup-terminals-test", + }, + }), + timeoutMs: 2500, + label: "createAgent should not block on setup", + }); + + expect(agent.cwd).toContain(path.join(".paseo", "worktrees")); + expect(existsSync(path.join(agent.cwd, "setup-done.txt"))).toBe(false); + expect(existsSync(path.join(agent.cwd, "dev-terminal.txt"))).toBe(false); + expect(existsSync(path.join(agent.cwd, "lint-terminal.txt"))).toBe(false); + + writeFileSync(path.join(agent.cwd, "allow-setup"), "ok\n"); + + await waitForTimelineToolCall( + collector.messages, + agent.id, + (item) => item.name === "paseo_worktree_setup" && item.status === "completed", + 20000 + ); + const terminalsBootstrapToolCall = await waitForTimelineToolCall( + collector.messages, + agent.id, + (item) => item.name === "paseo_worktree_terminals" && item.status === "completed", + 30000 + ); + const bootstrappedTerminals = getWorktreeTerminalBootstrapEntries( + terminalsBootstrapToolCall + ); + expect(bootstrappedTerminals).toBeTruthy(); + expect(bootstrappedTerminals?.length ?? 0).toBeGreaterThanOrEqual(2); + const failedBootstraps = + bootstrappedTerminals?.filter((terminal) => terminal.status === "failed") ?? []; + expect(failedBootstraps).toEqual([]); + + const list = await ctx.client.listTerminals(agent.cwd); + expect(list.error).toBeUndefined(); + expect(list.terminals.some((terminal) => terminal.name === "Dev Server")).toBe(true); + expect(list.terminals.length).toBeGreaterThanOrEqual(2); + await waitForPathExists({ + targetPath: path.join(agent.cwd, "setup-port.txt"), + timeoutMs: 30000, + label: "setup runtime port marker", + }); + + const setupPort = readFileSync(path.join(agent.cwd, "setup-port.txt"), "utf8").trim(); + expect(setupPort.length).toBeGreaterThan(0); + + const createdTerminal = await ctx.client.createTerminal(agent.cwd, "Manual Port Check"); + expect(createdTerminal.error).toBeNull(); + expect(createdTerminal.terminal).toBeTruthy(); + const manualTerminalId = createdTerminal.terminal?.id; + expect(manualTerminalId).toBeTruthy(); + if (!manualTerminalId) { + throw new Error("Expected manual terminal id"); + } + ctx.client.sendTerminalInput(manualTerminalId, { + type: "input", + data: 'echo "$PASEO_WORKTREE_PORT" > "$PASEO_WORKTREE_PATH/manual-terminal-port.txt"\r', + }); + await waitForPathExists({ + targetPath: path.join(agent.cwd, "manual-terminal-port.txt"), + timeoutMs: 30000, + label: "manual terminal runtime port marker", + }); + const manualTerminalPort = readFileSync( + path.join(agent.cwd, "manual-terminal-port.txt"), + "utf8" + ).trim(); + expect(manualTerminalPort).toBe(setupPort); + + await ctx.client.deleteAgent(agent.id); + rmSync(repoRoot, { recursive: true, force: true }); }); - - expect(agent.cwd).toContain(path.join(".paseo", "worktrees")); - expect(existsSync(path.join(agent.cwd, "setup-done.txt"))).toBe(false); - expect(existsSync(path.join(agent.cwd, "dev-terminal.txt"))).toBe(false); - expect(existsSync(path.join(agent.cwd, "lint-terminal.txt"))).toBe(false); - - writeFileSync(path.join(agent.cwd, "allow-setup"), "ok\n"); - - await waitForTimelineToolCall( - collector.messages, - agent.id, - (item) => item.name === "paseo_worktree_setup" && item.status === "completed", - 20000 - ); - - await waitForPathExists({ - targetPath: path.join(agent.cwd, "dev-terminal.txt"), - timeoutMs: 15000, - label: "dev terminal marker", - }); - await waitForPathExists({ - targetPath: path.join(agent.cwd, "lint-terminal.txt"), - timeoutMs: 15000, - label: "lint terminal marker", - }); - - const list = await ctx.client.listTerminals(agent.cwd); - expect(list.error).toBeUndefined(); - expect(list.terminals.some((terminal) => terminal.name === "Dev Server")).toBe(true); - expect(list.terminals.length).toBeGreaterThanOrEqual(2); - - await ctx.client.deleteAgent(agent.id); - rmSync(repoRoot, { recursive: true, force: true }); }, 60000 ); @@ -595,10 +669,12 @@ describe("daemon E2E", () => { expect(existsSync(path.join(agent.cwd, "setup-start.txt"))).toBe(true); expect(existsSync(path.join(agent.cwd, "should-not-run.txt"))).toBe(false); - const output = failed.detail.type === "unknown" ? failed.detail.output as any : undefined; - const commands = output?.commands as any[] | undefined; - expect(Array.isArray(commands)).toBe(true); - expect(commands?.[0]?.exitCode).toBe(7); + expect(failed.detail.type).toBe("worktree_setup"); + if (failed.detail.type === "worktree_setup") { + expect(Array.isArray(failed.detail.commands)).toBe(true); + expect(failed.detail.commands[0]?.exitCode).toBe(7); + expect(failed.detail.log).toContain("Exit 7"); + } await ctx.client.deleteAgent(agent.id); rmSync(repoRoot, { recursive: true, force: true }); @@ -609,9 +685,10 @@ describe("daemon E2E", () => { describe("createAgent with worktree", () => { test( - "creates agent in ~/.paseo/worktrees/{project} when worktree is requested", + "creates agent in ~/.paseo/worktrees/{hash} when worktree is requested", async () => { const cwd = tmpCwd(); + const projectHash = await deriveWorktreeProjectHash(cwd); const { execSync } = await import("child_process"); execSync("git init -b main", { cwd, stdio: "pipe" }); @@ -648,7 +725,7 @@ describe("daemon E2E", () => { path.join( ctx.daemon.paseoHome, "worktrees", - slugify(path.basename(cwd)), + projectHash, "worktree-test" ) ) @@ -662,5 +739,98 @@ describe("daemon E2E", () => { ); }); + describe("archivePaseoWorktree", () => { + test( + "archives worktree by running destroy commands and shutting down worktree terminals", + async () => { + const repoRoot = tmpCwd(); + + const { execSync } = await import("child_process"); + execSync("git init -b main", { cwd: repoRoot, stdio: "pipe" }); + execSync("git config user.email 'test@test.com'", { + cwd: repoRoot, + stdio: "pipe", + }); + execSync("git config user.name 'Test'", { cwd: repoRoot, stdio: "pipe" }); + + writeFileSync(path.join(repoRoot, "file.txt"), "hello\n"); + execSync("git add .", { cwd: repoRoot, stdio: "pipe" }); + execSync("git -c commit.gpgsign=false commit -m 'initial'", { + cwd: repoRoot, + stdio: "pipe", + }); + execSync("git branch -M main", { cwd: repoRoot, stdio: "pipe" }); + + const destroyMarkerPath = path.join(repoRoot, "destroy-marker.txt"); + writeFileSync( + path.join(repoRoot, "paseo.json"), + JSON.stringify({ + worktree: { + terminals: [ + { + name: "Dev Server", + command: 'echo "dev-server" > dev-terminal.txt; tail -f /dev/null', + }, + ], + destroy: [ + `echo "$PASEO_WORKTREE_PATH" > "${destroyMarkerPath}"`, + ], + }, + }) + ); + execSync("git add paseo.json", { cwd: repoRoot, stdio: "pipe" }); + execSync("git -c commit.gpgsign=false commit -m 'add worktree terminal + destroy'", { + cwd: repoRoot, + stdio: "pipe", + }); + + const agent = await withTimeout({ + promise: ctx.client.createAgent({ + provider: "codex", + model: CODEX_TEST_MODEL, + thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, + cwd: repoRoot, + title: "Worktree Archive Cleanup Test", + git: { + createWorktree: true, + createNewBranch: true, + baseBranch: "main", + newBranchName: "archive-cleanup-test", + worktreeSlug: "archive-cleanup-test", + }, + }), + timeoutMs: 2500, + label: "createAgent should not block on setup", + }); + + await waitForPathExists({ + targetPath: path.join(agent.cwd, "dev-terminal.txt"), + timeoutMs: 15000, + label: "worktree terminal marker", + }); + + const beforeArchiveDirectories = ctx.daemon.daemon.terminalManager.listDirectories(); + expect(beforeArchiveDirectories).toContain(agent.cwd); + + const archive = await ctx.client.archivePaseoWorktree({ + worktreePath: agent.cwd, + }); + expect(archive.error).toBeNull(); + expect(archive.success).toBe(true); + expect(archive.removedAgents).toContain(agent.id); + + expect(existsSync(agent.cwd)).toBe(false); + expect(existsSync(destroyMarkerPath)).toBe(true); + expect(readFileSync(destroyMarkerPath, "utf8").trim()).toBe(agent.cwd); + + const afterArchiveDirectories = ctx.daemon.daemon.terminalManager.listDirectories(); + expect(afterArchiveDirectories).not.toContain(agent.cwd); + + rmSync(repoRoot, { recursive: true, force: true }); + }, + 60000 + ); + }); + }); diff --git a/packages/server/src/server/daemon-e2e/live-preferences.e2e.test.ts b/packages/server/src/server/daemon-e2e/live-preferences.e2e.test.ts index fd7750088..1aa7efd65 100644 --- a/packages/server/src/server/daemon-e2e/live-preferences.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/live-preferences.e2e.test.ts @@ -73,7 +73,7 @@ describe("daemon E2E", () => { unsubscribe = ctx.client.subscribeRawMessages((message) => { messages.push(message); }); - ctx.client.subscribeAgentUpdates(); + await ctx.client.fetchAgents({ subscribe: { subscriptionId: "live-preferences" } }); }); afterEach(async () => { diff --git a/packages/server/src/server/daemon-e2e/send-while-running-stuck-claude.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/send-while-running-stuck-claude.real.e2e.test.ts new file mode 100644 index 000000000..d4614b8f2 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/send-while-running-stuck-claude.real.e2e.test.ts @@ -0,0 +1,109 @@ +import { describe, test, expect } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import pino from "pino"; + +import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { ClaudeAgentClient } from "../agent/providers/claude-agent.js"; +import { getFullAccessConfig } from "./agent-configs.js"; +import { applyAgentInputProcessingTransition } from "./send-while-running-stuck-test-utils.js"; +import { isCommandAvailable } from "../agent/provider-launch-config.js"; + +function tmpCwd(): string { + return mkdtempSync(path.join(tmpdir(), "daemon-real-stuck-claude-")); +} + +describe("daemon E2E (real claude) - send while running recovery", () => { + test.runIf(isCommandAvailable("claude"))( + "clears input processing when the interrupt transition is missed", + async () => { + const logger = pino({ level: "silent" }); + const cwd = tmpCwd(); + const daemon = await createTestPaseoDaemon({ + agentClients: { claude: new ClaudeAgentClient({ logger }) }, + logger, + }); + + const primary = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + const secondary = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + + try { + await primary.connect(); + await secondary.connect(); + await primary.fetchAgents({ subscribe: { subscriptionId: "primary" } }); + await secondary.fetchAgents({ subscribe: { subscriptionId: "secondary" } }); + + const agent = await primary.createAgent({ + cwd, + title: "stuck-repro-real-claude", + ...getFullAccessConfig("claude"), + }); + + await primary.sendMessage( + agent.id, + "Run bash command sleep 30, wait for completion, then reply done." + ); + await primary.waitForAgentUpsert( + agent.id, + (snapshot) => snapshot.status === "running", + 60_000 + ); + + let isProcessing = true; + let previousIsRunning = true; + let latestUpdatedAt = Date.now(); + + await primary.close(); + + await secondary.sendMessage(agent.id, "Reply with exactly: state saved"); + await secondary.waitForAgentUpsert( + agent.id, + (snapshot) => snapshot.status === "running", + 60_000 + ); + + const reconnected = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + try { + await reconnected.connect(); + await reconnected.fetchAgents({ + subscribe: { subscriptionId: "reconnected" }, + }); + + reconnected.on("agent_update", (message) => { + if (message.type !== "agent_update" || message.payload.kind !== "upsert") { + return; + } + if (message.payload.agent.id !== agent.id) { + return; + } + + const next = applyAgentInputProcessingTransition({ + snapshot: message.payload.agent, + currentIsProcessing: isProcessing, + previousIsRunning, + latestUpdatedAt, + }); + isProcessing = next.isProcessing; + previousIsRunning = next.previousIsRunning; + latestUpdatedAt = next.latestUpdatedAt; + }); + + await secondary.waitForFinish(agent.id, 180_000); + + // Sending while running should clear processing even if reconnect misses the + // not-running -> running transition. + expect(isProcessing).toBe(false); + } finally { + await reconnected.close(); + } + } finally { + await secondary.close(); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, + 300_000 + ); +}); diff --git a/packages/server/src/server/daemon-e2e/send-while-running-stuck-test-utils.ts b/packages/server/src/server/daemon-e2e/send-while-running-stuck-test-utils.ts new file mode 100644 index 000000000..0e148c6a4 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/send-while-running-stuck-test-utils.ts @@ -0,0 +1,42 @@ +import type { AgentSnapshotPayload } from "../messages.js"; + +export function applyAgentInputProcessingTransition(input: { + snapshot: AgentSnapshotPayload; + currentIsProcessing: boolean; + previousIsRunning: boolean; + latestUpdatedAt: number; +}): { isProcessing: boolean; previousIsRunning: boolean; latestUpdatedAt: number } { + const updatedAt = new Date(input.snapshot.updatedAt).getTime(); + if (updatedAt < input.latestUpdatedAt) { + return { + isProcessing: input.currentIsProcessing, + previousIsRunning: input.previousIsRunning, + latestUpdatedAt: input.latestUpdatedAt, + }; + } + + const isRunning = input.snapshot.status === "running"; + const wasRunning = input.previousIsRunning; + let isProcessing = input.currentIsProcessing; + + if (isProcessing) { + const hasEnteredRunning = !wasRunning && isRunning; + const hasFreshRunningUpdateWhileRunning = + wasRunning && isRunning && updatedAt > input.latestUpdatedAt; + const hasStoppedRunning = wasRunning && !isRunning; + + if ( + hasEnteredRunning || + hasFreshRunningUpdateWhileRunning || + hasStoppedRunning + ) { + isProcessing = false; + } + } + + return { + isProcessing, + previousIsRunning: isRunning, + latestUpdatedAt: updatedAt, + }; +} diff --git a/packages/server/src/server/daemon-e2e/send-while-running-stuck.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/send-while-running-stuck.real.e2e.test.ts new file mode 100644 index 000000000..6710343fd --- /dev/null +++ b/packages/server/src/server/daemon-e2e/send-while-running-stuck.real.e2e.test.ts @@ -0,0 +1,106 @@ +import { describe, test, expect } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import pino from "pino"; + +import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { CodexAppServerAgentClient } from "../agent/providers/codex-app-server-agent.js"; +import { getFullAccessConfig } from "./agent-configs.js"; +import { applyAgentInputProcessingTransition } from "./send-while-running-stuck-test-utils.js"; +import { isCommandAvailable } from "../agent/provider-launch-config.js"; + +function tmpCwd(): string { + return mkdtempSync(path.join(tmpdir(), "daemon-real-stuck-")); +} + +describe("daemon E2E (real codex) - send while running recovery", () => { + test.runIf(isCommandAvailable("codex"))( + "clears input processing when the interrupt transition is missed", + async () => { + const logger = pino({ level: "silent" }); + const cwd = tmpCwd(); + const daemon = await createTestPaseoDaemon({ + agentClients: { codex: new CodexAppServerAgentClient(logger) }, + logger, + }); + + const primary = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + const secondary = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + + try { + await primary.connect(); + await secondary.connect(); + await primary.fetchAgents({ subscribe: { subscriptionId: "primary" } }); + await secondary.fetchAgents({ subscribe: { subscriptionId: "secondary" } }); + + const agent = await primary.createAgent({ + cwd, + title: "stuck-repro-real-codex", + ...getFullAccessConfig("codex"), + }); + + await primary.sendMessage(agent.id, "Run: sleep 30"); + await primary.waitForAgentUpsert( + agent.id, + (snapshot) => snapshot.status === "running", + 30_000 + ); + + let isProcessing = true; + let previousIsRunning = true; + let latestUpdatedAt = Date.now(); + + await primary.close(); + + await secondary.sendMessage(agent.id, "Reply with exactly: state saved"); + await secondary.waitForAgentUpsert( + agent.id, + (snapshot) => snapshot.status === "running", + 30_000 + ); + + const reconnected = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + try { + await reconnected.connect(); + await reconnected.fetchAgents({ + subscribe: { subscriptionId: "reconnected" }, + }); + + reconnected.on("agent_update", (message) => { + if (message.type !== "agent_update" || message.payload.kind !== "upsert") { + return; + } + if (message.payload.agent.id !== agent.id) { + return; + } + + const next = applyAgentInputProcessingTransition({ + snapshot: message.payload.agent, + currentIsProcessing: isProcessing, + previousIsRunning, + latestUpdatedAt, + }); + isProcessing = next.isProcessing; + previousIsRunning = next.previousIsRunning; + latestUpdatedAt = next.latestUpdatedAt; + }); + + await secondary.waitForFinish(agent.id, 120_000); + + // Sending while running should clear processing even if reconnect misses the + // not-running -> running transition. + expect(isProcessing).toBe(false); + } finally { + await reconnected.close(); + } + } finally { + await secondary.close(); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, + 240_000 + ); +}); diff --git a/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts b/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts index d83293243..c1769bea8 100644 --- a/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts @@ -97,6 +97,42 @@ const shouldRun = !process.env.CI; 30000 ); + test( + "emits terminals_changed for subscribed cwd when terminals are created", + async () => { + const cwd = tmpCwd(); + await ctx.client.listTerminals(cwd); + + const snapshots: Array<{ cwd: string; names: string[] }> = []; + const unsubscribe = ctx.client.on("terminals_changed", (message) => { + if (message.type !== "terminals_changed") { + return; + } + snapshots.push({ + cwd: message.payload.cwd, + names: message.payload.terminals.map((terminal) => terminal.name), + }); + }); + + ctx.client.subscribeTerminals({ cwd }); + await ctx.client.createTerminal(cwd, "Dev Server"); + + await waitForCondition( + () => + snapshots.some( + (snapshot) => + snapshot.cwd === cwd && snapshot.names.includes("Dev Server") + ), + 10000 + ); + + ctx.client.unsubscribeTerminals({ cwd }); + unsubscribe(); + rmSync(cwd, { recursive: true, force: true }); + }, + 30000 + ); + test( "subscribes to terminal and receives state", async () => { diff --git a/packages/server/src/server/exports.ts b/packages/server/src/server/exports.ts index f4c8c5406..dee16d888 100644 --- a/packages/server/src/server/exports.ts +++ b/packages/server/src/server/exports.ts @@ -13,6 +13,15 @@ export { type LocalSttModelId, type LocalTtsModelId, } from "./speech/providers/local/models.js"; +export { + applySherpaLoaderEnv, + resolveSherpaLoaderEnv, + sherpaLoaderEnvKey, + sherpaPlatformArch, + sherpaPlatformPackageName, + type SherpaLoaderEnvKey, + type SherpaLoaderEnvResolution, +} from "./speech/providers/local/sherpa/sherpa-runtime-env.js"; // Agent SDK types for CLI commands export type { @@ -25,6 +34,20 @@ export type { // Agent activity curator for CLI logs export { curateAgentActivity } from "./agent/activity-curator.js"; +export { + getStructuredAgentResponse, + StructuredAgentResponseError, + StructuredAgentFallbackError, + DEFAULT_STRUCTURED_GENERATION_PROVIDERS, + generateStructuredAgentResponseWithFallback, + type AgentCaller, + type JsonSchema, + type StructuredGenerationAttempt, + type StructuredGenerationProvider, + type StructuredAgentGenerationOptions, + type StructuredAgentGenerationWithFallbackOptions, + type StructuredAgentResponseOptions, +} from "./agent/agent-response-loop.js"; // WebSocket message types for CLI streaming export type { diff --git a/packages/server/src/server/index.ts b/packages/server/src/server/index.ts index 80d687c64..ada1e4eba 100644 --- a/packages/server/src/server/index.ts +++ b/packages/server/src/server/index.ts @@ -1,9 +1,3 @@ -import dotenv from "dotenv"; -import { fileURLToPath } from "node:url"; -import path from "node:path"; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -dotenv.config({ path: path.resolve(__dirname, "../../.env") }); import { createPaseoDaemon } from "./bootstrap.js"; import { loadConfig } from "./config.js"; import { resolvePaseoHome } from "./paseo-home.js"; diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index c2c2b8bd9..90ee10c33 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -22,7 +22,6 @@ const ProviderCredentialsSchema = z const LocalSpeechProviderSchema = z .object({ modelsDir: z.string().min(1).optional(), - autoDownload: z.boolean().optional(), }) .strict(); @@ -175,6 +174,34 @@ function getLogger(logger: LoggerLike | undefined): LoggerLike | undefined { return logger?.child({ module: "config" }); } +function stripDeprecatedLocalSpeechConfigFields(parsed: unknown): unknown { + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return parsed; + } + + const root = { ...(parsed as Record) }; + const providers = root.providers; + if (!providers || typeof providers !== "object" || Array.isArray(providers)) { + return root; + } + + const providersRecord = { ...(providers as Record) }; + const local = providersRecord.local; + if (!local || typeof local !== "object" || Array.isArray(local)) { + root.providers = providersRecord; + return root; + } + + const localRecord = { ...(local as Record) }; + if ("autoDownload" in localRecord) { + delete localRecord.autoDownload; + } + + providersRecord.local = localRecord; + root.providers = providersRecord; + return root; +} + export function loadPersistedConfig( paseoHome: string, logger?: LoggerLike @@ -209,7 +236,8 @@ export function loadPersistedConfig( throw new Error(`[Config] Invalid JSON in ${configPath}: ${message}`); } - const result = PersistedConfigSchema.safeParse(parsed); + const migrated = stripDeprecatedLocalSpeechConfigFields(parsed); + const result = PersistedConfigSchema.safeParse(migrated); if (!result.success) { const issues = result.error.issues .map((i) => ` - ${i.path.join(".")}: ${i.message}`) diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index f8b9228a3..295acd47d 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -4,6 +4,7 @@ import { stat } from "fs/promises"; import { exec } from "child_process"; import { promisify } from "util"; import { join, resolve, sep } from "path"; +import { homedir } from "node:os"; import { z } from "zod"; import type { ToolSet } from "ai"; import { @@ -15,6 +16,8 @@ import { type FileDownloadTokenRequest, type GitSetupOptions, type ListTerminalsRequest, + type SubscribeTerminalsRequest, + type UnsubscribeTerminalsRequest, type CreateTerminalRequest, type SubscribeTerminalRequest, type UnsubscribeTerminalRequest, @@ -24,10 +27,14 @@ import { type DetachTerminalStreamRequest, type SubscribeCheckoutDiffRequest, type UnsubscribeCheckoutDiffRequest, + type DirectorySuggestionsRequest, type ProjectCheckoutLitePayload, type ProjectPlacementPayload, } from "./messages.js"; -import type { TerminalManager } from "../terminal/terminal-manager.js"; +import type { + TerminalManager, + TerminalsChangedEvent, +} from "../terminal/terminal-manager.js"; import type { TerminalSession } from "../terminal/terminal.js"; import { BinaryMuxChannel, @@ -68,11 +75,16 @@ import type { } from "./agent/agent-manager.js"; import { scheduleAgentMetadataGeneration } from "./agent/agent-metadata-generator.js"; import { toAgentPayload } from "./agent/agent-projections.js"; -import { appendTimelineItemIfAgentKnown } from "./agent/timeline-append.js"; +import { + appendTimelineItemIfAgentKnown, + emitLiveTimelineItemIfAgentKnown, +} from "./agent/timeline-append.js"; import { projectTimelineRows, type TimelineProjectionMode } from "./agent/timeline-projection.js"; import { + DEFAULT_STRUCTURED_GENERATION_PROVIDERS, + StructuredAgentFallbackError, StructuredAgentResponseError, - generateStructuredAgentResponse, + generateStructuredAgentResponseWithFallback, } from "./agent/agent-response-loop.js"; import type { AgentPermissionResponse, @@ -130,6 +142,7 @@ import { } from "../utils/checkout-git.js"; import { getProjectIcon } from "../utils/project-icon.js"; import { expandTilde } from "../utils/path.js"; +import { searchHomeDirectories } from "../utils/directory-suggestions.js"; import { ensureLocalSpeechModels, getLocalSpeechModelDir, @@ -155,12 +168,6 @@ const TERMINAL_STREAM_WINDOW_BYTES = 256 * 1024; const TERMINAL_STREAM_MAX_PENDING_BYTES = 2 * 1024 * 1024; const TERMINAL_STREAM_MAX_PENDING_CHUNKS = 2048; -/** - * Default model used for auto-generating commit messages and PR descriptions. - * Uses Claude Haiku for speed and cost efficiency. - */ -const AUTO_GEN_MODEL = "haiku"; - function deriveRemoteProjectKey(remoteUrl: string | null): string | null { if (!remoteUrl) { return null; @@ -286,6 +293,7 @@ type FetchAgentsRequestMessage = Extract< SessionInboundMessage, { type: "fetch_agents_request" } >; +type FetchAgentsRequestFilter = NonNullable; type FetchAgentsRequestSort = NonNullable[number]; type FetchAgentsResponsePayload = Extract< SessionOutboundMessage, @@ -293,6 +301,17 @@ type FetchAgentsResponsePayload = Extract< >["payload"]; type FetchAgentsResponseEntry = FetchAgentsResponsePayload["entries"][number]; type FetchAgentsResponsePageInfo = FetchAgentsResponsePayload["pageInfo"]; +type AgentUpdatePayload = Extract< + SessionOutboundMessage, + { type: "agent_update" } +>["payload"]; +type AgentUpdatesFilter = FetchAgentsRequestFilter; +type AgentUpdatesSubscriptionState = { + subscriptionId: string; + filter?: AgentUpdatesFilter; + isBootstrapping: boolean; + pendingUpdatesByAgentId: Map; +}; type FetchAgentsCursor = { sort: FetchAgentsRequestSort[]; values: Record; @@ -544,12 +563,7 @@ export class Session { private readonly pushTokenStore: PushTokenStore; private readonly providerRegistry: ReturnType; private unsubscribeAgentEvents: (() => void) | null = null; - private agentUpdatesSubscription: - | { - subscriptionId: string; - filter?: { labels?: Record; agentId?: string }; - } - | null = null; + private agentUpdatesSubscription: AgentUpdatesSubscriptionState | null = null; private clientActivity: { deviceType: "web" | "mobile"; focusedAgentId: string | null; @@ -559,6 +573,8 @@ export class Session { } | null = null; private readonly MOBILE_BACKGROUND_STREAM_GRACE_MS = 60_000; private readonly terminalManager: TerminalManager | null; + private readonly subscribedTerminalDirectories = new Set(); + private unsubscribeTerminalsChanged: (() => void) | null = null; private terminalSubscriptions: Map void> = new Map(); private terminalExitSubscriptions: Map void> = new Map(); private readonly terminalStreams = new Map< @@ -627,6 +643,11 @@ export class Session { this.agentStorage = agentStorage; this.createAgentMcpTransport = createAgentMcpTransport; this.terminalManager = terminalManager; + if (this.terminalManager) { + this.unsubscribeTerminalsChanged = this.terminalManager.subscribeTerminalsChanged( + (event) => this.handleTerminalsChanged(event) + ); + } this.voiceAgentMcpStdio = voice?.voiceAgentMcpStdio ?? null; const configuredModelsDir = dictation?.localModels?.modelsDir?.trim(); this.localSpeechModelsDir = @@ -1067,19 +1088,105 @@ export class Session { } } - private matchesAgentFilter( - agent: AgentSnapshotPayload, - filter?: { labels?: Record; agentId?: string } - ): boolean { - if (filter?.agentId && agent.id !== filter.agentId) { + private matchesAgentFilter(options: { + agent: AgentSnapshotPayload; + project: ProjectPlacementPayload; + filter?: AgentUpdatesFilter; + }): boolean { + const { agent, project, filter } = options; + + if (filter?.labels) { + const matchesLabels = Object.entries(filter.labels).every( + ([key, value]) => agent.labels[key] === value + ); + if (!matchesLabels) { + return false; + } + } + + const includeArchived = filter?.includeArchived ?? false; + if (!includeArchived && agent.archivedAt) { return false; } - if (!filter?.labels) { - return true; + + if (filter?.statuses && filter.statuses.length > 0) { + const statuses = new Set(filter.statuses); + if (!statuses.has(agent.status)) { + return false; + } + } + + if (typeof filter?.requiresAttention === "boolean") { + const requiresAttention = agent.requiresAttention ?? false; + if (requiresAttention !== filter.requiresAttention) { + return false; + } + } + + if (filter?.projectKeys && filter.projectKeys.length > 0) { + const projectKeys = new Set( + filter.projectKeys.filter((item) => item.trim().length > 0) + ); + if (projectKeys.size > 0 && !projectKeys.has(project.projectKey)) { + return false; + } + } + + return true; + } + + private getAgentUpdateTargetId(update: AgentUpdatePayload): string { + return update.kind === "remove" ? update.agentId : update.agent.id; + } + + private bufferOrEmitAgentUpdate( + subscription: AgentUpdatesSubscriptionState, + payload: AgentUpdatePayload + ): void { + if (subscription.isBootstrapping) { + subscription.pendingUpdatesByAgentId.set( + this.getAgentUpdateTargetId(payload), + payload + ); + return; + } + + this.emit({ + type: "agent_update", + payload, + }); + } + + private flushBootstrappedAgentUpdates(options?: { + snapshotUpdatedAtByAgentId?: Map; + }): void { + const subscription = this.agentUpdatesSubscription; + if (!subscription || !subscription.isBootstrapping) { + return; + } + + subscription.isBootstrapping = false; + const pending = Array.from(subscription.pendingUpdatesByAgentId.values()); + subscription.pendingUpdatesByAgentId.clear(); + + for (const payload of pending) { + if (payload.kind === "upsert") { + const snapshotUpdatedAt = options?.snapshotUpdatedAtByAgentId?.get( + payload.agent.id + ); + if (typeof snapshotUpdatedAt === "number") { + const updateUpdatedAt = Date.parse(payload.agent.updatedAt); + if (!Number.isNaN(updateUpdatedAt) && updateUpdatedAt <= snapshotUpdatedAt) { + continue; + } + } + } + + this.emit({ + type: "agent_update", + payload, + }); } - return Object.entries(filter.labels).every( - ([key, value]) => agent.labels[key] === value - ); } private buildFallbackProjectCheckout(cwd: string): ProjectCheckoutLitePayload { @@ -1145,20 +1252,25 @@ export class Session { } const payload = await this.buildAgentPayload(agent); - const matches = this.matchesAgentFilter(payload, subscription.filter); + const project = await this.buildProjectPlacement(payload.cwd); + const matches = this.matchesAgentFilter({ + agent: payload, + project, + filter: subscription.filter, + }); if (matches) { - const project = await this.buildProjectPlacement(payload.cwd); - this.emit({ - type: "agent_update", - payload: { kind: "upsert", agent: payload, project }, + this.bufferOrEmitAgentUpdate(subscription, { + kind: "upsert", + agent: payload, + project, }); return; } - this.emit({ - type: "agent_update", - payload: { kind: "remove", agentId: payload.id }, + this.bufferOrEmitAgentUpdate(subscription, { + kind: "remove", + agentId: payload.id, }); } catch (error) { this.sessionLogger.error({ err: error }, "Failed to emit agent update"); @@ -1191,21 +1303,6 @@ export class Session { await this.handleFetchAgent(msg.agentId, msg.requestId); break; - case "subscribe_agent_updates": - this.agentUpdatesSubscription = { - subscriptionId: msg.subscriptionId, - filter: msg.filter, - }; - break; - - case "unsubscribe_agent_updates": - if ( - this.agentUpdatesSubscription?.subscriptionId === msg.subscriptionId - ) { - this.agentUpdatesSubscription = null; - } - break; - case "delete_agent_request": await this.handleDeleteAgentRequest(msg.agentId, msg.requestId); break; @@ -1332,6 +1429,10 @@ export class Session { await this.handleBranchSuggestionsRequest(msg); break; + case "directory_suggestions_request": + await this.handleDirectorySuggestionsRequest(msg); + break; + case "subscribe_checkout_diff_request": await this.handleSubscribeCheckoutDiffRequest(msg); break; @@ -1423,7 +1524,7 @@ export class Session { } case "list_commands_request": - await this.handleListCommandsRequest(msg.agentId, msg.requestId); + await this.handleListCommandsRequest(msg); break; case "execute_command_request": @@ -1439,6 +1540,14 @@ export class Session { this.handleRegisterPushToken(msg.token); break; + case "subscribe_terminals_request": + this.handleSubscribeTerminalsRequest(msg); + break; + + case "unsubscribe_terminals_request": + this.handleUnsubscribeTerminalsRequest(msg); + break; + case "list_terminals_request": await this.handleListTerminalsRequest(msg); break; @@ -1650,9 +1759,9 @@ export class Session { }); if (this.agentUpdatesSubscription) { - this.emit({ - type: "agent_update", - payload: { kind: "remove", agentId }, + this.bufferOrEmitAgentUpdate(this.agentUpdatesSubscription, { + kind: "remove", + agentId, }); } } @@ -2311,7 +2420,10 @@ export class Session { const prompt = this.buildAgentPrompt(text, images); try { - this.agentManager.recordUserMessage(agentId, text, { messageId }); + this.agentManager.recordUserMessage(agentId, text, { + messageId, + emitState: false, + }); } catch (error) { this.sessionLogger.error( { err: error, agentId }, @@ -2414,6 +2526,12 @@ export class Session { agentId: snapshot.id, item, }), + emitLiveTimelineItem: (item) => + emitLiveTimelineItemIfAgentKnown({ + agentManager: this.agentManager, + agentId: snapshot.id, + item, + }), logger: this.sessionLogger, }); } @@ -2814,7 +2932,6 @@ export class Session { await ensureLocalSpeechModels({ modelsDir, modelIds, - autoDownload: true, logger: this.sessionLogger, }); this.emit({ @@ -2978,23 +3095,25 @@ export class Session { patch.length > 0 ? patch : "(No diff available)", ].join("\n"); try { - const result = await generateStructuredAgentResponse({ + const result = await generateStructuredAgentResponseWithFallback({ manager: this.agentManager, - agentConfig: { - provider: "claude", - model: AUTO_GEN_MODEL, - cwd, - title: "Commit generator", - internal: true, - }, + cwd, prompt, schema, schemaName: "CommitMessage", maxRetries: 2, + providers: DEFAULT_STRUCTURED_GENERATION_PROVIDERS, + agentConfigOverrides: { + title: "Commit generator", + internal: true, + }, }); return result.message; } catch (error) { - if (error instanceof StructuredAgentResponseError) { + if ( + error instanceof StructuredAgentResponseError || + error instanceof StructuredAgentFallbackError + ) { return "Update files"; } throw error; @@ -3043,22 +3162,24 @@ export class Session { patch.length > 0 ? patch : "(No diff available)", ].join("\n"); try { - return await generateStructuredAgentResponse({ + return await generateStructuredAgentResponseWithFallback({ manager: this.agentManager, - agentConfig: { - provider: "claude", - model: AUTO_GEN_MODEL, - cwd, - title: "PR generator", - internal: true, - }, + cwd, prompt, schema, schemaName: "PullRequest", maxRetries: 2, + providers: DEFAULT_STRUCTURED_GENERATION_PROVIDERS, + agentConfigOverrides: { + title: "PR generator", + internal: true, + }, }); } catch (error) { - if (error instanceof StructuredAgentResponseError) { + if ( + error instanceof StructuredAgentResponseError || + error instanceof StructuredAgentFallbackError + ) { return { title: "Update changes", body: "Automated PR generated by Paseo.", @@ -3352,9 +3473,12 @@ export class Session { /** * Handle list commands request for an agent */ - private async handleListCommandsRequest(agentId: string, requestId: string): Promise { + private async handleListCommandsRequest( + msg: Extract + ): Promise { + const { agentId, requestId, draftConfig } = msg; this.sessionLogger.debug( - { agentId }, + { agentId, draftConfig }, `Handling list commands request for agent ${agentId}` ); @@ -3362,47 +3486,59 @@ export class Session { const agents = this.agentManager.listAgents(); const agent = agents.find((a) => a.id === agentId); - if (!agent) { + if (agent?.session?.listCommands) { + const commands = await agent.session.listCommands(); this.emit({ type: "list_commands_response", payload: { agentId, - commands: [], - error: `Agent not found: ${agentId}`, + commands, + error: null, requestId, }, }); return; } - const session = agent.session; - if (!session || !session.listCommands) { + if (!agent && draftConfig) { + const sessionConfig: AgentSessionConfig = { + provider: draftConfig.provider, + cwd: expandTilde(draftConfig.cwd), + ...(draftConfig.modeId ? { modeId: draftConfig.modeId } : {}), + ...(draftConfig.model ? { model: draftConfig.model } : {}), + ...(draftConfig.thinkingOptionId + ? { thinkingOptionId: draftConfig.thinkingOptionId } + : {}), + }; + + const commands = await this.agentManager.listDraftCommands(sessionConfig); this.emit({ type: "list_commands_response", payload: { agentId, - commands: [], - error: `Agent does not support listing commands`, + commands, + error: null, requestId, }, }); return; } - const commands = await session.listCommands(); - this.emit({ type: "list_commands_response", payload: { agentId, - commands, - error: null, + commands: [], + error: agent + ? `Agent does not support listing commands` + : `Agent not found: ${agentId}`, requestId, }, }); + } catch (error: any) { this.sessionLogger.error( - { err: error, agentId }, + { err: error, agentId, draftConfig }, "Failed to list commands" ); this.emit({ @@ -3531,9 +3667,10 @@ export class Session { msg: Extract ): Promise { const { cwd, requestId } = msg; + const resolvedCwd = expandTilde(cwd); try { - const status = await getCheckoutStatus(cwd, { paseoHome: this.paseoHome }); + const status = await getCheckoutStatus(resolvedCwd, { paseoHome: this.paseoHome }); if (!status.isGit) { this.emit({ type: "checkout_status_response", @@ -3722,6 +3859,37 @@ export class Session { } } + private async handleDirectorySuggestionsRequest( + msg: DirectorySuggestionsRequest + ): Promise { + const { query, limit, requestId } = msg; + + try { + const directories = await searchHomeDirectories({ + homeDir: process.env.HOME ?? homedir(), + query, + limit, + }); + this.emit({ + type: "directory_suggestions_response", + payload: { + directories, + error: null, + requestId, + }, + }); + } catch (error) { + this.emit({ + type: "directory_suggestions_response", + payload: { + directories: [], + error: error instanceof Error ? error.message : String(error), + requestId, + }, + }); + } + } + private normalizeCheckoutDiffCompare(compare: CheckoutDiffCompareInput): CheckoutDiffCompareInput { if (compare.mode === "uncommitted") { return { mode: "uncommitted" }; @@ -4452,11 +4620,13 @@ export class Session { } } - await deletePaseoWorktree({ - cwd: repoRoot, - worktreePath: targetPath, - paseoHome: this.paseoHome, - }); + await this.killTerminalsUnderPath(targetPath); + + await deletePaseoWorktree({ + cwd: repoRoot, + worktreePath: targetPath, + paseoHome: this.paseoHome, + }); for (const agentId of removedAgents) { this.emit({ @@ -5001,28 +5171,11 @@ export class Session { }> { const filter = request.filter; const sort = this.normalizeFetchAgentsSort(request.sort); - const includeArchived = filter?.includeArchived ?? false; - let agents = await this.listAgentPayloads({ + const agents = await this.listAgentPayloads({ labels: filter?.labels, }); - if (!includeArchived) { - agents = agents.filter((agent) => !agent.archivedAt); - } - - if (filter?.statuses && filter.statuses.length > 0) { - const statuses = new Set(filter.statuses); - agents = agents.filter((agent) => statuses.has(agent.status)); - } - - if (typeof filter?.requiresAttention === "boolean") { - agents = agents.filter( - (agent) => - (agent.requiresAttention ?? false) === filter.requiresAttention - ); - } - const placementByCwd = new Map>(); const getPlacement = (cwd: string): Promise => { const existing = placementByCwd.get(cwd); @@ -5040,11 +5193,13 @@ export class Session { project: await getPlacement(agent.cwd), })) ); - - if (filter?.projectKeys && filter.projectKeys.length > 0) { - const projectKeys = new Set(filter.projectKeys.filter((item) => item.trim().length > 0)); - entries = entries.filter((entry) => projectKeys.has(entry.project.projectKey)); - } + entries = entries.filter((entry) => + this.matchesAgentFilter({ + agent: entry.agent, + project: entry.project, + filter, + }) + ); entries.sort((left, right) => this.compareFetchAgentsEntries(left, right, sort) @@ -5082,16 +5237,55 @@ export class Session { private async handleFetchAgents( request: Extract ): Promise { + const requestedSubscriptionId = request.subscribe?.subscriptionId?.trim(); + const subscriptionId = + request.subscribe + ? requestedSubscriptionId && requestedSubscriptionId.length > 0 + ? requestedSubscriptionId + : uuidv4() + : null; + try { + if (subscriptionId) { + this.agentUpdatesSubscription = { + subscriptionId, + filter: request.filter, + isBootstrapping: true, + pendingUpdatesByAgentId: new Map(), + }; + } + const payload = await this.listFetchAgentsEntries(request); + const snapshotUpdatedAtByAgentId = new Map(); + for (const entry of payload.entries) { + const parsedUpdatedAt = Date.parse(entry.agent.updatedAt); + if (!Number.isNaN(parsedUpdatedAt)) { + snapshotUpdatedAtByAgentId.set(entry.agent.id, parsedUpdatedAt); + } + } + this.emit({ type: "fetch_agents_response", payload: { requestId: request.requestId, + ...(subscriptionId ? { subscriptionId } : {}), ...payload, }, }); + + if ( + subscriptionId && + this.agentUpdatesSubscription?.subscriptionId === subscriptionId + ) { + this.flushBootstrappedAgentUpdates({ snapshotUpdatedAtByAgentId }); + } } catch (error) { + if ( + subscriptionId && + this.agentUpdatesSubscription?.subscriptionId === subscriptionId + ) { + this.agentUpdatesSubscription = null; + } const code = error instanceof SessionRequestError ? error.code : "fetch_agents_failed"; const message = @@ -5244,7 +5438,10 @@ export class Session { await this.interruptAgentIfRunning(agentId); try { - this.agentManager.recordUserMessage(agentId, msg.text, { messageId: msg.messageId }); + this.agentManager.recordUserMessage(agentId, msg.text, { + messageId: msg.messageId, + emitState: false, + }); } catch (error) { this.sessionLogger.error( { err: error, agentId }, @@ -6091,6 +6288,12 @@ export class Session { this.isVoiceMode = false; // Unsubscribe from all terminals + if (this.unsubscribeTerminalsChanged) { + this.unsubscribeTerminalsChanged(); + this.unsubscribeTerminalsChanged = null; + } + this.subscribedTerminalDirectories.clear(); + for (const unsubscribe of this.terminalSubscriptions.values()) { unsubscribe(); } @@ -6149,6 +6352,81 @@ export class Session { } } + private emitTerminalsChangedSnapshot(input: { + cwd: string; + terminals: Array<{ id: string; name: string }>; + }): void { + this.emit({ + type: "terminals_changed", + payload: { + cwd: input.cwd, + terminals: input.terminals, + }, + }); + } + + private handleTerminalsChanged(event: TerminalsChangedEvent): void { + if (!this.subscribedTerminalDirectories.has(event.cwd)) { + return; + } + + this.emitTerminalsChangedSnapshot({ + cwd: event.cwd, + terminals: event.terminals.map((terminal) => ({ + id: terminal.id, + name: terminal.name, + })), + }); + } + + private handleSubscribeTerminalsRequest(msg: SubscribeTerminalsRequest): void { + this.subscribedTerminalDirectories.add(msg.cwd); + void this.emitInitialTerminalsChangedSnapshot(msg.cwd); + } + + private handleUnsubscribeTerminalsRequest(msg: UnsubscribeTerminalsRequest): void { + this.subscribedTerminalDirectories.delete(msg.cwd); + } + + private async emitInitialTerminalsChangedSnapshot(cwd: string): Promise { + if (!this.terminalManager || !this.subscribedTerminalDirectories.has(cwd)) { + return; + } + + const hadDirectoryBeforeSubscribe = this.terminalManager + .listDirectories() + .includes(cwd); + + try { + const terminals = await this.terminalManager.getTerminals(cwd); + for (const terminal of terminals) { + this.ensureTerminalExitSubscription(terminal); + } + + // New directories auto-create Terminal 1, which already emits through + // terminal-manager change listeners. + if (!hadDirectoryBeforeSubscribe) { + return; + } + if (!this.subscribedTerminalDirectories.has(cwd)) { + return; + } + + this.emitTerminalsChangedSnapshot({ + cwd, + terminals: terminals.map((terminal) => ({ + id: terminal.id, + name: terminal.name, + })), + }); + } catch (error) { + this.sessionLogger.warn( + { err: error, cwd }, + "Failed to emit initial terminal snapshot" + ); + } + } + private async handleListTerminalsRequest(msg: ListTerminalsRequest): Promise { if (!this.terminalManager) { this.emit({ @@ -6312,6 +6590,56 @@ export class Session { session.send(msg.message); } + private killTrackedTerminal(terminalId: string, options?: { emitExit: boolean }): void { + const unsubscribe = this.terminalSubscriptions.get(terminalId); + if (unsubscribe) { + unsubscribe(); + this.terminalSubscriptions.delete(terminalId); + } + + const streamId = this.terminalStreamByTerminalId.get(terminalId); + if (typeof streamId === "number") { + this.detachTerminalStream(streamId, { emitExit: options?.emitExit ?? true }); + } + + this.terminalManager?.killTerminal(terminalId); + } + + private async killTerminalsUnderPath(rootPath: string): Promise { + if (!this.terminalManager) { + return; + } + + const cleanupErrors: Array<{ cwd: string; message: string }> = []; + const terminalDirectories = [...this.terminalManager.listDirectories()]; + for (const terminalCwd of terminalDirectories) { + if (!this.isPathWithinRoot(rootPath, terminalCwd)) { + continue; + } + + try { + const terminals = await this.terminalManager.getTerminals(terminalCwd); + for (const terminal of [...terminals]) { + this.killTrackedTerminal(terminal.id, { emitExit: true }); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + cleanupErrors.push({ cwd: terminalCwd, message }); + this.sessionLogger.warn( + { err: error, cwd: terminalCwd }, + "Failed to clean up worktree terminals during archive" + ); + } + } + + if (cleanupErrors.length > 0) { + const details = cleanupErrors + .map((entry) => `${entry.cwd}: ${entry.message}`) + .join("; "); + throw new Error(`Failed to clean up worktree terminals during archive (${details})`); + } + } + private async handleKillTerminalRequest(msg: KillTerminalRequest): Promise { if (!this.terminalManager) { this.emit({ @@ -6325,19 +6653,7 @@ export class Session { return; } - // Unsubscribe first - const unsubscribe = this.terminalSubscriptions.get(msg.terminalId); - if (unsubscribe) { - unsubscribe(); - this.terminalSubscriptions.delete(msg.terminalId); - } - - const streamId = this.terminalStreamByTerminalId.get(msg.terminalId); - if (typeof streamId === "number") { - this.detachTerminalStream(streamId, { emitExit: true }); - } - - this.terminalManager.killTerminal(msg.terminalId); + this.killTrackedTerminal(msg.terminalId, { emitExit: true }); this.emit({ type: "kill_terminal_response", payload: { diff --git a/packages/server/src/server/speech/providers/local/config.ts b/packages/server/src/server/speech/providers/local/config.ts index e494b9842..93da426ce 100644 --- a/packages/server/src/server/speech/providers/local/config.ts +++ b/packages/server/src/server/speech/providers/local/config.ts @@ -24,7 +24,6 @@ export type LocalSpeechModelConfig = { export type LocalSpeechProviderConfig = { modelsDir: string; - autoDownload?: boolean; models: LocalSpeechModelConfig; }; @@ -36,17 +35,6 @@ export type { LocalSpeechModelId, LocalSttModelId, LocalTtsModelId }; const DEFAULT_LOCAL_MODELS_SUBDIR = path.join("models", "local-speech"); -const BooleanStringSchema = z - .string() - .trim() - .toLowerCase() - .pipe(z.enum(["1", "0", "true", "false", "yes", "no"])) - .transform((value) => value === "1" || value === "true" || value === "yes"); - -const OptionalBooleanFlagSchema = z - .union([z.boolean(), BooleanStringSchema]) - .optional(); - const NumberLikeSchema = z.union([ z.number(), z.string().trim().min(1), @@ -63,7 +51,6 @@ const OptionalIntegerSchema = NumberLikeSchema const LocalSpeechResolutionSchema = z.object({ includeProviderConfig: z.boolean(), modelsDir: z.string().trim().min(1), - autoDownload: OptionalBooleanFlagSchema.default(true), dictationLocalSttModel: LocalSttModelIdSchema.default(DEFAULT_LOCAL_STT_MODEL), voiceLocalSttModel: LocalSttModelIdSchema.default(DEFAULT_LOCAL_STT_MODEL), voiceLocalTtsModel: LocalTtsModelIdSchema.default(DEFAULT_LOCAL_TTS_MODEL), @@ -98,7 +85,7 @@ function shouldIncludeLocalProviderConfig(params: { return ( localRequestedByFeature || params.env.PASEO_LOCAL_MODELS_DIR !== undefined || - params.persisted.providers?.local !== undefined + params.persisted.providers?.local?.modelsDir !== undefined ); } @@ -116,9 +103,6 @@ export function resolveLocalSpeechConfig(params: { params.env.PASEO_LOCAL_MODELS_DIR ?? params.persisted.providers?.local?.modelsDir ?? path.join(params.paseoHome, DEFAULT_LOCAL_MODELS_SUBDIR), - autoDownload: - params.env.PASEO_LOCAL_AUTO_DOWNLOAD ?? - params.persisted.providers?.local?.autoDownload, dictationLocalSttModel: params.env.PASEO_DICTATION_LOCAL_STT_MODEL ?? persistedLocalFeatureModel( @@ -160,7 +144,6 @@ export function resolveLocalSpeechConfig(params: { parsed.includeProviderConfig ? { modelsDir: parsed.modelsDir, - autoDownload: parsed.autoDownload, models: { dictationStt: parsed.dictationLocalSttModel, voiceStt: parsed.voiceLocalSttModel, diff --git a/packages/server/src/server/speech/providers/local/models.ts b/packages/server/src/server/speech/providers/local/models.ts index ee2dee911..f9861c52e 100644 --- a/packages/server/src/server/speech/providers/local/models.ts +++ b/packages/server/src/server/speech/providers/local/models.ts @@ -36,13 +36,11 @@ export function getLocalSpeechModelDir(modelsDir: string, modelId: LocalSpeechMo export async function ensureLocalSpeechModels(options: { modelsDir: string; modelIds: LocalSpeechModelId[]; - autoDownload?: boolean; logger: import("pino").Logger; }): Promise> { return ensureSherpaOnnxModels({ modelsDir: options.modelsDir, modelIds: options.modelIds, - autoDownload: options.autoDownload ?? true, logger: options.logger, }); } diff --git a/packages/server/src/server/speech/providers/local/runtime.ts b/packages/server/src/server/speech/providers/local/runtime.ts index 44303f7a1..7c15bd6fd 100644 --- a/packages/server/src/server/speech/providers/local/runtime.ts +++ b/packages/server/src/server/speech/providers/local/runtime.ts @@ -36,7 +36,6 @@ type ResolvedLocalModels = { type LocalSpeechAvailability = { configured: boolean; modelsDir: string | null; - autoDownload: boolean | null; }; export type InitializedLocalSpeech = { @@ -79,7 +78,6 @@ export function getLocalSpeechAvailability( return { configured: Boolean(localConfig), modelsDir: localConfig?.modelsDir ?? null, - autoDownload: localConfig?.autoDownload ?? null, }; } @@ -186,12 +184,10 @@ export async function initializeLocalSpeechServices(params: { providers: RequestedSpeechProviders; speechConfig: PaseoSpeechConfig | null; logger: Logger; - modelEnsureAutoDownload?: boolean; }): Promise { const { providers, logger, speechConfig } = params; const localConfig = speechConfig?.local ?? null; const localModels = resolveConfiguredLocalModels(speechConfig); - const modelEnsureAutoDownload = params.modelEnsureAutoDownload ?? (localConfig?.autoDownload ?? true); let sttService: SpeechToTextProvider | null = null; let ttsService: TextToSpeechProvider | null = null; @@ -209,28 +205,25 @@ export async function initializeLocalSpeechServices(params: { { modelsDir: localConfig.modelsDir, modelIds: requiredLocalModelIds, - autoDownload: modelEnsureAutoDownload, }, "Ensuring local speech models" ); await ensureLocalSpeechModels({ modelsDir: localConfig.modelsDir, modelIds: requiredLocalModelIds, - autoDownload: modelEnsureAutoDownload, logger, }); } catch (err) { - logger.error( + logger.warn( { err, modelsDir: localConfig.modelsDir, modelIds: requiredLocalModelIds, - autoDownload: modelEnsureAutoDownload, hint: "Use `paseo speech models` to inspect status and " + "`paseo speech download --model ` to fetch missing models.", }, - "Failed to ensure local speech models" + "Local speech model bootstrap failed" ); } } @@ -256,14 +249,14 @@ export async function initializeLocalSpeechServices(params: { localSttEngines.set(modelId, created); return created; } catch (err) { - logger.error( + logger.warn( { err, modelsDir: localConfig.modelsDir, modelId, hint: buildModelDownloadHint(modelId), }, - "Failed to initialize local STT engine (models missing or invalid)" + "Local STT engine unavailable" ); return null; } @@ -340,14 +333,14 @@ export async function initializeLocalSpeechServices(params: { } ttsService = localVoiceTtsProvider; } catch (err) { - logger.error( + logger.warn( { err, modelsDir: localConfig.modelsDir, modelId: localModels.voiceLocalTtsModel, hint: buildModelDownloadHint(localModels.voiceLocalTtsModel), }, - "Failed to initialize local TTS engine (models missing or invalid)" + "Local TTS engine unavailable" ); } } diff --git a/packages/server/src/server/speech/providers/local/sherpa/model-downloader.test.ts b/packages/server/src/server/speech/providers/local/sherpa/model-downloader.test.ts index 6152a5236..31b59bf7d 100644 --- a/packages/server/src/server/speech/providers/local/sherpa/model-downloader.test.ts +++ b/packages/server/src/server/speech/providers/local/sherpa/model-downloader.test.ts @@ -35,25 +35,12 @@ describe("sherpa model downloader", () => { const out = await ensureSherpaOnnxModel({ modelsDir, modelId: "kitten-nano-en-v0_1-fp16", - autoDownload: false, logger, }); expect(out).toBe(modelDir); }); - test("ensureSherpaOnnxModel throws when missing and autoDownload is false", async () => { - const modelsDir = makeTmpDir(); - await expect( - ensureSherpaOnnxModel({ - modelsDir, - modelId: "zipformer-bilingual-zh-en-2023-02-20", - autoDownload: false, - logger, - }) - ).rejects.toThrow(/auto-download/i); - }); - test("ensureSherpaOnnxModel logs lifecycle events without progress spam", async () => { const modelsDir = makeTmpDir(); const infoMessages: string[] = []; @@ -82,7 +69,6 @@ describe("sherpa model downloader", () => { await ensureSherpaOnnxModel({ modelsDir, modelId: "pocket-tts-onnx-int8", - autoDownload: true, logger: loggerWithSpy, }); } finally { diff --git a/packages/server/src/server/speech/providers/local/sherpa/model-downloader.ts b/packages/server/src/server/speech/providers/local/sherpa/model-downloader.ts index 4ef2c11bc..c0cf10da1 100644 --- a/packages/server/src/server/speech/providers/local/sherpa/model-downloader.ts +++ b/packages/server/src/server/speech/providers/local/sherpa/model-downloader.ts @@ -11,7 +11,6 @@ import { getSherpaOnnxModelSpec, type SherpaOnnxModelId } from "./model-catalog. export type EnsureSherpaOnnxModelOptions = { modelsDir: string; modelId: SherpaOnnxModelId; - autoDownload: boolean; logger: pino.Logger; }; @@ -104,13 +103,6 @@ export async function ensureSherpaOnnxModel(options: EnsureSherpaOnnxModelOption return modelDir; } - if (!options.autoDownload) { - throw new Error( - `Missing local speech model files for ${options.modelId} in ${modelDir}. ` + - `Set PASEO_LOCAL_AUTO_DOWNLOAD=1 to auto-download.` - ); - } - logger.info({ modelsDir: options.modelsDir }, "Starting model download"); try { @@ -207,7 +199,6 @@ export async function ensureSherpaOnnxModel(options: EnsureSherpaOnnxModelOption export async function ensureSherpaOnnxModels(options: { modelsDir: string; modelIds: SherpaOnnxModelId[]; - autoDownload: boolean; logger: pino.Logger; }): Promise> { const uniq = Array.from(new Set(options.modelIds)); @@ -216,7 +207,6 @@ export async function ensureSherpaOnnxModels(options: { out[id] = await ensureSherpaOnnxModel({ modelsDir: options.modelsDir, modelId: id, - autoDownload: options.autoDownload, logger: options.logger, }); } diff --git a/packages/server/src/server/speech/providers/local/sherpa/sherpa-onnx-node-loader.ts b/packages/server/src/server/speech/providers/local/sherpa/sherpa-onnx-node-loader.ts index 81498c079..ae7107c16 100644 --- a/packages/server/src/server/speech/providers/local/sherpa/sherpa-onnx-node-loader.ts +++ b/packages/server/src/server/speech/providers/local/sherpa/sherpa-onnx-node-loader.ts @@ -1,5 +1,12 @@ import { createRequire } from "node:module"; import path from "node:path"; +import { existsSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { + applySherpaLoaderEnv, + resolveSherpaLoaderEnv, + sherpaPlatformPackageName, +} from "./sherpa-runtime-env.js"; export type SherpaOnnxNodeModule = { OfflineRecognizer: new (config: any) => any; @@ -9,21 +16,77 @@ export type SherpaOnnxNodeModule = { let cached: SherpaOnnxNodeModule | null = null; -function platformArch(): string { - const platform = process.platform === "win32" ? "win" : process.platform; - return `${platform}-${process.arch}`; +type LoadAttempt = { + target: string; + error: unknown; +}; + +function appendAttempt(attempts: LoadAttempt[], target: string, error: unknown): void { + attempts.push({ target, error }); } -function prependLibraryPath( - envKey: "DYLD_LIBRARY_PATH" | "LD_LIBRARY_PATH" | "PATH", - dir: string -): void { - const current = process.env[envKey] ?? ""; - const parts = current.split(path.delimiter).filter(Boolean); - if (parts.includes(dir)) { +function formatError(error: unknown): string { + if (!error) { + return "unknown error"; + } + if (error instanceof Error) { + return error.message; + } + return String(error); +} + +function maybePatchLinuxAddonRunpath(addonPath: string): void { + if (process.platform !== "linux") { return; } - process.env[envKey] = [dir, ...parts].join(path.delimiter); + const patchelfCheck = spawnSync("patchelf", ["--version"], { + stdio: "ignore", + }); + if (patchelfCheck.status !== 0) { + return; + } + + const currentRpath = spawnSync("patchelf", ["--print-rpath", addonPath], { + encoding: "utf8", + }); + if (currentRpath.status !== 0) { + return; + } + const rpath = (currentRpath.stdout ?? "").trim(); + if (rpath.includes("$ORIGIN")) { + return; + } + + spawnSync("patchelf", ["--set-rpath", "$ORIGIN", addonPath], { + stdio: "ignore", + }); +} + +function loadWithRequire( + requireFn: NodeRequire, + target: string, + attempts: LoadAttempt[] +): SherpaOnnxNodeModule | null { + try { + return requireFn(target) as SherpaOnnxNodeModule; + } catch (error) { + appendAttempt(attempts, target, error); + return null; + } +} + +function buildFailure(attempts: LoadAttempt[], pkgName: string): Error { + const details = attempts + .map((attempt) => `- ${attempt.target}: ${formatError(attempt.error)}`) + .join("\n"); + const message = [ + `Failed to load sherpa-onnx-node for ${process.platform}-${process.arch}.`, + `Node ${process.version} (ABI ${process.versions.modules}).`, + `Platform package: ${pkgName}.`, + "Load attempts:", + details || "- (no attempts made)", + ].join("\n"); + return new Error(message); } export function loadSherpaOnnxNode(): SherpaOnnxNodeModule { @@ -32,26 +95,41 @@ export function loadSherpaOnnxNode(): SherpaOnnxNodeModule { } const require = createRequire(import.meta.url); + const attempts: LoadAttempt[] = []; - // sherpa-onnx-node depends on a platform-specific package (e.g. sherpa-onnx-darwin-arm64) - // that contains the native addon and shared libraries. Ensure the OS loader path includes it. - const arch = platformArch(); - const pkgName = `sherpa-onnx-${arch}`; - - try { - const pkgJson = require.resolve(`${pkgName}/package.json`); - const pkgDir = path.dirname(pkgJson); - if (process.platform === "darwin") { - prependLibraryPath("DYLD_LIBRARY_PATH", pkgDir); - } else if (process.platform === "linux") { - prependLibraryPath("LD_LIBRARY_PATH", pkgDir); - } else if (process.platform === "win32") { - prependLibraryPath("PATH", pkgDir); - } - } catch { - // Best effort - if the platform package isn't present, require() below will throw a useful error. + // Pass through upstream support matrix first. + const direct = loadWithRequire(require, "sherpa-onnx-node", attempts); + if (direct) { + cached = direct; + return cached; } - cached = require("sherpa-onnx-node") as SherpaOnnxNodeModule; - return cached; + // sherpa-onnx-node depends on a platform-specific package (e.g. sherpa-onnx-darwin-arm64) + // that contains the native addon and shared libraries. + const pkgName = sherpaPlatformPackageName(); + const resolvedEnv = resolveSherpaLoaderEnv(); + const platformPkgDir = resolvedEnv?.libDir ?? null; + + if (platformPkgDir) { + applySherpaLoaderEnv(process.env); + const addonPath = path.join(platformPkgDir, "sherpa-onnx.node"); + + if (existsSync(addonPath)) { + const byPath = loadWithRequire(require, addonPath, attempts); + if (byPath) { + cached = byPath; + return cached; + } + + // Linux fallback for broken prebuilt RUNPATHs. + maybePatchLinuxAddonRunpath(addonPath); + const afterPatch = loadWithRequire(require, addonPath, attempts); + if (afterPatch) { + cached = afterPatch; + return cached; + } + } + } + + throw buildFailure(attempts, pkgName); } diff --git a/packages/server/src/server/speech/providers/local/sherpa/sherpa-runtime-env.ts b/packages/server/src/server/speech/providers/local/sherpa/sherpa-runtime-env.ts new file mode 100644 index 000000000..85cbd80f5 --- /dev/null +++ b/packages/server/src/server/speech/providers/local/sherpa/sherpa-runtime-env.ts @@ -0,0 +1,103 @@ +import { createRequire } from "node:module"; +import path from "node:path"; + +export type SherpaLoaderEnvKey = "LD_LIBRARY_PATH" | "DYLD_LIBRARY_PATH" | "PATH"; + +export type SherpaLoaderEnvResolution = { + key: SherpaLoaderEnvKey; + libDir: string; + packageName: string; +}; + +export function sherpaPlatformArch( + platform: NodeJS.Platform = process.platform, + arch: string = process.arch +): string { + const normalizedPlatform = platform === "win32" ? "win" : platform; + return `${normalizedPlatform}-${arch}`; +} + +export function sherpaPlatformPackageName( + platform: NodeJS.Platform = process.platform, + arch: string = process.arch +): string { + return `sherpa-onnx-${sherpaPlatformArch(platform, arch)}`; +} + +export function sherpaLoaderEnvKey( + platform: NodeJS.Platform = process.platform +): SherpaLoaderEnvKey | null { + if (platform === "linux") { + return "LD_LIBRARY_PATH"; + } + if (platform === "darwin") { + return "DYLD_LIBRARY_PATH"; + } + if (platform === "win32") { + return "PATH"; + } + return null; +} + +export function prependEnvPath(existing: string | undefined, value: string): string { + const parts = (existing ?? "").split(path.delimiter).filter(Boolean); + if (parts.includes(value)) { + return parts.join(path.delimiter); + } + return [value, ...parts].join(path.delimiter); +} + +export function resolveSherpaLoaderEnv( + platform: NodeJS.Platform = process.platform, + arch: string = process.arch +): SherpaLoaderEnvResolution | null { + const key = sherpaLoaderEnvKey(platform); + if (!key) { + return null; + } + + const packageName = sherpaPlatformPackageName(platform, arch); + const require = createRequire(import.meta.url); + try { + const pkgJson = require.resolve(`${packageName}/package.json`); + return { + key, + libDir: path.dirname(pkgJson), + packageName, + }; + } catch { + return null; + } +} + +export function applySherpaLoaderEnv( + env: NodeJS.ProcessEnv, + platform: NodeJS.Platform = process.platform, + arch: string = process.arch +): { + changed: boolean; + key: SherpaLoaderEnvKey | null; + libDir: string | null; + packageName: string | null; +} { + const resolved = resolveSherpaLoaderEnv(platform, arch); + if (!resolved) { + return { + changed: false, + key: null, + libDir: null, + packageName: null, + }; + } + + const next = prependEnvPath(env[resolved.key], resolved.libDir); + const changed = next !== (env[resolved.key] ?? ""); + env[resolved.key] = next; + return { + changed, + key: resolved.key, + libDir: resolved.libDir, + packageName: resolved.packageName, + }; +} + diff --git a/packages/server/src/server/speech/providers/local/sherpa/speech-download.e2e.test.ts b/packages/server/src/server/speech/providers/local/sherpa/speech-download.e2e.test.ts index f567ad39b..287616202 100644 --- a/packages/server/src/server/speech/providers/local/sherpa/speech-download.e2e.test.ts +++ b/packages/server/src/server/speech/providers/local/sherpa/speech-download.e2e.test.ts @@ -106,7 +106,6 @@ describe("speech models (download E2E)", () => { await ensureSherpaOnnxModels({ modelsDir, modelIds, - autoDownload: true, logger, }); @@ -121,7 +120,6 @@ describe("speech models (download E2E)", () => { }, local: { modelsDir, - autoDownload: false, models: { dictationStt: set === "parakeet-pocket" diff --git a/packages/server/src/server/speech/speech-config-resolver.test.ts b/packages/server/src/server/speech/speech-config-resolver.test.ts index 5015cdb09..0e715af79 100644 --- a/packages/server/src/server/speech/speech-config-resolver.test.ts +++ b/packages/server/src/server/speech/speech-config-resolver.test.ts @@ -35,7 +35,6 @@ describe("resolveSpeechConfig", () => { }); expect(result.speech.local).toEqual({ modelsDir: path.join(paseoHome, "models", "local-speech"), - autoDownload: true, models: { dictationStt: "parakeet-tdt-0.6b-v2-int8", voiceStt: "parakeet-tdt-0.6b-v2-int8", @@ -67,7 +66,6 @@ describe("resolveSpeechConfig", () => { PASEO_VOICE_LOCAL_TTS_SPEAKER_ID: "5", PASEO_VOICE_LOCAL_TTS_SPEED: "1.35", PASEO_LOCAL_MODELS_DIR: "/tmp/models", - PASEO_LOCAL_AUTO_DOWNLOAD: "0", OPENAI_API_KEY: "env-key", PASEO_VOICE_STT_PROVIDER: "openai", PASEO_DICTATION_STT_PROVIDER: "local", @@ -82,7 +80,6 @@ describe("resolveSpeechConfig", () => { expect(result.speech.local).toEqual({ modelsDir: "/tmp/models", - autoDownload: false, models: { dictationStt: "zipformer-bilingual-zh-en-2023-02-20", voiceStt: "parakeet-tdt-0.6b-v3-int8", diff --git a/packages/server/src/server/speech/speech-runtime.test.ts b/packages/server/src/server/speech/speech-runtime.test.ts index fc2fe097c..b34b2f936 100644 --- a/packages/server/src/server/speech/speech-runtime.test.ts +++ b/packages/server/src/server/speech/speech-runtime.test.ts @@ -79,7 +79,6 @@ describe("initializeSpeechRuntime readiness", () => { availability: { configured: false, modelsDir: null, - autoDownload: null, }, cleanup: () => {}, }); @@ -115,7 +114,6 @@ describe("initializeSpeechRuntime readiness", () => { availability: { configured: false, modelsDir: null, - autoDownload: null, }, cleanup: () => {}, }); diff --git a/packages/server/src/server/speech/speech-runtime.ts b/packages/server/src/server/speech/speech-runtime.ts index 0492fb317..618a8d46b 100644 --- a/packages/server/src/server/speech/speech-runtime.ts +++ b/packages/server/src/server/speech/speech-runtime.ts @@ -205,7 +205,6 @@ function buildVoiceFeatureReadiness(params: { missingLocalModelIds: LocalSpeechModelId[]; backgroundDownloadInProgress: boolean; backgroundDownloadError: string | null; - localAutoDownloadEnabled: boolean; }): SpeechReadinessState { const enabled = params.realtimeVoice.enabled || params.dictation.enabled; if (!enabled) { @@ -246,7 +245,7 @@ function buildVoiceFeatureReadiness(params: { available: false, reasonCode: "models_missing", message: `Voice features are unavailable: missing local models (${joinModelIds(missingModelIds)}).`, - retryable: params.localAutoDownloadEnabled, + retryable: true, missingModelIds, }; } @@ -328,7 +327,6 @@ export async function initializeSpeechRuntime(params: { const openaiConfig = params.openaiConfig; const providers = resolveRequestedSpeechProviders(speechConfig); const requestedProviders = describeRequestedProviders(providers); - const localAutoDownloadEnabled = speechConfig?.local?.autoDownload ?? true; validateOpenAiCredentialRequirements({ providers, @@ -382,7 +380,6 @@ export async function initializeSpeechRuntime(params: { missingLocalModelIds, backgroundDownloadInProgress, backgroundDownloadError, - localAutoDownloadEnabled, }); return { generatedAt: new Date().toISOString(), @@ -459,12 +456,11 @@ export async function initializeSpeechRuntime(params: { }); }; - const reconcileServices = async (modelEnsureAutoDownload: boolean): Promise => { + const reconcileServices = async (): Promise => { const nextLocalSpeech = await initializeLocalSpeechServices({ providers, speechConfig, logger, - modelEnsureAutoDownload, }); const nextOpenAiSpeech = initializeOpenAiSpeechServices({ providers, @@ -521,13 +517,13 @@ export async function initializeSpeechRuntime(params: { } }; - const runReconcile = async (params: { modelEnsureAutoDownload: boolean }): Promise => { + const runReconcile = async (): Promise => { if (reconcileInFlight) { await reconcileInFlight; publishReadinessIfChanged(); return; } - reconcileInFlight = reconcileServices(params.modelEnsureAutoDownload).finally(() => { + reconcileInFlight = reconcileServices().finally(() => { reconcileInFlight = null; }); await reconcileInFlight; @@ -550,7 +546,7 @@ export async function initializeSpeechRuntime(params: { } const modelsDir = localModelConfig?.modelsDir ?? null; const modelIds = [...missingLocalModelIds]; - if (!modelsDir || modelIds.length === 0 || !localAutoDownloadEnabled) { + if (!modelsDir || modelIds.length === 0) { return; } @@ -571,10 +567,9 @@ export async function initializeSpeechRuntime(params: { await ensureLocalSpeechModels({ modelsDir, modelIds, - autoDownload: true, logger, }); - await runReconcile({ modelEnsureAutoDownload: false }); + await runReconcile(); backgroundDownloadError = null; } catch (error) { backgroundDownloadError = error instanceof Error ? error.message : String(error); @@ -610,12 +605,11 @@ export async function initializeSpeechRuntime(params: { missingLocalModelIds.length === 0 && !backgroundDownloadInProgress ) { - await runReconcile({ modelEnsureAutoDownload: false }); + await runReconcile(); } if ( missingLocalModelIds.length > 0 && - localAutoDownloadEnabled && !backgroundDownloadInProgress && !backgroundDownloadError ) { @@ -629,10 +623,10 @@ export async function initializeSpeechRuntime(params: { } }; - await runReconcile({ modelEnsureAutoDownload: false }); + await runReconcile(); const snapshot = computeReadinessSnapshot(); if (snapshot.voiceFeature.enabled && !snapshot.voiceFeature.available) { - if (missingLocalModelIds.length > 0 && localAutoDownloadEnabled) { + if (missingLocalModelIds.length > 0) { startBackgroundDownload(); } scheduleMonitor(); diff --git a/packages/server/src/server/test-utils/daemon-test-context.ts b/packages/server/src/server/test-utils/daemon-test-context.ts index f966547a8..59520b82f 100644 --- a/packages/server/src/server/test-utils/daemon-test-context.ts +++ b/packages/server/src/server/test-utils/daemon-test-context.ts @@ -43,7 +43,7 @@ export async function createDaemonTestContext( url: `ws://127.0.0.1:${daemon.port}/ws`, }); await client.connect(); - client.subscribeAgentUpdates({ subscriptionId: "test" }); + await client.fetchAgents({ subscribe: { subscriptionId: "test" } }); return { daemon, diff --git a/packages/server/src/server/utils/syntax-highlighter.ts b/packages/server/src/server/utils/syntax-highlighter.ts index 9e365d7ad..c86a5a40e 100644 --- a/packages/server/src/server/utils/syntax-highlighter.ts +++ b/packages/server/src/server/utils/syntax-highlighter.ts @@ -5,6 +5,7 @@ import { parser as cssParser } from "@lezer/css"; import { parser as htmlParser } from "@lezer/html"; import { parser as pythonParser } from "@lezer/python"; import { parser as markdownParser } from "@lezer/markdown"; +import { parser as elixirParser } from "lezer-elixir"; import type { Parser } from "@lezer/common"; // Map file extensions to parsers @@ -26,6 +27,9 @@ const parsersByExtension: Record = { htm: htmlParser, // Python py: pythonParser, + // Elixir + ex: elixirParser, + exs: elixirParser, // Markdown md: markdownParser, mdx: markdownParser, diff --git a/packages/server/src/server/worktree-bootstrap.test.ts b/packages/server/src/server/worktree-bootstrap.test.ts new file mode 100644 index 000000000..6d640edcf --- /dev/null +++ b/packages/server/src/server/worktree-bootstrap.test.ts @@ -0,0 +1,462 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { execSync } from "child_process"; +import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; + +import type { AgentTimelineItem } from "./agent/agent-sdk-types.js"; +import { + createAgentWorktree, + runAsyncWorktreeBootstrap, +} from "./worktree-bootstrap.js"; + +describe("runAsyncWorktreeBootstrap", () => { + let tempDir: string; + let repoDir: string; + let paseoHome: string; + + async function waitForPathExists(targetPath: string, timeoutMs = 10000): Promise { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + if (existsSync(targetPath)) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } + throw new Error(`Timed out waiting for path: ${targetPath}`); + } + + beforeEach(() => { + tempDir = realpathSync(mkdtempSync(join(tmpdir(), "worktree-bootstrap-test-"))); + repoDir = join(tempDir, "repo"); + paseoHome = join(tempDir, "paseo-home"); + + execSync(`mkdir -p ${repoDir}`); + execSync("git init -b main", { cwd: repoDir, stdio: "pipe" }); + execSync("git config user.email 'test@test.com'", { cwd: repoDir, stdio: "pipe" }); + execSync("git config user.name 'Test'", { cwd: repoDir, stdio: "pipe" }); + execSync("echo 'hello' > file.txt", { cwd: repoDir, stdio: "pipe" }); + execSync("git add .", { cwd: repoDir, stdio: "pipe" }); + execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: "pipe" }); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it("streams running setup updates live and persists only a final setup timeline row", async () => { + writeFileSync( + join(repoDir, "paseo.json"), + JSON.stringify({ + worktree: { + setup: ['echo "line-one"; echo "line-two" 1>&2', 'echo "line-three"'], + }, + }) + ); + execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" }); + execSync("git -c commit.gpgsign=false commit -m 'add setup'", { + cwd: repoDir, + stdio: "pipe", + }); + + const worktree = await createAgentWorktree({ + cwd: repoDir, + branchName: "feature-streaming-setup", + baseBranch: "main", + worktreeSlug: "feature-streaming-setup", + paseoHome, + }); + + const persisted: AgentTimelineItem[] = []; + const live: AgentTimelineItem[] = []; + + await runAsyncWorktreeBootstrap({ + agentId: "agent-test", + worktree, + terminalManager: null, + appendTimelineItem: async (item) => { + persisted.push(item); + return true; + }, + emitLiveTimelineItem: async (item: AgentTimelineItem) => { + live.push(item); + return true; + }, + }); + + const liveSetupItems = live.filter( + (item) => + item.type === "tool_call" && + item.name === "paseo_worktree_setup" && + item.status === "running" + ); + expect(liveSetupItems.length).toBeGreaterThan(0); + + const persistedSetupItems = persisted.filter( + (item) => item.type === "tool_call" && item.name === "paseo_worktree_setup" + ); + expect(persistedSetupItems).toHaveLength(1); + expect(persistedSetupItems[0]?.type).toBe("tool_call"); + if (persistedSetupItems[0]?.type === "tool_call") { + expect(persistedSetupItems[0].status).toBe("completed"); + expect(persistedSetupItems[0].detail.type).toBe("worktree_setup"); + + if (persistedSetupItems[0].detail.type === "worktree_setup") { + expect(persistedSetupItems[0].detail.log).toContain( + "==> [1/2] Running: echo \"line-one\"; echo \"line-two\" 1>&2" + ); + expect(persistedSetupItems[0].detail.log).toContain("line-one"); + expect(persistedSetupItems[0].detail.log).toContain("line-two"); + expect(persistedSetupItems[0].detail.log).toContain( + "==> [2/2] Running: echo \"line-three\"" + ); + expect(persistedSetupItems[0].detail.log).toContain("line-three"); + expect(persistedSetupItems[0].detail.log).toMatch(/<== \[1\/2\] Exit 0 in \d+\.\d{2}s/); + expect(persistedSetupItems[0].detail.log).toMatch(/<== \[2\/2\] Exit 0 in \d+\.\d{2}s/); + + expect(persistedSetupItems[0].detail.commands).toHaveLength(2); + expect(persistedSetupItems[0].detail.commands[0]).toMatchObject({ + index: 1, + command: 'echo "line-one"; echo "line-two" 1>&2', + status: "completed", + exitCode: 0, + }); + expect(persistedSetupItems[0].detail.commands[1]).toMatchObject({ + index: 2, + command: 'echo "line-three"', + status: "completed", + exitCode: 0, + }); + expect( + typeof persistedSetupItems[0].detail.commands[0]?.durationMs === "number" + ).toBe(true); + expect( + typeof persistedSetupItems[0].detail.commands[1]?.durationMs === "number" + ).toBe(true); + } + } + + const liveCallIds = new Set( + liveSetupItems + .filter((item): item is Extract => item.type === "tool_call") + .map((item) => item.callId) + ); + expect(liveCallIds.size).toBe(1); + if (persistedSetupItems[0]?.type === "tool_call") { + expect(liveCallIds.has(persistedSetupItems[0].callId)).toBe(true); + } + }); + + it("does not fail setup when live timeline emission throws", async () => { + writeFileSync( + join(repoDir, "paseo.json"), + JSON.stringify({ + worktree: { + setup: ['echo "ok"'], + }, + }) + ); + execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" }); + execSync("git -c commit.gpgsign=false commit -m 'add setup'", { + cwd: repoDir, + stdio: "pipe", + }); + + const worktree = await createAgentWorktree({ + cwd: repoDir, + branchName: "feature-live-failure", + baseBranch: "main", + worktreeSlug: "feature-live-failure", + paseoHome, + }); + + const persisted: AgentTimelineItem[] = []; + await expect( + runAsyncWorktreeBootstrap({ + agentId: "agent-live-failure", + worktree, + terminalManager: null, + appendTimelineItem: async (item) => { + persisted.push(item); + return true; + }, + emitLiveTimelineItem: async () => { + throw new Error("live emit failed"); + }, + }) + ).resolves.toBeUndefined(); + + const persistedSetupItems = persisted.filter( + (item) => item.type === "tool_call" && item.name === "paseo_worktree_setup" + ); + expect(persistedSetupItems).toHaveLength(1); + if (persistedSetupItems[0]?.type === "tool_call") { + expect(persistedSetupItems[0].status).toBe("completed"); + } + }); + + it("truncates each command output to 64kb in the middle", async () => { + const largeOutputCommand = + "node -e \"process.stdout.write('prefix-'); process.stdout.write('x'.repeat(70000)); process.stdout.write('-suffix')\""; + writeFileSync( + join(repoDir, "paseo.json"), + JSON.stringify({ + worktree: { + setup: [largeOutputCommand], + }, + }) + ); + execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" }); + execSync("git -c commit.gpgsign=false commit -m 'add large output setup'", { + cwd: repoDir, + stdio: "pipe", + }); + + const worktree = await createAgentWorktree({ + cwd: repoDir, + branchName: "feature-large-output", + baseBranch: "main", + worktreeSlug: "feature-large-output", + paseoHome, + }); + + const persisted: AgentTimelineItem[] = []; + await runAsyncWorktreeBootstrap({ + agentId: "agent-large-output", + worktree, + terminalManager: null, + appendTimelineItem: async (item) => { + persisted.push(item); + return true; + }, + emitLiveTimelineItem: async () => true, + }); + + const persistedSetupItem = persisted.find( + (item): item is Extract => + item.type === "tool_call" && item.name === "paseo_worktree_setup" + ); + expect(persistedSetupItem).toBeDefined(); + expect(persistedSetupItem?.detail.type).toBe("worktree_setup"); + if (!persistedSetupItem || persistedSetupItem.detail.type !== "worktree_setup") { + throw new Error("Expected worktree_setup tool detail"); + } + + expect(persistedSetupItem.detail.truncated).toBe(true); + expect(persistedSetupItem.detail.log).toContain("prefix-"); + expect(persistedSetupItem.detail.log).toContain("-suffix"); + expect(persistedSetupItem.detail.log).toContain("......"); + }); + + it("waits for terminal output before sending bootstrap commands", async () => { + writeFileSync( + join(repoDir, "paseo.json"), + JSON.stringify({ + worktree: { + terminals: [ + { + name: "Ready Terminal", + command: "echo ready", + }, + ], + }, + }) + ); + execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" }); + execSync("git -c commit.gpgsign=false commit -m 'add terminal bootstrap config'", { + cwd: repoDir, + stdio: "pipe", + }); + + const worktree = await createAgentWorktree({ + cwd: repoDir, + branchName: "feature-terminal-readiness", + baseBranch: "main", + worktreeSlug: "feature-terminal-readiness", + paseoHome, + }); + + let readyAt = 0; + let sendAt = 0; + let outputListener: ((chunk: { data: string }) => void) | null = null; + + await runAsyncWorktreeBootstrap({ + agentId: "agent-terminal-readiness", + worktree, + terminalManager: { + async getTerminals() { + return []; + }, + async createTerminal(options) { + setTimeout(() => { + readyAt = Date.now(); + outputListener?.({ data: "$ " }); + }, 25); + return { + id: "term-ready", + name: options.name ?? "Terminal", + cwd: options.cwd, + send: () => { + sendAt = Date.now(); + }, + subscribe: () => () => {}, + onExit: () => () => {}, + subscribeRaw: (listener) => { + outputListener = (chunk) => + listener({ + data: chunk.data, + startOffset: 0, + endOffset: chunk.data.length, + replay: false, + }); + return { + unsubscribe: () => { + outputListener = null; + }, + replayedFrom: 0, + currentOffset: 0, + earliestAvailableOffset: 0, + reset: false, + }; + }, + getOutputOffset: () => 0, + getState: () => ({ + rows: 0, + cols: 0, + grid: [], + scrollback: [], + cursor: { row: 0, col: 0 }, + }), + kill: () => {}, + }; + }, + registerCwdEnv() {}, + getTerminal() { + return undefined; + }, + killTerminal() {}, + listDirectories() { + return []; + }, + killAll() {}, + subscribeTerminalsChanged() { + return () => {}; + }, + }, + appendTimelineItem: async () => true, + emitLiveTimelineItem: async () => true, + }); + + expect(readyAt).toBeGreaterThan(0); + expect(sendAt).toBeGreaterThan(0); + expect(sendAt).toBeGreaterThanOrEqual(readyAt); + }); + + it("shares the same worktree runtime port across setup and bootstrap terminals", async () => { + writeFileSync( + join(repoDir, "paseo.json"), + JSON.stringify({ + worktree: { + setup: ['echo "$PASEO_WORKTREE_PORT" > setup-port.txt'], + terminals: [ + { + name: "Port Terminal", + command: "true", + }, + ], + }, + }) + ); + execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" }); + execSync("git -c commit.gpgsign=false commit -m 'add port setup and terminals'", { + cwd: repoDir, + stdio: "pipe", + }); + + const worktree = await createAgentWorktree({ + cwd: repoDir, + branchName: "feature-shared-runtime-port", + baseBranch: "main", + worktreeSlug: "feature-shared-runtime-port", + paseoHome, + }); + + const registeredEnvs: Array<{ cwd: string; env: Record }> = []; + const createTerminalEnvs: Record[] = []; + const persisted: AgentTimelineItem[] = []; + await runAsyncWorktreeBootstrap({ + agentId: "agent-shared-runtime-port", + worktree, + terminalManager: { + async getTerminals() { + return []; + }, + async createTerminal(options) { + createTerminalEnvs.push(options.env ?? {}); + return { + id: "term-1", + name: options.name ?? "Terminal", + cwd: options.cwd, + send: () => {}, + subscribe: () => () => {}, + onExit: () => () => {}, + subscribeRaw: () => ({ + unsubscribe: () => {}, + replayedFrom: 0, + currentOffset: 0, + earliestAvailableOffset: 0, + reset: false, + }), + getOutputOffset: () => 1, + getState: () => ({ + rows: 0, + cols: 0, + grid: [], + scrollback: [], + cursor: { row: 0, col: 0 }, + }), + kill: () => {}, + }; + }, + registerCwdEnv(options) { + registeredEnvs.push({ cwd: options.cwd, env: options.env }); + }, + getTerminal() { + return undefined; + }, + killTerminal() {}, + listDirectories() { + return []; + }, + killAll() {}, + subscribeTerminalsChanged() { + return () => {}; + }, + }, + appendTimelineItem: async (item) => { + persisted.push(item); + return true; + }, + emitLiveTimelineItem: async () => true, + }); + + const setupPortPath = join(worktree.worktreePath, "setup-port.txt"); + await waitForPathExists(setupPortPath); + + const setupPort = readFileSync(setupPortPath, "utf8").trim(); + expect(setupPort.length).toBeGreaterThan(0); + expect(registeredEnvs).toHaveLength(1); + expect(registeredEnvs[0]?.cwd).toBe(worktree.worktreePath); + expect(registeredEnvs[0]?.env.PASEO_WORKTREE_PORT).toBe(setupPort); + expect(createTerminalEnvs.length).toBeGreaterThan(0); + expect(createTerminalEnvs[0]?.PASEO_WORKTREE_PORT).toBe(setupPort); + + const terminalToolCall = persisted.find( + (item): item is Extract => + item.type === "tool_call" && + item.name === "paseo_worktree_terminals" && + item.status === "completed" + ); + expect(terminalToolCall?.status).toBe("completed"); + }); +}); diff --git a/packages/server/src/server/worktree-bootstrap.ts b/packages/server/src/server/worktree-bootstrap.ts index 873ebc1ed..731955301 100644 --- a/packages/server/src/server/worktree-bootstrap.ts +++ b/packages/server/src/server/worktree-bootstrap.ts @@ -1,13 +1,16 @@ import { v4 as uuidv4 } from "uuid"; import type { Logger } from "pino"; import type { TerminalManager } from "../terminal/terminal-manager.js"; +import type { TerminalSession } from "../terminal/terminal.js"; import { createWorktree, getWorktreeTerminalSpecs, + resolveWorktreeRuntimeEnv, runWorktreeSetupCommands, WorktreeSetupError, type WorktreeConfig, type WorktreeSetupCommandResult, + type WorktreeRuntimeEnv, } from "../utils/worktree.js"; import type { AgentTimelineItem } from "./agent/agent-sdk-types.js"; @@ -24,6 +27,7 @@ export interface RunAsyncWorktreeBootstrapOptions { worktree: WorktreeConfig; terminalManager: TerminalManager | null; appendTimelineItem: (item: AgentTimelineItem) => Promise; + emitLiveTimelineItem?: (item: AgentTimelineItem) => Promise; logger?: Logger; } @@ -35,6 +39,117 @@ export interface CreateAgentWorktreeOptions { paseoHome?: string; } +const MAX_WORKTREE_SETUP_COMMAND_OUTPUT_BYTES = 64 * 1024; +const WORKTREE_SETUP_TRUNCATION_MARKER = "\n......\n"; +const WORKTREE_BOOTSTRAP_TERMINAL_READY_TIMEOUT_MS = 1_500; + +type MiddleTruncationAccumulator = { + totalBytes: number; + head: string; + tail: string; + truncated: boolean; +}; + +function byteLength(text: string): number { + return Buffer.byteLength(text, "utf8"); +} + +function sliceFirstBytes(text: string, maxBytes: number): string { + if (maxBytes <= 0 || text.length === 0) { + return ""; + } + const bytes = Buffer.from(text, "utf8"); + if (bytes.length <= maxBytes) { + return text; + } + return bytes.subarray(0, maxBytes).toString("utf8"); +} + +function sliceLastBytes(text: string, maxBytes: number): string { + if (maxBytes <= 0 || text.length === 0) { + return ""; + } + const bytes = Buffer.from(text, "utf8"); + if (bytes.length <= maxBytes) { + return text; + } + return bytes.subarray(bytes.length - maxBytes).toString("utf8"); +} + +function createMiddleTruncationAccumulator(): MiddleTruncationAccumulator { + return { + totalBytes: 0, + head: "", + tail: "", + truncated: false, + }; +} + +function getHeadTailBudgets(maxBytes: number): { headBytes: number; tailBytes: number } { + const markerBytes = byteLength(WORKTREE_SETUP_TRUNCATION_MARKER); + const availableBytes = Math.max(0, maxBytes - markerBytes); + const headBytes = Math.floor(availableBytes / 2); + const tailBytes = availableBytes - headBytes; + return { headBytes, tailBytes }; +} + +function appendToMiddleTruncationAccumulator( + accumulator: MiddleTruncationAccumulator, + chunk: string +): void { + if (!chunk) { + return; + } + accumulator.totalBytes += byteLength(chunk); + + if (!accumulator.truncated) { + const combined = `${accumulator.head}${chunk}`; + if (byteLength(combined) <= MAX_WORKTREE_SETUP_COMMAND_OUTPUT_BYTES) { + accumulator.head = combined; + return; + } + const { headBytes, tailBytes } = getHeadTailBudgets( + MAX_WORKTREE_SETUP_COMMAND_OUTPUT_BYTES + ); + accumulator.head = sliceFirstBytes(combined, headBytes); + accumulator.tail = sliceLastBytes(combined, tailBytes); + accumulator.truncated = true; + return; + } + + const { tailBytes } = getHeadTailBudgets(MAX_WORKTREE_SETUP_COMMAND_OUTPUT_BYTES); + accumulator.tail = sliceLastBytes(`${accumulator.tail}${chunk}`, tailBytes); +} + +function truncateTextInMiddle( + text: string, + maxBytes: number +): { text: string; truncated: boolean } { + if (maxBytes <= 0 || !text) { + return { text: "", truncated: text.length > 0 }; + } + if (byteLength(text) <= maxBytes) { + return { text, truncated: false }; + } + const { headBytes, tailBytes } = getHeadTailBudgets(maxBytes); + return { + text: `${sliceFirstBytes(text, headBytes)}${WORKTREE_SETUP_TRUNCATION_MARKER}${sliceLastBytes(text, tailBytes)}`, + truncated: true, + }; +} + +function renderMiddleTruncationAccumulator( + accumulator: MiddleTruncationAccumulator +): { text: string; truncated: boolean } { + if (!accumulator.truncated) { + return { text: accumulator.head, truncated: false }; + } + return { + text: `${accumulator.head}${WORKTREE_SETUP_TRUNCATION_MARKER}${accumulator.tail}`, + truncated: true, + }; +} + export async function createAgentWorktree( options: CreateAgentWorktreeOptions ): Promise { @@ -48,25 +163,88 @@ export async function createAgentWorktree( }); } +function formatDurationMs(durationMs: number): string { + return `${(durationMs / 1000).toFixed(2)}s`; +} + +function commandStatusFromResult( + result: WorktreeSetupCommandResult +): "running" | "completed" | "failed" { + if (result.exitCode === null) { + return "running"; + } + return result.exitCode === 0 ? "completed" : "failed"; +} + +function buildWorktreeSetupLog(input: { + results: WorktreeSetupCommandResult[]; + outputAccumulatorsByIndex?: Map; +}): { log: string; truncated: boolean } { + const { results, outputAccumulatorsByIndex } = input; + if (results.length === 0) { + return { + log: "", + truncated: false, + }; + } + + const lines: string[] = []; + let anyTruncated = false; + const total = results.length; + for (const [index, result] of results.entries()) { + lines.push(`==> [${index + 1}/${total}] Running: ${result.command}`); + const accumulator = outputAccumulatorsByIndex?.get(index + 1); + const output = accumulator + ? renderMiddleTruncationAccumulator(accumulator) + : truncateTextInMiddle( + `${result.stdout ?? ""}${result.stderr ?? ""}`, + MAX_WORKTREE_SETUP_COMMAND_OUTPUT_BYTES + ); + if (output.text.length > 0) { + lines.push(output.text.replace(/\n$/, "")); + } + if (output.truncated) { + anyTruncated = true; + } + if (result.exitCode !== null) { + lines.push( + `<== [${index + 1}/${total}] Exit ${result.exitCode} in ${formatDurationMs(result.durationMs)}` + ); + } + } + return { + log: lines.join("\n"), + truncated: anyTruncated, + }; +} + function buildSetupTimelineItem(input: { callId: string; status: "running" | "completed" | "failed"; worktree: WorktreeConfig; results: WorktreeSetupCommandResult[]; + outputAccumulatorsByIndex?: Map; errorMessage: string | null; }): AgentTimelineItem { - const detailInput = { + const commands = input.results.map((result, index) => ({ + index: index + 1, + command: result.command, + cwd: result.cwd, + status: commandStatusFromResult(result), + exitCode: result.exitCode, + ...(result.durationMs > 0 ? { durationMs: result.durationMs } : {}), + })); + const renderedLog = buildWorktreeSetupLog({ + results: input.results, + outputAccumulatorsByIndex: input.outputAccumulatorsByIndex, + }); + const detail = { + type: "worktree_setup" as const, worktreePath: input.worktree.worktreePath, branchName: input.worktree.branchName, - }; - const detailOutput = { - worktreePath: input.worktree.worktreePath, - commands: input.results.map((result) => ({ - command: result.command, - cwd: result.cwd, - exitCode: result.exitCode, - output: `${result.stdout ?? ""}${result.stderr ? `\n${result.stderr}` : ""}`.trim(), - })), + log: renderedLog.log, + commands, + ...(renderedLog.truncated ? { truncated: true } : {}), }; if (input.status === "running") { @@ -75,11 +253,7 @@ function buildSetupTimelineItem(input: { name: "paseo_worktree_setup", callId: input.callId, status: "running", - detail: { - type: "unknown", - input: detailInput, - output: null, - }, + detail, error: null, }; } @@ -90,11 +264,7 @@ function buildSetupTimelineItem(input: { name: "paseo_worktree_setup", callId: input.callId, status: "completed", - detail: { - type: "unknown", - input: detailInput, - output: detailOutput, - }, + detail, error: null, }; } @@ -104,11 +274,7 @@ function buildSetupTimelineItem(input: { name: "paseo_worktree_setup", callId: input.callId, status: "failed", - detail: { - type: "unknown", - input: detailInput, - output: detailOutput, - }, + detail, error: { message: input.errorMessage ?? "Worktree setup failed" }, }; } @@ -173,8 +339,51 @@ function buildTerminalTimelineItem(input: { }; } +async function waitForTerminalBootstrapReadiness( + terminal: Pick +): Promise { + if (terminal.getOutputOffset() > 0) { + return; + } + + await new Promise((resolve) => { + let settled = false; + let timeout: ReturnType | null = null; + let unsubscribe: (() => void) | null = null; + + const finish = () => { + if (settled) { + return; + } + settled = true; + if (timeout) { + clearTimeout(timeout); + timeout = null; + } + if (unsubscribe) { + unsubscribe(); + unsubscribe = null; + } + resolve(); + }; + + const rawSubscription = terminal.subscribeRaw(() => { + finish(); + }); + unsubscribe = rawSubscription.unsubscribe; + + if (terminal.getOutputOffset() > 0) { + finish(); + return; + } + + timeout = setTimeout(finish, WORKTREE_BOOTSTRAP_TERMINAL_READY_TIMEOUT_MS); + }); +} + async function runWorktreeTerminalBootstrap( - options: RunAsyncWorktreeBootstrapOptions + options: RunAsyncWorktreeBootstrapOptions, + runtimeEnv: WorktreeRuntimeEnv ): Promise { const terminalSpecs = getWorktreeTerminalSpecs(options.worktree.worktreePath); if (terminalSpecs.length === 0) { @@ -214,7 +423,9 @@ async function runWorktreeTerminalBootstrap( const terminal = await options.terminalManager.createTerminal({ cwd: options.worktree.worktreePath, name: spec.name, + env: runtimeEnv, }); + await waitForTerminalBootstrapReadiness(terminal); terminal.send({ type: "input", data: `${spec.command}\r`, @@ -258,40 +469,116 @@ export async function runAsyncWorktreeBootstrap( ): Promise { const setupCallId = uuidv4(); let setupResults: WorktreeSetupCommandResult[] = []; + let runtimeEnv: WorktreeRuntimeEnv | null = null; + const emitLiveTimelineItem = options.emitLiveTimelineItem; + const runningResultsByIndex = new Map(); + const outputAccumulatorsByIndex = new Map(); + let liveEmitQueue = Promise.resolve(); - try { - const started = await options.appendTimelineItem( - buildSetupTimelineItem({ - callId: setupCallId, - status: "running", - worktree: options.worktree, - results: [], - errorMessage: null, - }) - ); - if (!started) { + const queueLiveRunningEmit = () => { + if (!emitLiveTimelineItem) { return; } + const runningResults = Array.from(runningResultsByIndex.entries()) + .sort((a, b) => a[0] - b[0]) + .map(([, result]) => result); + liveEmitQueue = liveEmitQueue.then(async () => { + try { + await emitLiveTimelineItem( + buildSetupTimelineItem({ + callId: setupCallId, + status: "running", + worktree: options.worktree, + results: runningResults, + outputAccumulatorsByIndex, + errorMessage: null, + }) + ); + } catch (error) { + options.logger?.warn( + { err: error, agentId: options.agentId }, + "Failed to emit live worktree setup timeline update" + ); + } + }); + }; + + try { + runtimeEnv = await resolveWorktreeRuntimeEnv({ + worktreePath: options.worktree.worktreePath, + branchName: options.worktree.branchName, + }); + options.terminalManager?.registerCwdEnv({ + cwd: options.worktree.worktreePath, + env: runtimeEnv, + }); setupResults = await runWorktreeSetupCommands({ worktreePath: options.worktree.worktreePath, branchName: options.worktree.branchName, cleanupOnFailure: false, + runtimeEnv, + onEvent: (event) => { + const existing = runningResultsByIndex.get(event.index); + const baseResult: WorktreeSetupCommandResult = existing ?? { + command: event.command, + cwd: event.cwd, + stdout: "", + stderr: "", + exitCode: null, + durationMs: 0, + }; + if (event.type === "output") { + const outputAccumulator = + outputAccumulatorsByIndex.get(event.index) ?? + createMiddleTruncationAccumulator(); + appendToMiddleTruncationAccumulator(outputAccumulator, event.chunk); + outputAccumulatorsByIndex.set(event.index, outputAccumulator); + runningResultsByIndex.set(event.index, { + ...baseResult, + // Keep the timeline command model lightweight; output is carried in + // outputAccumulatorsByIndex. + stdout: baseResult.stdout, + stderr: baseResult.stderr, + }); + queueLiveRunningEmit(); + return; + } + if (event.type === "command_completed") { + runningResultsByIndex.set(event.index, { + ...baseResult, + stdout: event.stdout, + stderr: event.stderr, + exitCode: event.exitCode, + durationMs: event.durationMs, + }); + queueLiveRunningEmit(); + return; + } + runningResultsByIndex.set(event.index, baseResult); + queueLiveRunningEmit(); + }, }); + await liveEmitQueue; - await options.appendTimelineItem( + const completed = await options.appendTimelineItem( buildSetupTimelineItem({ callId: setupCallId, status: "completed", worktree: options.worktree, results: setupResults, + outputAccumulatorsByIndex, errorMessage: null, }) ); + if (!completed) { + return; + } } catch (error) { if (error instanceof WorktreeSetupError) { setupResults = error.results; } + await liveEmitQueue; const message = error instanceof Error ? error.message : String(error); await options.appendTimelineItem( buildSetupTimelineItem({ @@ -299,11 +586,12 @@ export async function runAsyncWorktreeBootstrap( status: "failed", worktree: options.worktree, results: setupResults, + outputAccumulatorsByIndex, errorMessage: message, }) ); return; } - await runWorktreeTerminalBootstrap(options); + await runWorktreeTerminalBootstrap(options, runtimeEnv); } diff --git a/packages/server/src/shared/binary-mux.test.ts b/packages/server/src/shared/binary-mux.test.ts index 2af8d41b7..a246639bd 100644 --- a/packages/server/src/shared/binary-mux.test.ts +++ b/packages/server/src/shared/binary-mux.test.ts @@ -3,6 +3,7 @@ import { BinaryMuxChannel, TerminalBinaryFlags, TerminalBinaryMessageType, + asUint8Array, decodeBinaryMuxFrame, encodeBinaryMuxFrame, } from "./binary-mux.js"; @@ -40,4 +41,10 @@ describe("binary mux frame codec", () => { const tampered = encoded.slice(0, encoded.byteLength - 1); expect(decodeBinaryMuxFrame(tampered)).toBeNull(); }); + + it("converts UTF-8 string payloads to bytes", () => { + const bytes = asUint8Array("hello"); + expect(bytes).not.toBeNull(); + expect(Array.from(bytes ?? [])).toEqual(Array.from(new TextEncoder().encode("hello"))); + }); }); diff --git a/packages/server/src/shared/binary-mux.ts b/packages/server/src/shared/binary-mux.ts index 410797c8b..6b52c03c2 100644 --- a/packages/server/src/shared/binary-mux.ts +++ b/packages/server/src/shared/binary-mux.ts @@ -37,6 +37,19 @@ export interface BinaryMuxFrame { } export function asUint8Array(data: unknown): Uint8Array | null { + if (typeof data === "string") { + if (typeof TextEncoder !== "undefined") { + return new TextEncoder().encode(data); + } + if (typeof Buffer !== "undefined") { + return new Uint8Array(Buffer.from(data, "utf8")); + } + const out = new Uint8Array(data.length); + for (let i = 0; i < data.length; i += 1) { + out[i] = data.charCodeAt(i) & 0xff; + } + return out; + } if (data instanceof Uint8Array) { return data; } diff --git a/packages/server/src/shared/messages.list-commands.test.ts b/packages/server/src/shared/messages.list-commands.test.ts new file mode 100644 index 000000000..42a610d66 --- /dev/null +++ b/packages/server/src/shared/messages.list-commands.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "vitest"; +import { SessionInboundMessageSchema } from "./messages.js"; + +describe("list_commands_request schema", () => { + test("accepts legacy agent-only payload", () => { + const parsed = SessionInboundMessageSchema.parse({ + type: "list_commands_request", + agentId: "agent-123", + requestId: "req-123", + }); + + expect(parsed.type).toBe("list_commands_request"); + if (parsed.type !== "list_commands_request") { + throw new Error("Expected list_commands_request message"); + } + expect(parsed.agentId).toBe("agent-123"); + expect(parsed.draftConfig).toBeUndefined(); + }); + + test("accepts draft command context payload", () => { + const parsed = SessionInboundMessageSchema.parse({ + type: "list_commands_request", + agentId: "__new_agent__", + draftConfig: { + provider: "codex", + cwd: "/tmp/project", + modeId: "bypassPermissions", + model: "gpt-5", + thinkingOptionId: "off", + }, + requestId: "req-456", + }); + + expect(parsed.type).toBe("list_commands_request"); + if (parsed.type !== "list_commands_request") { + throw new Error("Expected list_commands_request message"); + } + expect(parsed.draftConfig).toEqual({ + provider: "codex", + cwd: "/tmp/project", + modeId: "bypassPermissions", + model: "gpt-5", + thinkingOptionId: "off", + }); + }); +}); diff --git a/packages/server/src/shared/messages.stream-parsing.test.ts b/packages/server/src/shared/messages.stream-parsing.test.ts index 7a9da87c1..018cc07a2 100644 --- a/packages/server/src/shared/messages.stream-parsing.test.ts +++ b/packages/server/src/shared/messages.stream-parsing.test.ts @@ -100,6 +100,26 @@ describe("shared messages stream parsing", () => { expect(parsed.success).toBe(false); }); + it("parses directory suggestions request and response payloads", () => { + const requestParsed = SessionInboundMessageSchema.safeParse({ + type: "directory_suggestions_request", + query: "proj", + limit: 20, + requestId: "req-dir-1", + }); + expect(requestParsed.success).toBe(true); + + const responseParsed = SessionOutboundMessageSchema.safeParse({ + type: "directory_suggestions_response", + payload: { + directories: ["/Users/test/projects/paseo"], + error: null, + requestId: "req-dir-1", + }, + }); + expect(responseParsed.success).toBe(true); + }); + it("rejects websocket envelope for removed agent_stream_snapshot message type", () => { const fixture = { type: "agent_stream_snapshot", diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index fd3ae1c41..37870c1db 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -188,6 +188,23 @@ const ToolCallDetailPayloadSchema: z.ZodType = z.discriminatedUn type: z.literal("search"), query: z.string(), }), + z.object({ + type: z.literal("worktree_setup"), + worktreePath: z.string(), + branchName: z.string(), + log: z.string(), + commands: z.array( + z.object({ + index: z.number().int().positive(), + command: z.string(), + cwd: z.string(), + status: z.enum(["running", "completed", "failed"]), + exitCode: z.number().nullable(), + durationMs: z.number().nonnegative().optional(), + }) + ), + truncated: z.boolean().optional(), + }), z.object({ type: z.literal("unknown"), input: UnknownValueSchema, @@ -388,26 +405,12 @@ export const AudioPlayedMessageSchema = z.object({ id: z.string(), }); -export const RequestAgentListMessageSchema = z.object({ - type: z.literal("request_agent_list"), - requestId: z.string(), - filter: z.object({ - labels: z.record(z.string()).optional(), - }).optional(), -}); - -export const SubscribeAgentUpdatesMessageSchema = z.object({ - type: z.literal("subscribe_agent_updates"), - subscriptionId: z.string(), - filter: z.object({ - labels: z.record(z.string()).optional(), - agentId: z.string().optional(), - }).optional(), -}); - -export const UnsubscribeAgentUpdatesMessageSchema = z.object({ - type: z.literal("unsubscribe_agent_updates"), - subscriptionId: z.string(), +const AgentDirectoryFilterSchema = z.object({ + labels: z.record(z.string()).optional(), + projectKeys: z.array(z.string()).optional(), + statuses: z.array(AgentStatusSchema).optional(), + includeArchived: z.boolean().optional(), + requiresAttention: z.boolean().optional(), }); export const DeleteAgentRequestMessageSchema = z.object({ @@ -455,15 +458,7 @@ export const SendAgentMessageSchema = z.object({ export const FetchAgentsRequestMessageSchema = z.object({ type: z.literal("fetch_agents_request"), requestId: z.string(), - filter: z - .object({ - labels: z.record(z.string()).optional(), - projectKeys: z.array(z.string()).optional(), - statuses: z.array(AgentStatusSchema).optional(), - includeArchived: z.boolean().optional(), - requiresAttention: z.boolean().optional(), - }) - .optional(), + filter: AgentDirectoryFilterSchema.optional(), sort: z .array( z.object({ @@ -478,6 +473,11 @@ export const FetchAgentsRequestMessageSchema = z.object({ cursor: z.string().min(1).optional(), }) .optional(), + subscribe: z + .object({ + subscriptionId: z.string().optional(), + }) + .optional(), }); export const FetchAgentRequestMessageSchema = z.object({ @@ -809,6 +809,13 @@ export const BranchSuggestionsRequestSchema = z.object({ requestId: z.string(), }); +export const DirectorySuggestionsRequestSchema = z.object({ + type: z.literal("directory_suggestions_request"), + query: z.string(), + limit: z.number().int().min(1).max(100).optional(), + requestId: z.string(), +}); + export const PaseoWorktreeListRequestSchema = z.object({ type: z.literal("paseo_worktree_list_request"), cwd: z.string().optional(), @@ -919,9 +926,18 @@ export const PingMessageSchema = z.object({ clientSentAt: z.number().int().optional(), }); +const ListCommandsDraftConfigSchema = z.object({ + provider: AgentProviderSchema, + cwd: z.string(), + modeId: z.string().optional(), + model: z.string().optional(), + thinkingOptionId: z.string().optional(), +}); + export const ListCommandsRequestSchema = z.object({ type: z.literal("list_commands_request"), agentId: z.string(), + draftConfig: ListCommandsDraftConfigSchema.optional(), requestId: z.string(), }); @@ -948,6 +964,16 @@ export const ListTerminalsRequestSchema = z.object({ requestId: z.string(), }); +export const SubscribeTerminalsRequestSchema = z.object({ + type: z.literal("subscribe_terminals_request"), + cwd: z.string(), +}); + +export const UnsubscribeTerminalsRequestSchema = z.object({ + type: z.literal("unsubscribe_terminals_request"), + cwd: z.string(), +}); + export const CreateTerminalRequestSchema = z.object({ type: z.literal("create_terminal_request"), cwd: z.string(), @@ -1011,8 +1037,6 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ AudioPlayedMessageSchema, FetchAgentsRequestMessageSchema, FetchAgentRequestMessageSchema, - SubscribeAgentUpdatesMessageSchema, - UnsubscribeAgentUpdatesMessageSchema, DeleteAgentRequestMessageSchema, ArchiveAgentRequestMessageSchema, UpdateAgentRequestMessageSchema, @@ -1048,6 +1072,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ CheckoutPrStatusRequestSchema, ValidateBranchRequestSchema, BranchSuggestionsRequestSchema, + DirectorySuggestionsRequestSchema, PaseoWorktreeListRequestSchema, PaseoWorktreeArchiveRequestSchema, FileExplorerRequestSchema, @@ -1060,6 +1085,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ ExecuteCommandRequestSchema, RegisterPushTokenMessageSchema, ListTerminalsRequestSchema, + SubscribeTerminalsRequestSchema, + UnsubscribeTerminalsRequestSchema, CreateTerminalRequestSchema, SubscribeTerminalRequestSchema, UnsubscribeTerminalRequestSchema, @@ -1406,6 +1433,7 @@ export const FetchAgentsResponseMessageSchema = z.object({ type: z.literal("fetch_agents_response"), payload: z.object({ requestId: z.string(), + subscriptionId: z.string().nullable().optional(), entries: z.array( z.object({ agent: AgentSnapshotPayloadSchema, @@ -1693,6 +1721,15 @@ export const BranchSuggestionsResponseSchema = z.object({ }), }); +export const DirectorySuggestionsResponseSchema = z.object({ + type: z.literal("directory_suggestions_response"), + payload: z.object({ + directories: z.array(z.string()), + error: z.string().nullable(), + requestId: z.string(), + }), +}); + const PaseoWorktreeSchema = z.object({ worktreePath: z.string(), branchName: z.string().nullable().optional(), @@ -1885,6 +1922,14 @@ export const ListTerminalsResponseSchema = z.object({ }), }); +export const TerminalsChangedSchema = z.object({ + type: z.literal("terminals_changed"), + payload: z.object({ + cwd: z.string(), + terminals: z.array(TerminalInfoSchema.omit({ cwd: true })), + }), +}); + export const CreateTerminalResponseSchema = z.object({ type: z.literal("create_terminal_response"), payload: z.object({ @@ -1994,6 +2039,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ CheckoutPrStatusResponseSchema, ValidateBranchResponseSchema, BranchSuggestionsResponseSchema, + DirectorySuggestionsResponseSchema, PaseoWorktreeListResponseSchema, PaseoWorktreeArchiveResponseSchema, FileExplorerResponseSchema, @@ -2006,6 +2052,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ ListCommandsResponseSchema, ExecuteCommandResponseSchema, ListTerminalsResponseSchema, + TerminalsChangedSchema, CreateTerminalResponseSchema, SubscribeTerminalResponseSchema, TerminalOutputSchema, @@ -2126,6 +2173,8 @@ export type ValidateBranchRequest = z.infer; export type ValidateBranchResponse = z.infer; export type BranchSuggestionsRequest = z.infer; export type BranchSuggestionsResponse = z.infer; +export type DirectorySuggestionsRequest = z.infer; +export type DirectorySuggestionsResponse = z.infer; export type PaseoWorktreeListRequest = z.infer; export type PaseoWorktreeListResponse = z.infer; export type PaseoWorktreeArchiveRequest = z.infer; @@ -2149,6 +2198,9 @@ export type RegisterPushTokenMessage = z.infer; export type ListTerminalsResponse = z.infer; +export type SubscribeTerminalsRequest = z.infer; +export type UnsubscribeTerminalsRequest = z.infer; +export type TerminalsChanged = z.infer; export type CreateTerminalRequest = z.infer; export type CreateTerminalResponse = z.infer; export type SubscribeTerminalRequest = z.infer; diff --git a/packages/server/src/shared/tool-call-display.test.ts b/packages/server/src/shared/tool-call-display.test.ts index 22c909f7d..3b99051bf 100644 --- a/packages/server/src/shared/tool-call-display.test.ts +++ b/packages/server/src/shared/tool-call-display.test.ts @@ -59,6 +59,34 @@ describe("shared tool-call display mapping", () => { }); }); + it("builds display model for worktree setup detail", () => { + const display = buildToolCallDisplayModel({ + name: "paseo_worktree_setup", + status: "running", + error: null, + detail: { + type: "worktree_setup", + worktreePath: "/tmp/repo/.paseo/worktrees/repo/branch", + branchName: "feature-branch", + log: "==> [1/1] Running: npm install\n", + commands: [ + { + index: 1, + command: "npm install", + cwd: "/tmp/repo/.paseo/worktrees/repo/branch", + status: "running", + exitCode: null, + }, + ], + }, + }); + + expect(display).toEqual({ + displayName: "Worktree Setup", + summary: "feature-branch", + }); + }); + it("provides errorText for failed calls", () => { const display = buildToolCallDisplayModel({ name: "shell", diff --git a/packages/server/src/shared/tool-call-display.ts b/packages/server/src/shared/tool-call-display.ts index e54bf40a3..b6eb12233 100644 --- a/packages/server/src/shared/tool-call-display.ts +++ b/packages/server/src/shared/tool-call-display.ts @@ -83,6 +83,10 @@ export function buildToolCallDisplayModel(input: ToolCallDisplayInput): ToolCall displayName = "Search"; summary = input.detail.query; break; + case "worktree_setup": + displayName = "Worktree Setup"; + summary = input.detail.branchName; + break; case "unknown": break; } diff --git a/packages/server/src/terminal/terminal-manager.test.ts b/packages/server/src/terminal/terminal-manager.test.ts index 5d6933571..b4864d013 100644 --- a/packages/server/src/terminal/terminal-manager.test.ts +++ b/packages/server/src/terminal/terminal-manager.test.ts @@ -1,5 +1,8 @@ import { describe, it, expect, afterEach } from "vitest"; import { createTerminalManager, type TerminalManager } from "./terminal-manager.js"; +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; async function waitForCondition( predicate: () => boolean, @@ -16,13 +19,34 @@ async function waitForCondition( throw new Error(`Timed out after ${timeoutMs}ms waiting for condition`); } +async function withShell(shell: string, run: () => Promise): Promise { + const originalShell = process.env.SHELL; + process.env.SHELL = shell; + try { + return await run(); + } finally { + if (originalShell === undefined) { + delete process.env.SHELL; + } else { + process.env.SHELL = originalShell; + } + } +} + describe("TerminalManager", () => { let manager: TerminalManager; + const temporaryDirs: string[] = []; afterEach(() => { if (manager) { manager.killAll(); } + while (temporaryDirs.length > 0) { + const dir = temporaryDirs.pop(); + if (dir) { + rmSync(dir, { recursive: true, force: true }); + } + } }); describe("getTerminals", () => { @@ -94,6 +118,58 @@ describe("TerminalManager", () => { manager = createTerminalManager(); await expect(manager.createTerminal({ cwd: "tmp" })).rejects.toThrow("cwd must be absolute path"); }); + + it("inherits registered env for the worktree root cwd", async () => { + await withShell("/bin/sh", async () => { + manager = createTerminalManager(); + const cwd = mkdtempSync(join(tmpdir(), "terminal-manager-env-root-")); + temporaryDirs.push(cwd); + const markerPath = join(cwd, "root-port.txt"); + + manager.registerCwdEnv({ + cwd, + env: { PASEO_WORKTREE_PORT: "45678" }, + }); + const session = await manager.createTerminal({ cwd }); + for (let attempt = 0; attempt < 10 && !existsSync(markerPath); attempt++) { + session.send({ + type: "input", + data: `printf '%s' \"$PASEO_WORKTREE_PORT\" > ${JSON.stringify(markerPath)}\r`, + }); + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + await waitForCondition(() => existsSync(markerPath), 10000); + expect(readFileSync(markerPath, "utf8")).toBe("45678"); + }); + }); + + it("inherits registered env for subdirectories within the worktree", async () => { + await withShell("/bin/sh", async () => { + manager = createTerminalManager(); + const rootCwd = mkdtempSync(join(tmpdir(), "terminal-manager-env-subdir-")); + const subdirCwd = join(rootCwd, "packages", "app"); + mkdirSync(subdirCwd, { recursive: true }); + temporaryDirs.push(rootCwd); + const markerPath = join(subdirCwd, "subdir-port.txt"); + + manager.registerCwdEnv({ + cwd: rootCwd, + env: { PASEO_WORKTREE_PORT: "45679" }, + }); + const session = await manager.createTerminal({ cwd: subdirCwd }); + for (let attempt = 0; attempt < 10 && !existsSync(markerPath); attempt++) { + session.send({ + type: "input", + data: `printf '%s' \"$PASEO_WORKTREE_PORT\" > ${JSON.stringify(markerPath)}\r`, + }); + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + await waitForCondition(() => existsSync(markerPath), 10000); + expect(readFileSync(markerPath, "utf8")).toBe("45679"); + }); + }); }); describe("getTerminal", () => { @@ -156,7 +232,7 @@ describe("TerminalManager", () => { manager = createTerminalManager(); const terminals = await manager.getTerminals("/tmp"); const exitedId = terminals[0].id; - terminals[0].send({ type: "input", data: "\u0004" }); + terminals[0].kill(); await waitForCondition(() => manager.getTerminal(exitedId) === undefined, 10000); @@ -201,4 +277,52 @@ describe("TerminalManager", () => { expect(manager.getTerminal(homeId)).toBeUndefined(); }); }); + + describe("subscribeTerminalsChanged", () => { + it("emits cwd snapshots when terminals are created", async () => { + manager = createTerminalManager(); + const snapshots: Array<{ cwd: string; terminalNames: string[] }> = []; + const unsubscribe = manager.subscribeTerminalsChanged((input) => { + snapshots.push({ + cwd: input.cwd, + terminalNames: input.terminals.map((terminal) => terminal.name), + }); + }); + + await manager.getTerminals("/tmp"); + await manager.createTerminal({ cwd: "/tmp", name: "Dev Server" }); + + expect(snapshots).toContainEqual({ + cwd: "/tmp", + terminalNames: ["Terminal 1"], + }); + expect(snapshots).toContainEqual({ + cwd: "/tmp", + terminalNames: ["Terminal 1", "Dev Server"], + }); + + unsubscribe(); + }); + + it("emits empty snapshot when last terminal is removed", async () => { + manager = createTerminalManager(); + const snapshots: Array<{ cwd: string; terminalCount: number }> = []; + const unsubscribe = manager.subscribeTerminalsChanged((input) => { + snapshots.push({ + cwd: input.cwd, + terminalCount: input.terminals.length, + }); + }); + + const terminals = await manager.getTerminals("/tmp"); + manager.killTerminal(terminals[0].id); + + expect(snapshots).toContainEqual({ + cwd: "/tmp", + terminalCount: 0, + }); + + unsubscribe(); + }); + }); }); diff --git a/packages/server/src/terminal/terminal-manager.ts b/packages/server/src/terminal/terminal-manager.ts index 970557eb1..e336130f7 100644 --- a/packages/server/src/terminal/terminal-manager.ts +++ b/packages/server/src/terminal/terminal-manager.ts @@ -1,18 +1,40 @@ import { createTerminal, type TerminalSession } from "./terminal.js"; +import { resolve, sep } from "node:path"; + +export interface TerminalListItem { + id: string; + name: string; + cwd: string; +} + +export interface TerminalsChangedEvent { + cwd: string; + terminals: TerminalListItem[]; +} + +export type TerminalsChangedListener = (input: TerminalsChangedEvent) => void; export interface TerminalManager { getTerminals(cwd: string): Promise; - createTerminal(options: { cwd: string; name?: string }): Promise; + createTerminal(options: { + cwd: string; + name?: string; + env?: Record; + }): Promise; + registerCwdEnv(options: { cwd: string; env: Record }): void; getTerminal(id: string): TerminalSession | undefined; killTerminal(id: string): void; listDirectories(): string[]; killAll(): void; + subscribeTerminalsChanged(listener: TerminalsChangedListener): () => void; } export function createTerminalManager(): TerminalManager { const terminalsByCwd = new Map(); const terminalsById = new Map(); const terminalExitUnsubscribeById = new Map void>(); + const terminalsChangedListeners = new Set(); + const defaultEnvByRootCwd = new Map>(); function assertAbsolutePath(cwd: string): void { if (!cwd.startsWith("/")) { @@ -48,6 +70,25 @@ export function createTerminalManager(): TerminalManager { if (options.kill) { session.kill(); } + + emitTerminalsChanged({ cwd: session.cwd }); + } + + function resolveDefaultEnvForCwd(cwd: string): Record | undefined { + const normalizedCwd = resolve(cwd); + let bestMatchRoot: string | null = null; + + for (const rootCwd of defaultEnvByRootCwd.keys()) { + const matches = normalizedCwd === rootCwd || normalizedCwd.startsWith(`${rootCwd}${sep}`); + if (!matches) { + continue; + } + if (!bestMatchRoot || rootCwd.length > bestMatchRoot.length) { + bestMatchRoot = rootCwd; + } + } + + return bestMatchRoot ? defaultEnvByRootCwd.get(bestMatchRoot) : undefined; } function registerSession(session: TerminalSession): TerminalSession { @@ -59,39 +100,91 @@ export function createTerminalManager(): TerminalManager { return session; } + function toTerminalListItem(input: { session: TerminalSession }): TerminalListItem { + return { + id: input.session.id, + name: input.session.name, + cwd: input.session.cwd, + }; + } + + function emitTerminalsChanged(input: { cwd: string }): void { + if (terminalsChangedListeners.size === 0) { + return; + } + + const terminals = (terminalsByCwd.get(input.cwd) ?? []).map((session) => + toTerminalListItem({ session }) + ); + const event: TerminalsChangedEvent = { + cwd: input.cwd, + terminals, + }; + + for (const listener of terminalsChangedListeners) { + try { + listener(event); + } catch { + // no-op + } + } + } + return { async getTerminals(cwd: string): Promise { assertAbsolutePath(cwd); let terminals = terminalsByCwd.get(cwd); if (!terminals || terminals.length === 0) { + const inheritedEnv = resolveDefaultEnvForCwd(cwd); const session = registerSession( - await createTerminal({ cwd, name: "Terminal 1" }) + await createTerminal({ + cwd, + name: "Terminal 1", + ...(inheritedEnv ? { env: inheritedEnv } : {}), + }) ); terminals = [session]; terminalsByCwd.set(cwd, terminals); + emitTerminalsChanged({ cwd }); } return terminals; }, - async createTerminal(options: { cwd: string; name?: string }): Promise { + async createTerminal(options: { + cwd: string; + name?: string; + env?: Record; + }): Promise { assertAbsolutePath(options.cwd); const terminals = terminalsByCwd.get(options.cwd) ?? []; const defaultName = `Terminal ${terminals.length + 1}`; + const inheritedEnv = resolveDefaultEnvForCwd(options.cwd); + const mergedEnv = + inheritedEnv || options.env + ? { ...(inheritedEnv ?? {}), ...(options.env ?? {}) } + : undefined; const session = registerSession( await createTerminal({ cwd: options.cwd, name: options.name ?? defaultName, + ...(mergedEnv ? { env: mergedEnv } : {}), }) ); terminals.push(session); terminalsByCwd.set(options.cwd, terminals); + emitTerminalsChanged({ cwd: options.cwd }); return session; }, + registerCwdEnv(options: { cwd: string; env: Record }): void { + assertAbsolutePath(options.cwd); + defaultEnvByRootCwd.set(resolve(options.cwd), { ...options.env }); + }, + getTerminal(id: string): TerminalSession | undefined { return terminalsById.get(id); }, @@ -109,5 +202,12 @@ export function createTerminalManager(): TerminalManager { removeSessionById(id, { kill: true }); } }, + + subscribeTerminalsChanged(listener: TerminalsChangedListener): () => void { + terminalsChangedListeners.add(listener); + return () => { + terminalsChangedListeners.delete(listener); + }; + }, }; } diff --git a/packages/server/src/terminal/terminal.test.ts b/packages/server/src/terminal/terminal.test.ts index 707017acb..d68ae9b59 100644 --- a/packages/server/src/terminal/terminal.test.ts +++ b/packages/server/src/terminal/terminal.test.ts @@ -1,5 +1,12 @@ import { describe, it, expect, afterEach } from "vitest"; -import { createTerminal, type TerminalSession } from "./terminal.js"; +import { + createTerminal, + ensureNodePtySpawnHelperExecutableForCurrentPlatform, + type TerminalSession, +} from "./terminal.js"; +import { chmodSync, mkdtempSync, mkdirSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; // Extract text from a single row function getRowText(state: ReturnType, rowIndex: number): string { @@ -40,12 +47,19 @@ async function waitForLines( describe("Terminal", () => { const sessions: TerminalSession[] = []; + const temporaryDirs: string[] = []; afterEach(async () => { for (const session of sessions) { session.kill(); } sessions.length = 0; + while (temporaryDirs.length > 0) { + const dir = temporaryDirs.pop(); + if (dir) { + rmSync(dir, { recursive: true, force: true }); + } + } }); function trackSession(session: TerminalSession): TerminalSession { @@ -54,6 +68,24 @@ describe("Terminal", () => { } describe("createTerminal", () => { + it("ensures darwin prebuild spawn-helper is executable", () => { + const packageRoot = mkdtempSync(join(tmpdir(), "terminal-node-pty-helper-")); + temporaryDirs.push(packageRoot); + const prebuildDir = join(packageRoot, "prebuilds", `darwin-${process.arch}`); + mkdirSync(prebuildDir, { recursive: true }); + const helperPath = join(prebuildDir, "spawn-helper"); + writeFileSync(helperPath, "#!/bin/sh\necho helper\n"); + chmodSync(helperPath, 0o644); + + ensureNodePtySpawnHelperExecutableForCurrentPlatform({ + packageRoot, + platform: "darwin", + force: true, + }); + + expect(statSync(helperPath).mode & 0o111).toBe(0o111); + }); + it("creates a terminal session with an id, name, and cwd", async () => { const session = trackSession( await createTerminal({ diff --git a/packages/server/src/terminal/terminal.ts b/packages/server/src/terminal/terminal.ts index a88ace471..293976ae5 100644 --- a/packages/server/src/terminal/terminal.ts +++ b/packages/server/src/terminal/terminal.ts @@ -1,8 +1,13 @@ import * as pty from "node-pty"; import xterm, { type Terminal as TerminalType } from "@xterm/headless"; import { randomUUID } from "crypto"; +import { chmodSync, existsSync, statSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { createRequire } from "node:module"; const { Terminal } = xterm; +const require = createRequire(import.meta.url); +let nodePtySpawnHelperChecked = false; export interface Cell { char: string; @@ -83,6 +88,73 @@ export interface CreateTerminalOptions { name?: string; } +type EnsureNodePtySpawnHelperExecutableOptions = { + packageRoot?: string; + platform?: NodeJS.Platform; + arch?: string; + force?: boolean; +}; + +function resolveNodePtyPackageRoot(): string | null { + try { + const packageJsonPath = require.resolve("node-pty/package.json"); + return dirname(packageJsonPath); + } catch { + return null; + } +} + +function ensureExecutableBit(path: string): void { + if (!existsSync(path)) { + return; + } + const stat = statSync(path); + if (!stat.isFile()) { + return; + } + // node-pty 1.1.0 shipped darwin prebuild spawn-helper without execute bit. + if ((stat.mode & 0o111) === 0o111) { + return; + } + chmodSync(path, stat.mode | 0o111); +} + +export function ensureNodePtySpawnHelperExecutableForCurrentPlatform( + options: EnsureNodePtySpawnHelperExecutableOptions = {} +): void { + const platform = options.platform ?? process.platform; + if (platform !== "darwin") { + return; + } + if (nodePtySpawnHelperChecked && !options.force) { + return; + } + + const packageRoot = options.packageRoot ?? resolveNodePtyPackageRoot(); + if (!packageRoot) { + return; + } + const arch = options.arch ?? process.arch; + + const candidates = [ + join(packageRoot, "build", "Release", "spawn-helper"), + join(packageRoot, "build", "Debug", "spawn-helper"), + join(packageRoot, "prebuilds", `darwin-${arch}`, "spawn-helper"), + ]; + + for (const candidate of candidates) { + try { + ensureExecutableBit(candidate); + } catch { + // best-effort hardening only + } + } + + if (!options.force) { + nodePtySpawnHelperChecked = true; + } +} + function extractCell(terminal: TerminalType, row: number, col: number): Cell { const buffer = terminal.buffer.active; const line = buffer.getLine(row); @@ -212,6 +284,8 @@ export async function createTerminal(options: CreateTerminalOptions): Promise { + let tempRoot: string; + let homeDir: string; + let outsideDir: string; + + beforeEach(() => { + tempRoot = realpathSync(mkdtempSync(path.join(tmpdir(), "directory-suggestions-"))); + homeDir = path.join(tempRoot, "home"); + outsideDir = path.join(tempRoot, "outside"); + + mkdirSync(homeDir, { recursive: true }); + mkdirSync(outsideDir, { recursive: true }); + + mkdirSync(path.join(homeDir, "projects", "paseo"), { recursive: true }); + mkdirSync(path.join(homeDir, "projects", "playground"), { recursive: true }); + mkdirSync(path.join(homeDir, "documents", "plans"), { recursive: true }); + mkdirSync(path.join(homeDir, ".hidden", "cache"), { recursive: true }); + writeFileSync(path.join(homeDir, "projects", "README.md"), "not a directory\n"); + + mkdirSync(path.join(outsideDir, "outside-match"), { recursive: true }); + symlinkSync(path.join(outsideDir, "outside-match"), path.join(homeDir, "outside-link")); + }); + + afterEach(() => { + rmSync(tempRoot, { recursive: true, force: true }); + }); + + it("returns an empty list for blank queries", async () => { + await expect( + searchHomeDirectories({ + homeDir, + query: " ", + limit: 10, + }) + ).resolves.toEqual([]); + }); + + it("returns only existing directories", async () => { + const results = await searchHomeDirectories({ + homeDir, + query: "proj", + limit: 10, + }); + + expect(results).toContain(path.join(homeDir, "projects")); + expect(results).toContain(path.join(homeDir, "projects", "paseo")); + expect(results).not.toContain(path.join(homeDir, "projects", "README.md")); + }); + + it("supports home-relative path query syntax", async () => { + const results = await searchHomeDirectories({ + homeDir, + query: "~/projects/pa", + limit: 10, + }); + + expect(results).toEqual([ + path.join(homeDir, "projects", "paseo"), + ]); + }); + + it("prioritizes exact segment matches before segment-prefix matches", async () => { + const exactSegmentPath = path.join( + homeDir, + "something", + "faro", + "something-else" + ); + const prefixSegmentPath = path.join( + homeDir, + "something", + "somethingelse", + "faro-bla" + ); + mkdirSync(exactSegmentPath, { recursive: true }); + mkdirSync(prefixSegmentPath, { recursive: true }); + + const results = await searchHomeDirectories({ + homeDir, + query: "faro", + limit: 30, + }); + + const exactIndex = results.indexOf(exactSegmentPath); + const prefixIndex = results.indexOf(prefixSegmentPath); + expect(exactIndex).toBeGreaterThanOrEqual(0); + expect(prefixIndex).toBeGreaterThanOrEqual(0); + expect(exactIndex).toBeLessThan(prefixIndex); + }); + + it("prioritizes partial matches that appear earlier in the path", async () => { + const earlierPath = path.join(homeDir, "farofoo"); + const laterPath = path.join(homeDir, "x", "y", "farofoo"); + mkdirSync(earlierPath, { recursive: true }); + mkdirSync(laterPath, { recursive: true }); + + const results = await searchHomeDirectories({ + homeDir, + query: "arofo", + limit: 30, + }); + + const earlierIndex = results.indexOf(earlierPath); + const laterIndex = results.indexOf(laterPath); + expect(earlierIndex).toBeGreaterThanOrEqual(0); + expect(laterIndex).toBeGreaterThanOrEqual(0); + expect(earlierIndex).toBeLessThan(laterIndex); + }); + + it("returns home-root suggestions when query is '~'", async () => { + const results = await searchHomeDirectories({ + homeDir, + query: "~", + limit: 20, + }); + + expect(results).toContain(path.join(homeDir, "projects")); + expect(results).toContain(path.join(homeDir, "documents")); + }); + + it("does not return paths that escape home through symlinks", async () => { + const results = await searchHomeDirectories({ + homeDir, + query: "outside", + limit: 20, + }); + + expect(results).not.toContain(path.join(homeDir, "outside-link")); + expect(results).not.toContain(path.join(outsideDir, "outside-match")); + }); + + it("respects the result limit", async () => { + const results = await searchHomeDirectories({ + homeDir, + query: "p", + limit: 1, + }); + + expect(results).toHaveLength(1); + }); +}); diff --git a/packages/server/src/utils/directory-suggestions.ts b/packages/server/src/utils/directory-suggestions.ts new file mode 100644 index 000000000..f3ea554ad --- /dev/null +++ b/packages/server/src/utils/directory-suggestions.ts @@ -0,0 +1,456 @@ +import type { Dirent } from "node:fs"; +import { readdir, realpath, stat } from "node:fs/promises"; +import path from "node:path"; + +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 = 6; +const DEFAULT_MAX_DIRECTORIES_SCANNED = 5000; +const DIRECTORY_LIST_CACHE_TTL_MS = 8_000; +const DIRECTORY_LIST_CACHE_MAX_ENTRIES = 4_000; + +type QueryParts = { + isPathQuery: boolean; + parentPart: string; + searchTerm: string; +}; + +type RankedDirectory = { + absolutePath: string; + matchTier: number; + segmentIndex: number; + matchOffset: number; + depth: number; +}; + +type ChildDirectoryEntry = { + name: string; + absolutePath: string; +}; + +type DirectoryListCacheEntry = { + expiresAt: number; + entries: ChildDirectoryEntry[]; +}; + +const directoryListCache = new Map(); +const NO_SEGMENT_INDEX = Number.MAX_SAFE_INTEGER; +const NO_MATCH_OFFSET = Number.MAX_SAFE_INTEGER; + +export async function searchHomeDirectories( + options: SearchHomeDirectoriesOptions +): Promise { + 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 { + 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 { + const queue: Array<{ directory: string; depth: number }> = [ + { directory: input.homeRoot, depth: 0 }, + ]; + const visited = new Set([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(); + 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 isPathInsideRoot(root: string, target: string): boolean { + const relative = path.relative(root, target); + return ( + relative === "" || + (!relative.startsWith("..") && !path.isAbsolute(relative)) + ); +} + +function normalizeQueryParts(query: string, homeRoot: string): QueryParts | null { + const typedQuery = query.trim().replace(/\\/g, "/"); + let normalized = typedQuery; + if (!normalized) { + return null; + } + + if (normalized.startsWith("~")) { + normalized = normalized.slice(1); + if (normalized.startsWith("/")) { + normalized = normalized.slice(1); + } + } + + if (path.isAbsolute(normalized)) { + const absolute = path.resolve(normalized); + if (!isPathInsideRoot(homeRoot, absolute)) { + return null; + } + normalized = normalizeRelativePath(homeRoot, absolute); + } + + 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 = normalized.includes("/"); + const slashIndex = normalized.lastIndexOf("/"); + const parentPart = slashIndex >= 0 ? normalized.slice(0, slashIndex) : ""; + const searchTerm = slashIndex >= 0 ? normalized.slice(slashIndex + 1) : normalized; + + return { + isPathQuery, + parentPart, + searchTerm, + }; +} + +async function resolveDirectory(inputPath: string): Promise { + 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 { + 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 entries: ChildDirectoryEntry[] = []; + for (const dirent of dirents) { + if (!dirent.isDirectory() && !dirent.isSymbolicLink()) { + continue; + } + const candidatePath = path.join(input.directory, dirent.name); + const absolutePath = await resolveDirectoryCandidate({ + candidatePath, + dirent, + homeRoot: input.homeRoot, + }); + if (!absolutePath) { + continue; + } + entries.push({ + name: dirent.name, + absolutePath, + }); + } + + setDirectoryListCache(input.directory, { + expiresAt: now + DIRECTORY_LIST_CACHE_TTL_MS, + entries, + }); + + return entries; +} + +async function resolveDirectoryCandidate(input: { + candidatePath: string; + dirent: Dirent; + homeRoot: string; +}): Promise { + 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 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 as string | undefined; + if (!oldestKey) { + return; + } + directoryListCache.delete(oldestKey); + } +} diff --git a/packages/server/src/utils/worktree-metadata.ts b/packages/server/src/utils/worktree-metadata.ts index 259292006..211664d91 100644 --- a/packages/server/src/utils/worktree-metadata.ts +++ b/packages/server/src/utils/worktree-metadata.ts @@ -2,11 +2,26 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; import { isAbsolute, join, resolve } from "path"; import { z } from "zod"; -const PaseoWorktreeMetadataSchema = z.object({ +const PaseoWorktreeMetadataV1Schema = z.object({ version: z.literal(1), baseRefName: z.string().min(1), }); +const PaseoWorktreeMetadataV2Schema = z.object({ + version: z.literal(2), + baseRefName: z.string().min(1), + runtime: z + .object({ + worktreePort: z.number().int().positive(), + }) + .optional(), +}); + +const PaseoWorktreeMetadataSchema = z.union([ + PaseoWorktreeMetadataV1Schema, + PaseoWorktreeMetadataV2Schema, +]); + export type PaseoWorktreeMetadata = z.infer; function getGitDirForWorktreeRoot(worktreeRoot: string): string { @@ -68,6 +83,31 @@ export function writePaseoWorktreeMetadata( writeFileSync(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`, "utf8"); } +export function writePaseoWorktreeRuntimeMetadata( + worktreeRoot: string, + options: { worktreePort: number } +): void { + if (!Number.isInteger(options.worktreePort) || options.worktreePort <= 0) { + throw new Error(`Invalid worktree runtime port: ${options.worktreePort}`); + } + + const current = readPaseoWorktreeMetadata(worktreeRoot); + if (!current) { + throw new Error("Cannot persist worktree runtime metadata: missing base metadata"); + } + + const metadataPath = getPaseoWorktreeMetadataPath(worktreeRoot); + mkdirSync(join(getGitDirForWorktreeRoot(worktreeRoot), "paseo"), { recursive: true }); + const next: PaseoWorktreeMetadata = { + version: 2, + baseRefName: current.baseRefName, + runtime: { + worktreePort: options.worktreePort, + }, + }; + writeFileSync(metadataPath, `${JSON.stringify(next, null, 2)}\n`, "utf8"); +} + export function readPaseoWorktreeMetadata(worktreeRoot: string): PaseoWorktreeMetadata | null { const metadataPath = getPaseoWorktreeMetadataPath(worktreeRoot); if (!existsSync(metadataPath)) { @@ -85,3 +125,14 @@ export function requirePaseoWorktreeBaseRefName(worktreeRoot: string): string { } return metadata.baseRefName; } + +export function readPaseoWorktreeRuntimePort(worktreeRoot: string): number | null { + const metadata = readPaseoWorktreeMetadata(worktreeRoot); + if (!metadata) { + return null; + } + if (metadata.version === 2 && metadata.runtime?.worktreePort) { + return metadata.runtime.worktreePort; + } + return null; +} diff --git a/packages/server/src/utils/worktree.test.ts b/packages/server/src/utils/worktree.test.ts index 9c62c09b6..3a17e483e 100644 --- a/packages/server/src/utils/worktree.test.ts +++ b/packages/server/src/utils/worktree.test.ts @@ -1,17 +1,22 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { createWorktree, + deriveWorktreeProjectHash, deletePaseoWorktree, getWorktreeTerminalSpecs, isPaseoOwnedWorktreeCwd, listPaseoWorktrees, + resolveWorktreeRuntimeEnv, + type WorktreeSetupCommandProgressEvent, + runWorktreeSetupCommands, slugify, } from "./worktree"; import { getPaseoWorktreeMetadataPath } from "./worktree-metadata.js"; import { execSync } from "child_process"; import { mkdtempSync, rmSync, existsSync, realpathSync, writeFileSync, readFileSync } from "fs"; -import { join } from "path"; +import { dirname, join } from "path"; import { tmpdir } from "os"; +import net from "node:net"; describe("createWorktree", () => { let tempDir: string; @@ -39,6 +44,7 @@ describe("createWorktree", () => { }); it("creates a worktree for the current branch (main)", async () => { + const projectHash = await deriveWorktreeProjectHash(repoDir); const result = await createWorktree({ branchName: "main", cwd: repoDir, @@ -48,7 +54,7 @@ describe("createWorktree", () => { }); expect(result.worktreePath).toBe( - join(paseoHome, "worktrees", "test-repo", "hello-world") + join(paseoHome, "worktrees", projectHash, "hello-world") ); expect(existsSync(result.worktreePath)).toBe(true); expect(existsSync(join(result.worktreePath, "file.txt"))).toBe(true); @@ -82,7 +88,14 @@ describe("createWorktree", () => { paseoHome: varPaseoHome, }); - const privateWorktreePath = join(privateTempDir, "paseo-home", "worktrees", "test-repo", "realpath-test"); + const projectHash = await deriveWorktreeProjectHash(varRepoDir); + const privateWorktreePath = join( + privateTempDir, + "paseo-home", + "worktrees", + projectHash, + "realpath-test" + ); expect(existsSync(privateWorktreePath)).toBe(true); const ownership = await isPaseoOwnedWorktreeCwd(privateWorktreePath, { paseoHome: varPaseoHome }); @@ -91,7 +104,22 @@ describe("createWorktree", () => { rmSync(varTempDir, { recursive: true, force: true }); }); + it("reports repoRoot as the repository root for paseo-owned worktrees", async () => { + const result = await createWorktree({ + branchName: "main", + cwd: repoDir, + baseBranch: "main", + worktreeSlug: "repo-root-check", + paseoHome, + }); + + const ownership = await isPaseoOwnedWorktreeCwd(result.worktreePath, { paseoHome }); + expect(ownership.allowed).toBe(true); + expect(ownership.repoRoot).toBe(repoDir); + }); + it("creates a worktree with a new branch", async () => { + const projectHash = await deriveWorktreeProjectHash(repoDir); const result = await createWorktree({ branchName: "feature-branch", cwd: repoDir, @@ -101,7 +129,7 @@ describe("createWorktree", () => { }); expect(result.worktreePath).toBe( - join(paseoHome, "worktrees", "test-repo", "my-feature") + join(paseoHome, "worktrees", projectHash, "my-feature") ); expect(existsSync(result.worktreePath)).toBe(true); @@ -127,6 +155,7 @@ describe("createWorktree", () => { }); it("handles branch name collision by adding suffix", async () => { + const projectHash = await deriveWorktreeProjectHash(repoDir); // Create a branch named "hello" first execSync("git branch hello", { cwd: repoDir }); @@ -140,7 +169,7 @@ describe("createWorktree", () => { // Should create branch "hello-1" since "hello" exists expect(result.worktreePath).toBe( - join(paseoHome, "worktrees", "test-repo", "hello") + join(paseoHome, "worktrees", projectHash, "hello") ); expect(existsSync(result.worktreePath)).toBe(true); @@ -233,6 +262,98 @@ describe("createWorktree", () => { expect(existsSync(join(result.worktreePath, "setup.log"))).toBe(false); }); + it("streams setup command progress events while commands are executing", async () => { + const paseoConfig = { + worktree: { + setup: [ + 'echo "first line"; echo "second line" 1>&2', + ], + }, + }; + writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig)); + execSync( + "git add paseo.json && git -c commit.gpgsign=false commit -m 'add streaming setup'", + { cwd: repoDir } + ); + + const progressEvents: WorktreeSetupCommandProgressEvent[] = []; + const results = await runWorktreeSetupCommands({ + worktreePath: repoDir, + branchName: "main", + cleanupOnFailure: false, + onEvent: (event) => { + progressEvents.push(event); + }, + }); + + expect(results).toHaveLength(1); + expect(progressEvents.some((event) => event.type === "command_started")).toBe(true); + expect(progressEvents.some((event) => event.type === "output")).toBe(true); + expect(progressEvents.some((event) => event.type === "command_completed")).toBe(true); + }); + + it("reuses persisted worktree runtime port across resolutions", async () => { + const result = await createWorktree({ + branchName: "main", + cwd: repoDir, + baseBranch: "main", + worktreeSlug: "runtime-env-port-reuse", + runSetup: false, + paseoHome, + }); + + const first = await resolveWorktreeRuntimeEnv({ + worktreePath: result.worktreePath, + branchName: result.branchName, + }); + const second = await resolveWorktreeRuntimeEnv({ + worktreePath: result.worktreePath, + branchName: result.branchName, + }); + + expect(second.PASEO_WORKTREE_PORT).toBe(first.PASEO_WORKTREE_PORT); + }); + + it("fails runtime env resolution when persisted port is in use", async () => { + const result = await createWorktree({ + branchName: "main", + cwd: repoDir, + baseBranch: "main", + worktreeSlug: "runtime-env-port-conflict", + runSetup: false, + paseoHome, + }); + + const env = await resolveWorktreeRuntimeEnv({ + worktreePath: result.worktreePath, + branchName: result.branchName, + }); + const port = Number(env.PASEO_WORKTREE_PORT); + + const server = net.createServer(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, () => resolve()); + }); + + await expect( + resolveWorktreeRuntimeEnv({ + worktreePath: result.worktreePath, + branchName: result.branchName, + }) + ).rejects.toThrow(`Persisted worktree port ${port} is already in use`); + + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + }); + it("cleans up worktree if setup command fails", async () => { // Create paseo.json with failing setup command const paseoConfig = { @@ -325,7 +446,47 @@ describe("paseo worktree manager", () => { rmSync(tempDir, { recursive: true, force: true }); }); - it("lists and deletes paseo worktrees under ~/.paseo/worktrees/{project}", async () => { + it("isolates worktree roots for repositories that share the same directory name", async () => { + const repoA = join(tempDir, "team-a", "test-repo"); + const repoB = join(tempDir, "team-b", "test-repo"); + + for (const repo of [repoA, repoB]) { + execSync(`mkdir -p ${repo}`); + execSync("git init -b main", { cwd: repo }); + execSync("git config user.email 'test@test.com'", { cwd: repo }); + execSync("git config user.name 'Test'", { cwd: repo }); + execSync("echo 'hello' > file.txt", { cwd: repo }); + execSync("git add .", { cwd: repo }); + execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repo }); + } + + const fromRepoA = await createWorktree({ + branchName: "main", + cwd: repoA, + baseBranch: "main", + worktreeSlug: "alpha", + paseoHome, + }); + const fromRepoB = await createWorktree({ + branchName: "main", + cwd: repoB, + baseBranch: "main", + worktreeSlug: "alpha", + paseoHome, + }); + + expect(dirname(fromRepoA.worktreePath)).not.toBe(dirname(fromRepoB.worktreePath)); + expect(fromRepoA.worktreePath.endsWith("alpha-1")).toBe(false); + expect(fromRepoB.worktreePath.endsWith("alpha-1")).toBe(false); + + const repoAWorktrees = await listPaseoWorktrees({ cwd: repoA, paseoHome }); + const repoBWorktrees = await listPaseoWorktrees({ cwd: repoB, paseoHome }); + + expect(repoAWorktrees.map((entry) => entry.path)).toEqual([fromRepoA.worktreePath]); + expect(repoBWorktrees.map((entry) => entry.path)).toEqual([fromRepoB.worktreePath]); + }); + + it("lists and deletes paseo worktrees under ~/.paseo/worktrees/{hash}", async () => { const first = await createWorktree({ branchName: "main", cwd: repoDir, diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index e87c41a26..ed195aeb5 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -1,10 +1,17 @@ -import { exec } from "child_process"; +import { exec, spawn } from "child_process"; import { promisify } from "util"; import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync } from "fs"; import { join, basename, dirname, resolve, sep } from "path"; import net from "node:net"; +import { createHash } from "node:crypto"; import { createNameId } from "mnemonic-id"; -import { normalizeBaseRefName, writePaseoWorktreeMetadata } from "./worktree-metadata.js"; +import { + normalizeBaseRefName, + readPaseoWorktreeMetadata, + readPaseoWorktreeRuntimePort, + writePaseoWorktreeMetadata, + writePaseoWorktreeRuntimeMetadata, +} from "./worktree-metadata.js"; import { resolvePaseoHome } from "../server/paseo-home.js"; interface PaseoConfig { @@ -26,14 +33,52 @@ export interface WorktreeConfig { worktreePath: string; } +export type WorktreeRuntimeEnv = { + PASEO_SOURCE_CHECKOUT_PATH: string; + PASEO_ROOT_PATH: string; + PASEO_WORKTREE_PATH: string; + PASEO_BRANCH_NAME: string; + PASEO_WORKTREE_PORT: string; +}; + export type WorktreeSetupCommandResult = { command: string; cwd: string; stdout: string; stderr: string; exitCode: number | null; + durationMs: number; }; +export type WorktreeSetupCommandProgressEvent = + | { + type: "command_started"; + index: number; + total: number; + command: string; + cwd: string; + } + | { + type: "output"; + index: number; + total: number; + command: string; + cwd: string; + stream: "stdout" | "stderr"; + chunk: string; + } + | { + type: "command_completed"; + index: number; + total: number; + command: string; + cwd: string; + exitCode: number | null; + durationMs: number; + stdout: string; + stderr: string; + }; + export interface WorktreeTerminalConfig { name?: string; command: string; @@ -153,6 +198,7 @@ async function execSetupCommand( command: string, options: { cwd: string; env: NodeJS.ProcessEnv } ): Promise { + const startedAt = Date.now(); try { const { stdout, stderr } = await execAsync(command, { cwd: options.cwd, @@ -165,6 +211,7 @@ async function execSetupCommand( stdout: stdout ?? "", stderr: stderr ?? "", exitCode: 0, + durationMs: Date.now() - startedAt, }; } catch (error: any) { return { @@ -175,10 +222,105 @@ async function execSetupCommand( error?.stderr ?? (error instanceof Error ? error.message : String(error)), exitCode: typeof error?.code === "number" ? error.code : null, + durationMs: Date.now() - startedAt, }; } } +async function execSetupCommandStreamed(options: { + command: string; + cwd: string; + env: NodeJS.ProcessEnv; + index: number; + total: number; + onEvent?: (event: WorktreeSetupCommandProgressEvent) => void; +}): Promise { + return new Promise((resolve) => { + const startedAt = Date.now(); + const stdoutChunks: string[] = []; + const stderrChunks: string[] = []; + let settled = false; + + const finish = (exitCode: number | null) => { + if (settled) { + return; + } + settled = true; + const result: WorktreeSetupCommandResult = { + command: options.command, + cwd: options.cwd, + stdout: stdoutChunks.join(""), + stderr: stderrChunks.join(""), + exitCode, + durationMs: Date.now() - startedAt, + }; + options.onEvent?.({ + type: "command_completed", + index: options.index, + total: options.total, + command: options.command, + cwd: options.cwd, + exitCode: result.exitCode, + durationMs: result.durationMs, + stdout: result.stdout, + stderr: result.stderr, + }); + resolve(result); + }; + + options.onEvent?.({ + type: "command_started", + index: options.index, + total: options.total, + command: options.command, + cwd: options.cwd, + }); + + const child = spawn("/bin/bash", ["-lc", options.command], { + cwd: options.cwd, + env: options.env, + stdio: ["ignore", "pipe", "pipe"], + }); + + child.stdout?.on("data", (chunk: Buffer | string) => { + const text = chunk.toString(); + stdoutChunks.push(text); + options.onEvent?.({ + type: "output", + index: options.index, + total: options.total, + command: options.command, + cwd: options.cwd, + stream: "stdout", + chunk: text, + }); + }); + + child.stderr?.on("data", (chunk: Buffer | string) => { + const text = chunk.toString(); + stderrChunks.push(text); + options.onEvent?.({ + type: "output", + index: options.index, + total: options.total, + command: options.command, + cwd: options.cwd, + stream: "stderr", + chunk: text, + }); + }); + + child.on("error", (error) => { + stderrChunks.push(error instanceof Error ? error.message : String(error)); + finish(null); + }); + + child.on("close", (code) => { + finish(typeof code === "number" ? code : null); + }); + }); +} + async function getAvailablePort(): Promise { return new Promise((resolve, reject) => { const server = net.createServer(); @@ -200,6 +342,29 @@ async function getAvailablePort(): Promise { }); } +async function assertPortAvailable(port: number): Promise { + await new Promise((resolve, reject) => { + const server = net.createServer(); + server.once("error", (error: NodeJS.ErrnoException) => { + const message = error?.code === "EADDRINUSE" + ? `Persisted worktree port ${port} is already in use` + : error instanceof Error + ? error.message + : String(error); + reject(new Error(message)); + }); + server.listen(port, () => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + }); +} + async function inferRepoRootPathFromWorktreePath(worktreePath: string): Promise { try { const commonDir = await getGitCommonDir(worktreePath); @@ -233,6 +398,8 @@ export async function runWorktreeSetupCommands(options: { branchName: string; cleanupOnFailure: boolean; repoRootPath?: string; + runtimeEnv?: WorktreeRuntimeEnv; + onEvent?: (event: WorktreeSetupCommandProgressEvent) => void; }): Promise { // Read paseo.json from the worktree (it will have the same content as the source repo) const setupCommands = getWorktreeSetupCommands(options.worktreePath); @@ -240,29 +407,33 @@ export async function runWorktreeSetupCommands(options: { return []; } - const repoRootPath = - options.repoRootPath ?? (await inferRepoRootPathFromWorktreePath(options.worktreePath)); - const worktreePort = await getAvailablePort(); - + const runtimeEnv = + options.runtimeEnv ?? + (await resolveWorktreeRuntimeEnv({ + worktreePath: options.worktreePath, + branchName: options.branchName, + ...(options.repoRootPath ? { repoRootPath: options.repoRootPath } : {}), + })); const setupEnv = { ...process.env, - // Source checkout path is the original git repo root (shared across worktrees), not the - // worktree itself. This allows setup scripts to copy local files (e.g. .env) from the - // source checkout. - PASEO_SOURCE_CHECKOUT_PATH: repoRootPath, - // Backward-compatible alias. - PASEO_ROOT_PATH: repoRootPath, - PASEO_WORKTREE_PATH: options.worktreePath, - PASEO_BRANCH_NAME: options.branchName, - PASEO_WORKTREE_PORT: String(worktreePort), + ...runtimeEnv, }; const results: WorktreeSetupCommandResult[] = []; - for (const cmd of setupCommands) { - const result = await execSetupCommand(cmd, { - cwd: options.worktreePath, - env: setupEnv, - }); + for (const [index, cmd] of setupCommands.entries()) { + const result = options.onEvent + ? await execSetupCommandStreamed({ + command: cmd, + cwd: options.worktreePath, + env: setupEnv, + index: index + 1, + total: setupCommands.length, + onEvent: options.onEvent, + }) + : await execSetupCommand(cmd, { + cwd: options.worktreePath, + env: setupEnv, + }); results.push(result); if (result.exitCode !== 0) { @@ -302,6 +473,40 @@ async function resolveBranchNameForWorktreePath(worktreePath: string): Promise { + const repoRootPath = + options.repoRootPath ?? (await inferRepoRootPathFromWorktreePath(options.worktreePath)); + const branchName = + options.branchName ?? (await resolveBranchNameForWorktreePath(options.worktreePath)); + + let worktreePort = readPaseoWorktreeRuntimePort(options.worktreePath); + if (worktreePort === null) { + worktreePort = await getAvailablePort(); + const metadata = readPaseoWorktreeMetadata(options.worktreePath); + if (metadata) { + writePaseoWorktreeRuntimeMetadata(options.worktreePath, { worktreePort }); + } + } else { + await assertPortAvailable(worktreePort); + } + + return { + // Source checkout path is the original git repo root (shared across worktrees), not the + // worktree itself. This allows setup scripts to copy local files (e.g. .env) from the + // source checkout. + PASEO_SOURCE_CHECKOUT_PATH: repoRootPath, + // Backward-compatible alias. + PASEO_ROOT_PATH: repoRootPath, + PASEO_WORKTREE_PATH: options.worktreePath, + PASEO_BRANCH_NAME: branchName, + PASEO_WORKTREE_PORT: String(worktreePort), + }; +} + export async function runWorktreeDestroyCommands(options: { worktreePath: string; branchName?: string; @@ -435,91 +640,37 @@ function generateWorktreeSlug(): string { return createNameId(); } -function tryParseGitRemote(remoteUrl: string): { host?: string; path: string } | null { - const cleaned = remoteUrl.trim().replace(/\.git$/i, ""); - if (!cleaned) { - return null; - } +const WORKTREE_PROJECT_HASH_LENGTH = 8; - if (cleaned.includes("://")) { - try { - const url = new URL(cleaned); - return { host: url.hostname, path: url.pathname.replace(/^\/+/, "") }; - } catch { - // fall through - } +function deriveShortAlphanumericHash(value: string): string { + const digest = createHash("sha256").update(value).digest(); + let hashValue = 0n; + for (let index = 0; index < 8; index += 1) { + hashValue = (hashValue << 8n) | BigInt(digest[index] ?? 0); } - - // Support scp-like syntax: git@github.com:owner/repo - const scpMatch = cleaned.match(/^(?:.+@)?([^:]+):(.+)$/); - if (scpMatch?.[2]) { - return { host: scpMatch[1], path: scpMatch[2].replace(/^\/+/, "") }; - } - - return { path: cleaned.replace(/^\/+/, "") }; + return hashValue + .toString(36) + .padStart(13, "0") + .slice(0, WORKTREE_PROJECT_HASH_LENGTH); } -function inferProjectNameFromRemote(remoteUrl: string): string | null { - const parsed = tryParseGitRemote(remoteUrl); - if (!parsed?.path) { - return null; - } - const segments = parsed.path.split("/").filter((segment) => segment.length > 0); - if (segments.length === 0) { - return null; - } - return segments[segments.length - 1] ?? null; -} - -async function detectWorktreeProject(cwd: string): Promise { - // First try to get project name from remote URL (consistent across worktrees) +export async function deriveWorktreeProjectHash(cwd: string): Promise { try { - const { stdout } = await execAsync("git config --get remote.origin.url", { - cwd, - env: READ_ONLY_GIT_ENV, - }); - const remote = stdout.trim(); - if (remote) { - const inferred = inferProjectNameFromRemote(remote); - if (inferred) { - const projected = slugify(inferred); - if (projected) { - return projected; - } - } - } + const commonDir = await getGitCommonDir(cwd); + const normalizedCommonDir = normalizePathForOwnership(commonDir); + const repoRoot = basename(normalizedCommonDir) === ".git" + ? dirname(normalizedCommonDir) + : normalizedCommonDir; + return deriveShortAlphanumericHash(repoRoot); } catch { - // ignore - } - - // Fallback: derive from git common dir parent (works for both main checkout and worktrees) - // For normal repos: .git -> parent is repo root - // For bare repos: the bare repo dir itself - // For worktrees: common dir points to main repo's .git - try { - const { stdout } = await execAsync("git rev-parse --git-common-dir", { - cwd, - env: READ_ONLY_GIT_ENV, - }); - const commonDir = stdout.trim(); - const normalized = realpathSync(commonDir); - // If common dir ends with .git, use its parent. Otherwise use its parent too (bare repo case). - const repoRoot = basename(normalized) === ".git" ? dirname(normalized) : dirname(normalized); - return slugify(basename(repoRoot)); - } catch { - // Last resort: use cwd - try { - return slugify(basename(realpathSync(cwd))); - } catch { - return slugify(basename(cwd)); - } + return deriveShortAlphanumericHash(normalizePathForOwnership(cwd)); } } async function getPaseoWorktreesRoot(cwd: string, paseoHome?: string): Promise { const home = paseoHome ? resolve(paseoHome) : resolvePaseoHome(); - const project = await detectWorktreeProject(cwd); - return join(home, "worktrees", project); + const projectHash = await deriveWorktreeProjectHash(cwd); + return join(home, "worktrees", projectHash); } function normalizePathForOwnership(input: string): string { @@ -530,11 +681,19 @@ function normalizePathForOwnership(input: string): string { } } +function resolveRepoRootFromGitCommonDir(commonDir: string): string { + const normalizedCommonDir = normalizePathForOwnership(commonDir); + return basename(normalizedCommonDir) === ".git" + ? dirname(normalizedCommonDir) + : normalizedCommonDir; +} + export async function isPaseoOwnedWorktreeCwd( cwd: string, options?: { paseoHome?: string } ): Promise { const gitCommonDir = await getGitCommonDir(cwd); + const repoRoot = resolveRepoRootFromGitCommonDir(gitCommonDir); const worktreesRoot = await getPaseoWorktreesRoot(cwd, options?.paseoHome); const resolvedRoot = normalizePathForOwnership(worktreesRoot) + sep; const resolvedCwd = normalizePathForOwnership(cwd); @@ -542,7 +701,7 @@ export async function isPaseoOwnedWorktreeCwd( if (!resolvedCwd.startsWith(resolvedRoot)) { return { allowed: false, - repoRoot: gitCommonDir, + repoRoot, worktreeRoot: worktreesRoot, worktreePath: resolvedCwd, }; @@ -555,7 +714,7 @@ export async function isPaseoOwnedWorktreeCwd( }); return { allowed, - repoRoot: gitCommonDir, + repoRoot, worktreeRoot: worktreesRoot, worktreePath: resolvedCwd, }; diff --git a/packages/website/package.json b/packages/website/package.json index bb0c84647..e8747a82d 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/website", - "version": "0.1.4", + "version": "0.1.13", "private": true, "type": "module", "scripts": { diff --git a/packages/website/public/schemas/paseo.config.v1.json b/packages/website/public/schemas/paseo.config.v1.json index 0bbe1d253..5afb19a64 100644 --- a/packages/website/public/schemas/paseo.config.v1.json +++ b/packages/website/public/schemas/paseo.config.v1.json @@ -95,9 +95,6 @@ "modelsDir": { "type": "string", "minLength": 1 - }, - "autoDownload": { - "type": "boolean" } }, "additionalProperties": false @@ -197,6 +194,9 @@ "dictation": { "type": "object", "properties": { + "enabled": { + "type": "boolean" + }, "stt": { "type": "object", "properties": { @@ -230,6 +230,9 @@ "voiceMode": { "type": "object", "properties": { + "enabled": { + "type": "boolean" + }, "llm": { "type": "object", "properties": { diff --git a/packages/website/src/routeTree.gen.ts b/packages/website/src/routeTree.gen.ts index a12d4d08f..cb6a14641 100644 --- a/packages/website/src/routeTree.gen.ts +++ b/packages/website/src/routeTree.gen.ts @@ -15,6 +15,7 @@ import { Route as IndexRouteImport } from './routes/index' import { Route as DocsIndexRouteImport } from './routes/docs/index' import { Route as DocsWorktreesRouteImport } from './routes/docs/worktrees' import { Route as DocsVoiceRouteImport } from './routes/docs/voice' +import { Route as DocsUpdatesRouteImport } from './routes/docs/updates' import { Route as DocsSecurityRouteImport } from './routes/docs/security' import { Route as DocsConfigurationRouteImport } from './routes/docs/configuration' import { Route as DocsCliRouteImport } from './routes/docs/cli' @@ -50,6 +51,11 @@ const DocsVoiceRoute = DocsVoiceRouteImport.update({ path: '/voice', getParentRoute: () => DocsRoute, } as any) +const DocsUpdatesRoute = DocsUpdatesRouteImport.update({ + id: '/updates', + path: '/updates', + getParentRoute: () => DocsRoute, +} as any) const DocsSecurityRoute = DocsSecurityRouteImport.update({ id: '/security', path: '/security', @@ -79,6 +85,7 @@ export interface FileRoutesByFullPath { '/docs/cli': typeof DocsCliRoute '/docs/configuration': typeof DocsConfigurationRoute '/docs/security': typeof DocsSecurityRoute + '/docs/updates': typeof DocsUpdatesRoute '/docs/voice': typeof DocsVoiceRoute '/docs/worktrees': typeof DocsWorktreesRoute '/docs/': typeof DocsIndexRoute @@ -90,6 +97,7 @@ export interface FileRoutesByTo { '/docs/cli': typeof DocsCliRoute '/docs/configuration': typeof DocsConfigurationRoute '/docs/security': typeof DocsSecurityRoute + '/docs/updates': typeof DocsUpdatesRoute '/docs/voice': typeof DocsVoiceRoute '/docs/worktrees': typeof DocsWorktreesRoute '/docs': typeof DocsIndexRoute @@ -103,6 +111,7 @@ export interface FileRoutesById { '/docs/cli': typeof DocsCliRoute '/docs/configuration': typeof DocsConfigurationRoute '/docs/security': typeof DocsSecurityRoute + '/docs/updates': typeof DocsUpdatesRoute '/docs/voice': typeof DocsVoiceRoute '/docs/worktrees': typeof DocsWorktreesRoute '/docs/': typeof DocsIndexRoute @@ -117,6 +126,7 @@ export interface FileRouteTypes { | '/docs/cli' | '/docs/configuration' | '/docs/security' + | '/docs/updates' | '/docs/voice' | '/docs/worktrees' | '/docs/' @@ -128,6 +138,7 @@ export interface FileRouteTypes { | '/docs/cli' | '/docs/configuration' | '/docs/security' + | '/docs/updates' | '/docs/voice' | '/docs/worktrees' | '/docs' @@ -140,6 +151,7 @@ export interface FileRouteTypes { | '/docs/cli' | '/docs/configuration' | '/docs/security' + | '/docs/updates' | '/docs/voice' | '/docs/worktrees' | '/docs/' @@ -195,6 +207,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DocsVoiceRouteImport parentRoute: typeof DocsRoute } + '/docs/updates': { + id: '/docs/updates' + path: '/updates' + fullPath: '/docs/updates' + preLoaderRoute: typeof DocsUpdatesRouteImport + parentRoute: typeof DocsRoute + } '/docs/security': { id: '/docs/security' path: '/security' @@ -231,6 +250,7 @@ interface DocsRouteChildren { DocsCliRoute: typeof DocsCliRoute DocsConfigurationRoute: typeof DocsConfigurationRoute DocsSecurityRoute: typeof DocsSecurityRoute + DocsUpdatesRoute: typeof DocsUpdatesRoute DocsVoiceRoute: typeof DocsVoiceRoute DocsWorktreesRoute: typeof DocsWorktreesRoute DocsIndexRoute: typeof DocsIndexRoute @@ -241,6 +261,7 @@ const DocsRouteChildren: DocsRouteChildren = { DocsCliRoute: DocsCliRoute, DocsConfigurationRoute: DocsConfigurationRoute, DocsSecurityRoute: DocsSecurityRoute, + DocsUpdatesRoute: DocsUpdatesRoute, DocsVoiceRoute: DocsVoiceRoute, DocsWorktreesRoute: DocsWorktreesRoute, DocsIndexRoute: DocsIndexRoute, diff --git a/packages/website/src/routes/docs.tsx b/packages/website/src/routes/docs.tsx index dfd820c29..fd1a03e68 100644 --- a/packages/website/src/routes/docs.tsx +++ b/packages/website/src/routes/docs.tsx @@ -7,6 +7,7 @@ export const Route = createFileRoute('/docs')({ const navigation = [ { name: 'Getting started', href: '/docs' }, + { name: 'Updates', href: '/docs/updates' }, { name: 'Voice', href: '/docs/voice' }, { name: 'Git worktrees', href: '/docs/worktrees' }, { name: 'CLI', href: '/docs/cli' }, diff --git a/packages/website/src/routes/docs/configuration.tsx b/packages/website/src/routes/docs/configuration.tsx index d22f5b14a..dd5276134 100644 --- a/packages/website/src/routes/docs/configuration.tsx +++ b/packages/website/src/routes/docs/configuration.tsx @@ -177,7 +177,7 @@ docker run --rm -i \\
  • OPENAI_API_KEY — override OpenAI provider key
  • PASEO_VOICE_LLM_PROVIDER — override voice LLM provider (claude, codex, opencode)
  • PASEO_DICTATION_STT_PROVIDER, PASEO_VOICE_STT_PROVIDER, PASEO_VOICE_TTS_PROVIDER — override voice provider selection (local or openai)
  • -
  • PASEO_LOCAL_MODELS_DIR and PASEO_LOCAL_AUTO_DOWNLOAD — control local model directory and download behavior
  • +
  • PASEO_LOCAL_MODELS_DIR — control local model directory
  • PASEO_DICTATION_LOCAL_STT_MODEL — override local dictation STT model
  • PASEO_VOICE_LOCAL_STT_MODEL, PASEO_VOICE_LOCAL_TTS_MODEL — override local voice STT/TTS models
  • PASEO_VOICE_LOCAL_TTS_SPEAKER_ID, PASEO_VOICE_LOCAL_TTS_SPEED — optional local voice TTS tuning
  • diff --git a/packages/website/src/routes/docs/index.tsx b/packages/website/src/routes/docs/index.tsx index 69dcfe5ec..447727ba9 100644 --- a/packages/website/src/routes/docs/index.tsx +++ b/packages/website/src/routes/docs/index.tsx @@ -101,6 +101,11 @@ function GettingStarted() {

    Next

    diff --git a/packages/website/src/routes/index.tsx b/packages/website/src/routes/index.tsx index 28b1d8ab2..be1f88148 100644 --- a/packages/website/src/routes/index.tsx +++ b/packages/website/src/routes/index.tsx @@ -1,8 +1,12 @@ import * as React from 'react' import { createFileRoute } from '@tanstack/react-router' import { CursorFieldProvider } from '~/components/butterfly' +import websitePackage from '../../package.json' import '~/styles.css' +const desktopVersion = websitePackage.version +const macDownloadHref = `https://github.com/getpaseo/paseo/releases/download/v${desktopVersion}/Paseo_${desktopVersion}_aarch64.dmg` + export const Route = createFileRoute('/')({ head: () => ({ meta: [ @@ -108,7 +112,7 @@ function Hero() { return (

    - Run Codex and Claude Code locally from your phone and desktop + Run Codex and Claude Code on your machine from your phone and desktop

    Bring your dev environment anywhere, with voice support built in. @@ -194,7 +198,7 @@ function GetStarted() { npm install -g @getpaseo/cli && paseo

    ) @@ -230,6 +252,20 @@ function AppleIcon(props: React.SVGProps) { ) } +function AndroidIcon(props: React.SVGProps) { + return ( + + ) +} + function GlobeIcon(props: React.SVGProps) { return ( /dev/null; then tmux new-session -d -s \"$SESSION_NAME\" -c \"$PWD\"; tmux send-keys -t \"$SESSION_NAME\":0.0 \"npm run dev\" Enter; fi" } ] } diff --git a/paseo@0.1.0 b/paseo@0.1.0 deleted file mode 100644 index 75f939caa..000000000 --- a/paseo@0.1.0 +++ /dev/null @@ -1,4 +0,0 @@ - -> paseo@0.1.0 cli -> npx tsx packages/cli/src/index.js logs cli --filter tools -